forked from Yara724/api
Compare commits
21 Commits
7c59c2407e
...
5e4897f609
| Author | SHA1 | Date | |
|---|---|---|---|
| 5e4897f609 | |||
| a79c3ca05f | |||
| 36fa1c552e | |||
| 9742fecc11 | |||
| 8007c30efd | |||
| 144f8f3e2a | |||
| 8674dd8762 | |||
|
|
f39c50aeb0 | ||
| 2fda740171 | |||
| 72e5bd616c | |||
| 4b41a60f64 | |||
| 9310285bd4 | |||
|
|
2a8b66bc16 | ||
| 9168a6bdcd | |||
|
|
057bedeb0c | ||
| 808a3b8526 | |||
|
|
da7a4f8890 | ||
| 21e55012be | |||
|
|
385757c3a0 | ||
| a1b122a33b | |||
|
|
aec9e76918 |
142
.woodpecker.yml
Normal file
142
.woodpecker.yml
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
# yara724/api — development deployment (Deploy-Develop)
|
||||||
|
# Manual tasks: see ci-cd/TASK.md
|
||||||
|
# Requires: repo marked Trusted in Woodpecker (host volume mounts)
|
||||||
|
|
||||||
|
when:
|
||||||
|
- event: push
|
||||||
|
branch: main
|
||||||
|
- event: manual
|
||||||
|
|
||||||
|
skip_clone: true
|
||||||
|
|
||||||
|
variables:
|
||||||
|
- &host_workspace /data/1-deploy/gitea/yara724/api
|
||||||
|
- &workspace_volume /data/1-deploy/gitea/yara724/api:/workspace
|
||||||
|
- &host_ssh /home/talieh/.ssh:/root/.ssh:ro
|
||||||
|
- &pipeline_env
|
||||||
|
WORKSPACE: *host_workspace
|
||||||
|
PROJECT_NAME: Yara724 API
|
||||||
|
ENVIRONMENT: Development
|
||||||
|
APPLICATION_URL: https://y724-user.ittalie.ir/api
|
||||||
|
GIT_COMMIT_URL: https://git.ittalie.com/Yara724/api/commit/
|
||||||
|
GIT_BRANCH: main
|
||||||
|
COMPOSE_FILE: docker-compose.yml
|
||||||
|
SUCCESS_COLOR: "#36a64f"
|
||||||
|
FAILURE_COLOR: "#dc3545"
|
||||||
|
|
||||||
|
steps:
|
||||||
|
pull:
|
||||||
|
image: docker.arvancloud.ir/alpine/git:latest
|
||||||
|
environment:
|
||||||
|
<<: *pipeline_env
|
||||||
|
GIT_SSH_COMMAND: ssh -o UserKnownHostsFile=/tmp/known_hosts -o StrictHostKeyChecking=yes
|
||||||
|
volumes:
|
||||||
|
- *workspace_volume
|
||||||
|
- *host_ssh
|
||||||
|
commands:
|
||||||
|
- git config --global --add safe.directory /workspace
|
||||||
|
- mkdir -p /tmp && ssh-keyscan -H git.ittalie.com >> /tmp/known_hosts
|
||||||
|
- cd /workspace
|
||||||
|
- git pull origin main
|
||||||
|
- date +%s > /workspace/.wp-deploy-start
|
||||||
|
|
||||||
|
deploy:
|
||||||
|
image: docker.arvancloud.ir/docker:24-cli
|
||||||
|
environment:
|
||||||
|
<<: *pipeline_env
|
||||||
|
volumes:
|
||||||
|
- /var/run/docker.sock:/var/run/docker.sock
|
||||||
|
- *workspace_volume
|
||||||
|
commands:
|
||||||
|
- cd /workspace
|
||||||
|
- docker compose -f docker-compose.yml up -d --build
|
||||||
|
|
||||||
|
notify-success:
|
||||||
|
image: docker.arvancloud.ir/alpine:3.20
|
||||||
|
environment:
|
||||||
|
<<: *pipeline_env
|
||||||
|
ROCKETCHAT_WEBHOOK:
|
||||||
|
from_secret: rocketchat_webhook
|
||||||
|
volumes:
|
||||||
|
- *workspace_volume
|
||||||
|
when:
|
||||||
|
- status: success
|
||||||
|
commands:
|
||||||
|
- apk add --no-cache curl jq git > /dev/null
|
||||||
|
- |
|
||||||
|
set -euo pipefail
|
||||||
|
git config --global --add safe.directory /workspace
|
||||||
|
cd /workspace
|
||||||
|
|
||||||
|
COMMIT_HASH="$(git rev-parse --short HEAD)"
|
||||||
|
DEPLOY_START="$(cat /workspace/.wp-deploy-start)"
|
||||||
|
DEPLOY_END="$(date +%s)"
|
||||||
|
DEPLOY_DURATION="$((DEPLOY_END - DEPLOY_START))"
|
||||||
|
|
||||||
|
COMMIT_1="$(git log -1 --pretty=format:'%s')"
|
||||||
|
COMMIT_2="$(git log -2 --pretty=format:'%s' | tail -n1)"
|
||||||
|
COMMIT_3="$(git log -3 --pretty=format:'%s' | tail -n1)"
|
||||||
|
|
||||||
|
TITLE="✅ $PROJECT_NAME - $ENVIRONMENT ✅"
|
||||||
|
TEXT="🌐 **URL**: $APPLICATION_URL
|
||||||
|
|
||||||
|
🔖 **Commit Hash**: [$COMMIT_HASH]($GIT_COMMIT_URL$COMMIT_HASH)
|
||||||
|
|
||||||
|
📝 **Recent Changes**:
|
||||||
|
|
||||||
|
• $COMMIT_1
|
||||||
|
|
||||||
|
• $COMMIT_2
|
||||||
|
|
||||||
|
• $COMMIT_3
|
||||||
|
|
||||||
|
⏱️ **Deployment Duration**: $${DEPLOY_DURATION}s"
|
||||||
|
|
||||||
|
payload="$(jq -n \
|
||||||
|
--arg title "$TITLE" \
|
||||||
|
--arg text "$TEXT" \
|
||||||
|
--arg color "$SUCCESS_COLOR" \
|
||||||
|
'{text: $title, attachments: [{text: $text, color: $color}]}')"
|
||||||
|
|
||||||
|
curl -fsS -H "Content-Type: application/json" -d "$payload" "$ROCKETCHAT_WEBHOOK" >/dev/null
|
||||||
|
rm -f /workspace/.wp-deploy-start
|
||||||
|
|
||||||
|
notify-failure:
|
||||||
|
image: docker.arvancloud.ir/alpine:3.20
|
||||||
|
environment:
|
||||||
|
<<: *pipeline_env
|
||||||
|
ROCKETCHAT_WEBHOOK:
|
||||||
|
from_secret: rocketchat_webhook
|
||||||
|
volumes:
|
||||||
|
- *workspace_volume
|
||||||
|
when:
|
||||||
|
- status: failure
|
||||||
|
commands:
|
||||||
|
- apk add --no-cache curl jq git > /dev/null
|
||||||
|
- |
|
||||||
|
set -euo pipefail
|
||||||
|
git config --global --add safe.directory /workspace
|
||||||
|
cd /workspace
|
||||||
|
|
||||||
|
COMMIT_HASH="$(git rev-parse --short HEAD 2>/dev/null || echo unknown)"
|
||||||
|
TITLE="💥 $PROJECT_NAME - $ENVIRONMENT 💥"
|
||||||
|
TEXT="━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||||
|
🌐 **Application URL**
|
||||||
|
|
||||||
|
$APPLICATION_URL
|
||||||
|
|
||||||
|
🔖 **Commit**
|
||||||
|
|
||||||
|
\`$COMMIT_HASH\`
|
||||||
|
|
||||||
|
⚠️ **Pipeline failed** — check Woodpecker for the failing step.
|
||||||
|
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||||
|
|
||||||
|
payload="$(jq -n \
|
||||||
|
--arg title "$TITLE" \
|
||||||
|
--arg text "$TEXT" \
|
||||||
|
--arg color "$FAILURE_COLOR" \
|
||||||
|
'{text: $title, attachments: [{text: $text, color: $color}]}')"
|
||||||
|
|
||||||
|
curl -fsS -H "Content-Type: application/json" -d "$payload" "$ROCKETCHAT_WEBHOOK" >/dev/null || true
|
||||||
|
rm -f /workspace/.wp-deploy-start
|
||||||
BIN
assets/Vazirmatn-Bold.ttf
Normal file
BIN
assets/Vazirmatn-Bold.ttf
Normal file
Binary file not shown.
BIN
assets/Vazirmatn-Regular.ttf
Normal file
BIN
assets/Vazirmatn-Regular.ttf
Normal file
Binary file not shown.
@@ -8139,7 +8139,7 @@ export class ClaimRequestManagementService {
|
|||||||
file: Express.Multer.File,
|
file: Express.Multer.File,
|
||||||
currentUserId: string,
|
currentUserId: string,
|
||||||
actor?: { sub: string; role?: string },
|
actor?: { sub: string; role?: string },
|
||||||
options?: { v3InPersonFlow?: boolean; skipMetalPlate?: boolean },
|
options?: { v3InPersonFlow?: boolean; skipMetalPlate?: boolean; requiresFileMakerApproval?: boolean },
|
||||||
): Promise<UploadRequiredDocumentV2ResponseDto> {
|
): Promise<UploadRequiredDocumentV2ResponseDto> {
|
||||||
try {
|
try {
|
||||||
const claimCase = await this.claimCaseDbService.findById(claimRequestId);
|
const claimCase = await this.claimCaseDbService.findById(claimRequestId);
|
||||||
@@ -8334,12 +8334,14 @@ export class ClaimRequestManagementService {
|
|||||||
progressAfterDoc.capturePhaseDocsComplete
|
progressAfterDoc.capturePhaseDocsComplete
|
||||||
) {
|
) {
|
||||||
captureStepCompletedOnThisUpload = true;
|
captureStepCompletedOnThisUpload = true;
|
||||||
|
|
||||||
|
// Advance to walk-around video step (UPLOADING_REQUIRED_DOCUMENTS /
|
||||||
|
// UPLOAD_REQUIRED_DOCUMENTS). For V4/V5 the claim is finalised in
|
||||||
|
// setVideoCaptureV3 (car-capture), which is the last FileReviewer
|
||||||
|
// action before handing off to the damage expert.
|
||||||
$set["status"] = ClaimCaseStatus.UPLOADING_REQUIRED_DOCUMENTS;
|
$set["status"] = ClaimCaseStatus.UPLOADING_REQUIRED_DOCUMENTS;
|
||||||
$set["workflow.currentStep"] =
|
$set["workflow.currentStep"] = ClaimWorkflowStep.UPLOAD_REQUIRED_DOCUMENTS;
|
||||||
ClaimWorkflowStep.UPLOAD_REQUIRED_DOCUMENTS;
|
$set["workflow.nextStep"] = ClaimWorkflowStep.USER_SUBMISSION_COMPLETE;
|
||||||
$set["workflow.nextStep"] = options?.v3InPersonFlow
|
|
||||||
? ClaimWorkflowStep.UPLOAD_REQUIRED_DOCUMENTS
|
|
||||||
: ClaimWorkflowStep.USER_SUBMISSION_COMPLETE;
|
|
||||||
|
|
||||||
completedStepsEntries.push(
|
completedStepsEntries.push(
|
||||||
ClaimWorkflowStep.CAPTURE_PART_DAMAGES,
|
ClaimWorkflowStep.CAPTURE_PART_DAMAGES,
|
||||||
@@ -8354,8 +8356,9 @@ export class ClaimRequestManagementService {
|
|||||||
timestamp: new Date(),
|
timestamp: new Date(),
|
||||||
metadata: {
|
metadata: {
|
||||||
stepKey: ClaimWorkflowStep.CAPTURE_PART_DAMAGES,
|
stepKey: ClaimWorkflowStep.CAPTURE_PART_DAMAGES,
|
||||||
description:
|
description: options?.v3InPersonFlow
|
||||||
"Angles, damaged parts, and capture-phase vehicle evidence (chassis, engine, metal plate) are complete. Please upload remaining required documents.",
|
? "V4/V5 capture docs complete. Upload walk-around video to finalise."
|
||||||
|
: "Angles, damaged parts, and capture-phase vehicle evidence are complete.",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -8518,7 +8521,9 @@ export class ClaimRequestManagementService {
|
|||||||
|
|
||||||
const message = isCapturePhaseDocUpload
|
const message = isCapturePhaseDocUpload
|
||||||
? captureStepCompletedOnThisUpload
|
? captureStepCompletedOnThisUpload
|
||||||
? "Capture-phase documents and all damage captures are complete. Please proceed to upload the remaining required documents."
|
? options?.v3InPersonFlow
|
||||||
|
? "Capture-phase documents and all damage captures are complete. Upload the walk-around video (car-capture) to finalise."
|
||||||
|
: "Capture-phase documents and all damage captures are complete. Please proceed to upload the remaining required documents."
|
||||||
: remaining > 0
|
: remaining > 0
|
||||||
? `Document saved. ${remaining} capture-phase document(s) still required (chassis, engine, metal plate).`
|
? `Document saved. ${remaining} capture-phase document(s) still required (chassis, engine, metal plate).`
|
||||||
: capturePhaseSequenceMessage(
|
: capturePhaseSequenceMessage(
|
||||||
@@ -10205,6 +10210,18 @@ export class ClaimRequestManagementService {
|
|||||||
base: GetCaptureRequirementsV2ResponseDto,
|
base: GetCaptureRequirementsV2ResponseDto,
|
||||||
): GetCaptureRequirementsV2ResponseDto {
|
): GetCaptureRequirementsV2ResponseDto {
|
||||||
const skipMetalPlate = !!(blame as any)?.isMadeByFileMaker;
|
const skipMetalPlate = !!(blame as any)?.isMadeByFileMaker;
|
||||||
|
|
||||||
|
// For V4/V5 files strip metal-plate keys from the base response entirely
|
||||||
|
// so they never appear in any phase — the front-end should not show them.
|
||||||
|
if (skipMetalPlate) {
|
||||||
|
base = {
|
||||||
|
...base,
|
||||||
|
requiredDocuments: base.requiredDocuments.filter(
|
||||||
|
(d) => !OPTIONAL_CAPTURE_PHASE_DOC_KEYS_V4V5.includes(d.key as any),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const step = claimCase.workflow?.currentStep;
|
const step = claimCase.workflow?.currentStep;
|
||||||
const capturePartDone = this.claimV3StepCompleted(
|
const capturePartDone = this.claimV3StepCompleted(
|
||||||
claimCase,
|
claimCase,
|
||||||
@@ -10355,7 +10372,7 @@ export class ClaimRequestManagementService {
|
|||||||
file,
|
file,
|
||||||
currentUserId,
|
currentUserId,
|
||||||
actor,
|
actor,
|
||||||
{ v3InPersonFlow: true, skipMetalPlate },
|
{ v3InPersonFlow: true, skipMetalPlate, requiresFileMakerApproval: !!(claimCase as any).requiresFileMakerApproval },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -10550,7 +10567,7 @@ export class ClaimRequestManagementService {
|
|||||||
ClaimWorkflowStep.UPLOAD_REQUIRED_DOCUMENTS
|
ClaimWorkflowStep.UPLOAD_REQUIRED_DOCUMENTS
|
||||||
) {
|
) {
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
`Complete all part/angle captures first. Expected ${ClaimWorkflowStep.UPLOAD_REQUIRED_DOCUMENTS}, but current step is ${claimCase.workflow?.currentStep}`,
|
`Complete capture-phase documents (chassis/engine) first. Expected ${ClaimWorkflowStep.UPLOAD_REQUIRED_DOCUMENTS}, but current step is ${claimCase.workflow?.currentStep}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -10573,12 +10590,53 @@ export class ClaimRequestManagementService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.setVideoCaptureV2(
|
// Store the walk-around video (shared V2 logic).
|
||||||
|
const result = await this.setVideoCaptureV2(
|
||||||
claimRequestId,
|
claimRequestId,
|
||||||
fileDetail,
|
fileDetail,
|
||||||
currentUserId,
|
currentUserId,
|
||||||
actor,
|
actor,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// V4/V5 (in-person FileReviewer flow): car-capture is the last step.
|
||||||
|
// Both variants land at WAITING_FOR_DAMAGE_EXPERT here.
|
||||||
|
// For V5 (requiresFileMakerApproval=true), autoSubmitToFanavaranV2OnClaimCompleted
|
||||||
|
// intercepts COMPLETED → WAITING_FOR_FILE_MAKER_APPROVAL after expert review.
|
||||||
|
await this.claimCaseDbService.findByIdAndUpdate(claimRequestId, {
|
||||||
|
$set: {
|
||||||
|
status: ClaimCaseStatus.WAITING_FOR_DAMAGE_EXPERT,
|
||||||
|
claimStatus: ClaimStatus.PENDING,
|
||||||
|
"workflow.currentStep": ClaimWorkflowStep.USER_SUBMISSION_COMPLETE,
|
||||||
|
"workflow.nextStep": ClaimWorkflowStep.EXPERT_DAMAGE_ASSESSMENT,
|
||||||
|
},
|
||||||
|
$push: {
|
||||||
|
"workflow.completedSteps": ClaimWorkflowStep.USER_SUBMISSION_COMPLETE,
|
||||||
|
history: {
|
||||||
|
type: "STEP_COMPLETED",
|
||||||
|
actor: {
|
||||||
|
actorId: new Types.ObjectId(currentUserId),
|
||||||
|
actorName: claimCase.owner?.fullName || "User",
|
||||||
|
actorType: actor?.role === RoleEnum.FILE_REVIEWER ? "file_reviewer" : "user",
|
||||||
|
},
|
||||||
|
timestamp: new Date(),
|
||||||
|
metadata: {
|
||||||
|
stepKey: ClaimWorkflowStep.USER_SUBMISSION_COMPLETE,
|
||||||
|
description: "V4/V5 walk-around video uploaded. Claim ready for damage expert review.",
|
||||||
|
v3InPersonFlow: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Mark the linked blame file as COMPLETED.
|
||||||
|
if (claimCase.blameRequestId) {
|
||||||
|
await this.blameRequestDbService.findByIdAndUpdate(
|
||||||
|
String(claimCase.blameRequestId),
|
||||||
|
{ $set: { status: BlameCaseStatus.COMPLETED } },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
async capturePartV3(
|
async capturePartV3(
|
||||||
|
|||||||
@@ -131,6 +131,13 @@ export class ClaimDetailV2ResponseDto {
|
|||||||
})
|
})
|
||||||
awaitingFactorValidation?: boolean;
|
awaitingFactorValidation?: boolean;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description:
|
||||||
|
"True for V5 files — the FileMaker must approve or reject the claim before it is submitted to fanavaran. False (or absent) for V4 files which go straight through.",
|
||||||
|
example: true,
|
||||||
|
})
|
||||||
|
requiresFileMakerApproval?: boolean;
|
||||||
|
|
||||||
@ApiPropertyOptional({
|
@ApiPropertyOptional({
|
||||||
description:
|
description:
|
||||||
"Slice of `claim.evaluation` exposed to the damage expert. " +
|
"Slice of `claim.evaluation` exposed to the damage expert. " +
|
||||||
|
|||||||
@@ -70,6 +70,13 @@ export class ClaimListItemV2Dto {
|
|||||||
"True in the expert repair-factor validation queue: `status=EXPERT_VALIDATING_REPAIR_FACTORS` (or legacy `WAITING_FOR_INSURER_APPROVAL`) with `claimStatus=UNDER_REVIEW` and `currentStep=EXPERT_COST_EVALUATION`.",
|
"True in the expert repair-factor validation queue: `status=EXPERT_VALIDATING_REPAIR_FACTORS` (or legacy `WAITING_FOR_INSURER_APPROVAL`) with `claimStatus=UNDER_REVIEW` and `currentStep=EXPERT_COST_EVALUATION`.",
|
||||||
})
|
})
|
||||||
awaitingFactorValidation?: boolean;
|
awaitingFactorValidation?: boolean;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description:
|
||||||
|
"True for V5 files — the FileMaker must approve or reject the claim before it is submitted to fanavaran. False (or absent) for V4 files which go straight through.",
|
||||||
|
example: true,
|
||||||
|
})
|
||||||
|
requiresFileMakerApproval?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class GetClaimListV2ResponseDto {
|
export class GetClaimListV2ResponseDto {
|
||||||
|
|||||||
@@ -4309,6 +4309,7 @@ export class ExpertClaimService {
|
|||||||
blameRequestId: c.blameRequestId?.toString(),
|
blameRequestId: c.blameRequestId?.toString(),
|
||||||
createdAt: c.createdAt,
|
createdAt: c.createdAt,
|
||||||
awaitingFactorValidation: claimIsAwaitingExpertFactorValidationV2(c),
|
awaitingFactorValidation: claimIsAwaitingExpertFactorValidationV2(c),
|
||||||
|
requiresFileMakerApproval: !!(c as any).requiresFileMakerApproval,
|
||||||
needsFileReviewerCompletion:
|
needsFileReviewerCompletion:
|
||||||
String(blame?.status ?? "") === "WAITING_FOR_FILE_REVIEWER" ||
|
String(blame?.status ?? "") === "WAITING_FOR_FILE_REVIEWER" ||
|
||||||
(!!blame?.isMadeByFileMaker &&
|
(!!blame?.isMadeByFileMaker &&
|
||||||
@@ -4692,8 +4693,8 @@ export class ExpertClaimService {
|
|||||||
const isFactorValidationPending =
|
const isFactorValidationPending =
|
||||||
claimIsAwaitingExpertFactorValidationV2(claim);
|
claimIsAwaitingExpertFactorValidationV2(claim);
|
||||||
|
|
||||||
// FileMaker: can only view V5 files (requiresFileMakerApproval: true) that they created.
|
// FileMaker: can view any file they created (both during filing and approval phase).
|
||||||
// V4 files do not require FileMaker approval and must not appear in their panel.
|
// The list endpoint already filters to V5-only; detail access is scoped by ownership.
|
||||||
if (actor.role === RoleEnum.FILE_MAKER) {
|
if (actor.role === RoleEnum.FILE_MAKER) {
|
||||||
const isOwn = claimCaseInitiatedByFieldExpert(claim, actor, linkedBlame);
|
const isOwn = claimCaseInitiatedByFieldExpert(claim, actor, linkedBlame);
|
||||||
if (!isOwn) {
|
if (!isOwn) {
|
||||||
@@ -4701,11 +4702,6 @@ export class ExpertClaimService {
|
|||||||
"FileMakers can only view files they created.",
|
"FileMakers can only view files they created.",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (!(claim as any).requiresFileMakerApproval) {
|
|
||||||
throw new ForbiddenException(
|
|
||||||
"This file does not require FileMaker approval.",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
// Fall through to the detail build below
|
// Fall through to the detail build below
|
||||||
} else if (
|
} else if (
|
||||||
// Field experts and FileReviewers can view IN_PERSON expert-initiated claims
|
// Field experts and FileReviewers can view IN_PERSON expert-initiated claims
|
||||||
@@ -5044,6 +5040,7 @@ export class ExpertClaimService {
|
|||||||
carAngles,
|
carAngles,
|
||||||
damagedParts,
|
damagedParts,
|
||||||
awaitingFactorValidation: isFactorValidationPending,
|
awaitingFactorValidation: isFactorValidationPending,
|
||||||
|
requiresFileMakerApproval: !!(claim as any).requiresFileMakerApproval,
|
||||||
evaluation:
|
evaluation:
|
||||||
(evaluationForApi as
|
(evaluationForApi as
|
||||||
| ClaimDetailV2ResponseDto["evaluation"]
|
| ClaimDetailV2ResponseDto["evaluation"]
|
||||||
|
|||||||
@@ -22,20 +22,18 @@ const LABEL_COLUMN_X = COLON_X + 10;
|
|||||||
|
|
||||||
function resolveFontFile(variant: "regular" | "bold"): string {
|
function resolveFontFile(variant: "regular" | "bold"): string {
|
||||||
const file =
|
const file =
|
||||||
variant === "bold"
|
variant === "bold" ? "Vazirmatn-Bold.ttf" : "Vazirmatn-Regular.ttf";
|
||||||
? "NotoKufiArabic-Bold.ttf"
|
|
||||||
: "NotoKufiArabic-Regular.ttf";
|
|
||||||
const candidates = [
|
const candidates = [
|
||||||
|
join(process.cwd(), "assets", file),
|
||||||
join(process.cwd(), "assets", "fonts", file),
|
join(process.cwd(), "assets", "fonts", file),
|
||||||
join(process.cwd(), "dist", "fonts", file),
|
join(process.cwd(), "dist", "assets", file),
|
||||||
join(process.cwd(), "dist", "assets", "fonts", file),
|
join(process.cwd(), "dist", "assets", "fonts", file),
|
||||||
join("/usr/share/fonts/truetype/noto", file),
|
|
||||||
];
|
];
|
||||||
for (const path of candidates) {
|
for (const path of candidates) {
|
||||||
if (existsSync(path)) return path;
|
if (existsSync(path)) return path;
|
||||||
}
|
}
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Persian PDF font not found (${file}). Place it under assets/fonts/ or install noto fonts on the host.`,
|
`Persian PDF font not found (${file}). Place it under assets/ or assets/fonts/.`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -46,6 +44,16 @@ export type PersianPdfDocument = InstanceType<typeof PDFDocument> & {
|
|||||||
addBlank: (lines?: number) => void;
|
addBlank: (lines?: number) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** Ensure at least `neededPts` of vertical space remain; add a page if not. */
|
||||||
|
function ensureSpace(
|
||||||
|
doc: InstanceType<typeof PDFDocument>,
|
||||||
|
neededPts: number,
|
||||||
|
): void {
|
||||||
|
if (doc.y + neededPts > doc.page.maxY()) {
|
||||||
|
doc.addPage();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function drawRtlLine(
|
function drawRtlLine(
|
||||||
doc: InstanceType<typeof PDFDocument>,
|
doc: InstanceType<typeof PDFDocument>,
|
||||||
text: string,
|
text: string,
|
||||||
@@ -69,6 +77,9 @@ function drawKeyValueRow(
|
|||||||
label: string,
|
label: string,
|
||||||
value: string,
|
value: string,
|
||||||
): void {
|
): void {
|
||||||
|
// Reserve one row height (font 10pt ≈ 14 pts with leading) before drawing.
|
||||||
|
ensureSpace(doc, 16);
|
||||||
|
|
||||||
const y = doc.y;
|
const y = doc.y;
|
||||||
const { label: faLabel, value: faValue } = pdfKitRtlKeyValue(label, value);
|
const { label: faLabel, value: faValue } = pdfKitRtlKeyValue(label, value);
|
||||||
|
|
||||||
@@ -118,6 +129,9 @@ export function createPersianPdfDocument(): PersianPdfDocument {
|
|||||||
};
|
};
|
||||||
|
|
||||||
doc.addSection = (text: string) => {
|
doc.addSection = (text: string) => {
|
||||||
|
// Reserve space for the gap + section heading + at least one data row below it,
|
||||||
|
// so the heading is never stranded alone at the bottom of a page.
|
||||||
|
ensureSpace(doc, 60);
|
||||||
doc.moveDown(0.5);
|
doc.moveDown(0.5);
|
||||||
drawRtlLine(doc, text, 13, true);
|
drawRtlLine(doc, text, 13, true);
|
||||||
doc.font("FaRegular").fontSize(10);
|
doc.font("FaRegular").fontSize(10);
|
||||||
|
|||||||
@@ -8,18 +8,31 @@ export function normalizePersianPdfText(text: string): string {
|
|||||||
.trim();
|
.trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* pdfkit renders glyphs strictly left-to-right.
|
||||||
|
* fontkit already reshapes each word's glyphs into visual (LTR) order,
|
||||||
|
* but it does NOT reorder words — so a multi-word RTL string like
|
||||||
|
* "نام صاحب خودرو" has its words placed LTR on the page and reads
|
||||||
|
* backwards. Reversing the word sequence here makes pdfkit emit the
|
||||||
|
* words in the correct visual order for a right-to-left PDF reader.
|
||||||
|
*/
|
||||||
|
function reverseRtlWords(text: string): string {
|
||||||
|
if (!/[\u0600-\u06FF]/.test(text)) return text;
|
||||||
|
return text.split(" ").reverse().join(" ");
|
||||||
|
}
|
||||||
|
|
||||||
export function pdfKitRtlKeyValue(
|
export function pdfKitRtlKeyValue(
|
||||||
label: string,
|
label: string,
|
||||||
value: string,
|
value: string,
|
||||||
): { label: string; value: string } {
|
): { label: string; value: string } {
|
||||||
return {
|
return {
|
||||||
label: normalizePersianPdfText(label),
|
label: reverseRtlWords(normalizePersianPdfText(label)),
|
||||||
value: normalizePersianPdfText(value),
|
value: reverseRtlWords(normalizePersianPdfText(value)),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function pdfKitRtlParagraph(text: string): string {
|
export function pdfKitRtlParagraph(text: string): string {
|
||||||
return normalizePersianPdfText(text);
|
return reverseRtlWords(normalizePersianPdfText(text));
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isMostlyAscii(text: string): boolean {
|
export function isMostlyAscii(text: string): boolean {
|
||||||
|
|||||||
@@ -51,8 +51,7 @@ import { GetCaptureRequirementsV2ResponseDto } from "src/claim-request-managemen
|
|||||||
*
|
*
|
||||||
* The FileReviewer picks up a sealed file (both signatures collected by the
|
* The FileReviewer picks up a sealed file (both signatures collected by the
|
||||||
* FileMaker) and completes the damage-assessment portion: accident fields,
|
* FileMaker) and completes the damage-assessment portion: accident fields,
|
||||||
* documents, part selection, photo capture, walk-around video, and the final
|
* documents, part selection, photo capture, and walk-around video (final step).
|
||||||
* blame accident video that moves the file to WAITING_FOR_EXPERT.
|
|
||||||
*
|
*
|
||||||
* Sequence:
|
* Sequence:
|
||||||
* accident-fields
|
* accident-fields
|
||||||
@@ -61,9 +60,11 @@ import { GetCaptureRequirementsV2ResponseDto } from "src/claim-request-managemen
|
|||||||
* → select-outer-parts
|
* → select-outer-parts
|
||||||
* → select-other-parts
|
* → select-other-parts
|
||||||
* → capture-part (damaged-part photos + four angles)
|
* → capture-part (damaged-part photos + four angles)
|
||||||
* → upload-document (chassis / engine / metal plate — capture phase)
|
* → upload-document (chassis / engine — capture phase)
|
||||||
* → car-capture (walk-around video)
|
* → car-capture (walk-around video — FINAL FileReviewer step)
|
||||||
* → upload-video (blame accident video — final, moves to WAITING_FOR_EXPERT)
|
* ↳ claim → WAITING_FOR_DAMAGE_EXPERT, blame → COMPLETED
|
||||||
|
*
|
||||||
|
* NOTE: upload-video is a no-op for V4 (blame is already COMPLETED by car-capture).
|
||||||
*/
|
*/
|
||||||
@ApiTags("v4 FileReviewer — blame file review & capture")
|
@ApiTags("v4 FileReviewer — blame file review & capture")
|
||||||
@Controller("v4/file-reviewer/blame-request-management")
|
@Controller("v4/file-reviewer/blame-request-management")
|
||||||
@@ -341,9 +342,10 @@ export class FileReviewerBlameV4Controller {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary: "Walk-around video",
|
summary: "Walk-around video (final FileReviewer step — V4)",
|
||||||
description:
|
description:
|
||||||
"Upload after capture-part is complete (parts, angles, capture-phase docs). Required before the final blame accident video.",
|
"Last FileReviewer action. Upload after capture-part is complete (parts, angles, chassis/engine docs). " +
|
||||||
|
"Finalises the claim → WAITING_FOR_DAMAGE_EXPERT and marks the blame as COMPLETED.",
|
||||||
})
|
})
|
||||||
async carCapture(
|
async carCapture(
|
||||||
@Param("claimRequestId") claimRequestId: string,
|
@Param("claimRequestId") claimRequestId: string,
|
||||||
@@ -384,10 +386,10 @@ export class FileReviewerBlameV4Controller {
|
|||||||
@Post("upload-video/:requestId")
|
@Post("upload-video/:requestId")
|
||||||
@ApiParam({ name: "requestId" })
|
@ApiParam({ name: "requestId" })
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary: "Blame accident video (FileReviewer final step — optional)",
|
summary: "Blame accident video (no-op for V4 — car-capture is the final step)",
|
||||||
description:
|
description:
|
||||||
"Last step of the V4 flow. Requires claim walk-around video and completed capture. " +
|
"Deprecated for V4. The blame is already COMPLETED by car-capture. " +
|
||||||
"File is optional — omit it to complete the blame without attaching a video.",
|
"Calling this endpoint returns an idempotent success response.",
|
||||||
})
|
})
|
||||||
async uploadBlameVideo(
|
async uploadBlameVideo(
|
||||||
@Param("requestId") requestId: string,
|
@Param("requestId") requestId: string,
|
||||||
|
|||||||
@@ -47,11 +47,9 @@ import {
|
|||||||
import { GetCaptureRequirementsV2ResponseDto } from "src/claim-request-management/dto/capture-requirements-v2.dto";
|
import { GetCaptureRequirementsV2ResponseDto } from "src/claim-request-management/dto/capture-requirements-v2.dto";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* V5 FileReviewer flow — same as V4 except the final blame accident video moves
|
* V5 FileReviewer flow — same as V4 except after the damage expert completes
|
||||||
* the file to WAITING_FOR_FINANCIAL_EXPERT instead of WAITING_FOR_EXPERT.
|
* their review, the claim goes to WAITING_FOR_FILE_MAKER_APPROVAL instead of
|
||||||
*
|
* being submitted directly to fanavaran.
|
||||||
* A FinancialExpert must then approve the file (→ WAITING_FOR_EXPERT) or
|
|
||||||
* reject it back (→ FINANCIAL_EXPERT_REJECTED) for the FileReviewer to correct.
|
|
||||||
*
|
*
|
||||||
* Sequence:
|
* Sequence:
|
||||||
* accident-fields
|
* accident-fields
|
||||||
@@ -60,9 +58,12 @@ import { GetCaptureRequirementsV2ResponseDto } from "src/claim-request-managemen
|
|||||||
* → select-outer-parts
|
* → select-outer-parts
|
||||||
* → select-other-parts
|
* → select-other-parts
|
||||||
* → capture-part (damaged-part photos + four angles)
|
* → capture-part (damaged-part photos + four angles)
|
||||||
* → upload-document (chassis / engine / metal plate — capture phase)
|
* → upload-document (chassis / engine — capture phase)
|
||||||
* → car-capture (walk-around video)
|
* → car-capture (walk-around video — FINAL FileReviewer step)
|
||||||
* → upload-video (blame accident video — final, moves to WAITING_FOR_FINANCIAL_EXPERT)
|
* ↳ claim → WAITING_FOR_DAMAGE_EXPERT, blame → COMPLETED
|
||||||
|
* ↳ after expert review: claim → WAITING_FOR_FILE_MAKER_APPROVAL
|
||||||
|
*
|
||||||
|
* NOTE: upload-video is a no-op for V5 (blame is already COMPLETED by car-capture).
|
||||||
*/
|
*/
|
||||||
@ApiTags("v5 FileReviewer — blame file review & capture (with FinancialExpert approval)")
|
@ApiTags("v5 FileReviewer — blame file review & capture (with FinancialExpert approval)")
|
||||||
@Controller("v5/file-reviewer/blame-request-management")
|
@Controller("v5/file-reviewer/blame-request-management")
|
||||||
@@ -340,9 +341,11 @@ export class FileReviewerBlameV5Controller {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary: "Walk-around video",
|
summary: "Walk-around video (final FileReviewer step — V5)",
|
||||||
description:
|
description:
|
||||||
"Upload after capture-part is complete (parts, angles, capture-phase docs). Required before the final blame accident video.",
|
"Last FileReviewer action. Upload after capture-part is complete (parts, angles, chassis/engine docs). " +
|
||||||
|
"Finalises the claim → WAITING_FOR_DAMAGE_EXPERT and marks the blame as COMPLETED. " +
|
||||||
|
"After the damage expert review completes the claim moves to WAITING_FOR_FILE_MAKER_APPROVAL.",
|
||||||
})
|
})
|
||||||
async carCapture(
|
async carCapture(
|
||||||
@Param("claimRequestId") claimRequestId: string,
|
@Param("claimRequestId") claimRequestId: string,
|
||||||
@@ -360,7 +363,6 @@ export class FileReviewerBlameV5Controller {
|
|||||||
|
|
||||||
// ─── Final blame video ─────────────────────────────────────────────────────────
|
// ─── Final blame video ─────────────────────────────────────────────────────────
|
||||||
//
|
//
|
||||||
// V5 difference: moves file to WAITING_FOR_FINANCIAL_EXPERT, not WAITING_FOR_EXPERT.
|
|
||||||
|
|
||||||
@ApiBody({
|
@ApiBody({
|
||||||
schema: {
|
schema: {
|
||||||
@@ -385,10 +387,10 @@ export class FileReviewerBlameV5Controller {
|
|||||||
@Post("upload-video/:requestId")
|
@Post("upload-video/:requestId")
|
||||||
@ApiParam({ name: "requestId" })
|
@ApiParam({ name: "requestId" })
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary: "Blame accident video (FileReviewer final step — V5, optional)",
|
summary: "Blame accident video (no-op for V5 — car-capture is the final step)",
|
||||||
description:
|
description:
|
||||||
"Last step of the V5 FileReviewer flow. Requires claim walk-around video and completed capture. " +
|
"Deprecated for V5. The blame is already COMPLETED by car-capture. " +
|
||||||
"File is optional — omit it to complete the blame without attaching a video.",
|
"Calling this endpoint returns an idempotent success response.",
|
||||||
})
|
})
|
||||||
async uploadBlameVideo(
|
async uploadBlameVideo(
|
||||||
@Param("requestId") requestId: string,
|
@Param("requestId") requestId: string,
|
||||||
|
|||||||
@@ -8814,6 +8814,14 @@ export class RequestManagementService {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
: {}),
|
: {}),
|
||||||
|
// V5 (isMadeByFileMaker): mark approval gate at claim creation so the flag
|
||||||
|
// is always present regardless of whether upload-video is called.
|
||||||
|
...((req as any).isMadeByFileMaker
|
||||||
|
? {
|
||||||
|
requiresFileMakerApproval: true,
|
||||||
|
fileMakerApprovalActorId: new Types.ObjectId(actor.sub),
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
history: [
|
history: [
|
||||||
{
|
{
|
||||||
type: "CLAIM_CREATED",
|
type: "CLAIM_CREATED",
|
||||||
@@ -9816,19 +9824,15 @@ export class RequestManagementService {
|
|||||||
}
|
}
|
||||||
await (req as any).save();
|
await (req as any).save();
|
||||||
|
|
||||||
// Advance the claim into the damage-expert queue AND mark FileMaker approval
|
// Advance the claim into the damage-expert queue.
|
||||||
// required before fanavaran submission.
|
// requiresFileMakerApproval / fileMakerApprovalActorId were already set at
|
||||||
const fileMakerActorId = req.initiatedByFieldExpertId ?? null;
|
// claim creation time (createV3ClaimFromBlame) so no need to repeat here.
|
||||||
await this.claimCaseDbService.findByIdAndUpdate(String(claim._id), {
|
await this.claimCaseDbService.findByIdAndUpdate(String(claim._id), {
|
||||||
$set: {
|
$set: {
|
||||||
status: ClaimCaseStatus.WAITING_FOR_DAMAGE_EXPERT,
|
status: ClaimCaseStatus.WAITING_FOR_DAMAGE_EXPERT,
|
||||||
claimStatus: ClaimStatus.PENDING,
|
claimStatus: ClaimStatus.PENDING,
|
||||||
"workflow.currentStep": ClaimWorkflowStep.USER_SUBMISSION_COMPLETE,
|
"workflow.currentStep": ClaimWorkflowStep.USER_SUBMISSION_COMPLETE,
|
||||||
"workflow.nextStep": ClaimWorkflowStep.EXPERT_DAMAGE_ASSESSMENT,
|
"workflow.nextStep": ClaimWorkflowStep.EXPERT_DAMAGE_ASSESSMENT,
|
||||||
requiresFileMakerApproval: true,
|
|
||||||
...(fileMakerActorId
|
|
||||||
? { fileMakerApprovalActorId: new Types.ObjectId(String(fileMakerActorId)) }
|
|
||||||
: {}),
|
|
||||||
},
|
},
|
||||||
$push: {
|
$push: {
|
||||||
"workflow.completedSteps": ClaimWorkflowStep.USER_SUBMISSION_COMPLETE,
|
"workflow.completedSteps": ClaimWorkflowStep.USER_SUBMISSION_COMPLETE,
|
||||||
@@ -9845,7 +9849,6 @@ export class RequestManagementService {
|
|||||||
description:
|
description:
|
||||||
"V5 field workflow complete. Claim ready for damage expert review (FileMaker approval required before fanavaran).",
|
"V5 field workflow complete. Claim ready for damage expert review (FileMaker approval required before fanavaran).",
|
||||||
v5InPersonFlow: true,
|
v5InPersonFlow: true,
|
||||||
fileMakerActorId: fileMakerActorId ? String(fileMakerActorId) : null,
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user