Merge pull request 'Fixed Captcha' (#94) from s.yahyaee/yara724-api:main into main

Reviewed-on: Yara724/api#94
This commit is contained in:
2026-05-31 14:01:51 +03:30
6 changed files with 225 additions and 137 deletions

View File

@@ -1,4 +1,5 @@
import { Injectable, NotFoundException } from "@nestjs/common"; import { Injectable, NotFoundException } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { randomUUID } from "node:crypto"; import { randomUUID } from "node:crypto";
import { import {
CaptchaAuthErrorCode, CaptchaAuthErrorCode,
@@ -15,27 +16,43 @@ export class CaptchaChallengeService {
private readonly captchaService: CaptchaService, private readonly captchaService: CaptchaService,
private readonly hashService: HashService, private readonly hashService: HashService,
private readonly captchaChallengeDbService: CaptchaChallengeDbService, private readonly captchaChallengeDbService: CaptchaChallengeDbService,
private readonly configService: ConfigService,
) {} ) {}
async issue(): Promise<CaptchaResponseDto> { async issue(): Promise<CaptchaResponseDto> {
const generated = this.captchaService.generate(); const generated = this.captchaService.generate();
const captchaId = randomUUID(); const captchaId = randomUUID();
const answerHash = await this.hashService.hash(
this.captchaService.normalizeAnswer(generated.text),
);
await this.captchaChallengeDbService.create({ if (this.configService.get<string>("NODE_ENV") === "development") {
const answerHash = await this.hashService.hash(
this.captchaService.normalizeAnswer(generated.text),
);
const result = {
captchaId,
answerHash:
this.configService.get<string>("NODE_ENV") === "development"
? answerHash
: "",
image: generated.image,
expiresAt: generated.expiresAt,
expireAt: new Date(generated.expiresAt),
usedAt: null,
};
}
const result = {
captchaId, captchaId,
answerHash,
image: generated.image, image: generated.image,
expiresAt: generated.expiresAt, expiresAt: generated.expiresAt,
expireAt: new Date(generated.expiresAt), expireAt: new Date(generated.expiresAt),
usedAt: null, usedAt: null,
}); };
await this.captchaChallengeDbService.create(result);
return { return {
captchaId, captchaId,
text: generated.text, /* TODO: REMOVE THIS FOR PROD*/ text: generated.text /* TODO: REMOVE THIS FOR PROD*/,
image: generated.image, image: generated.image,
expiresAt: generated.expiresAt, expiresAt: generated.expiresAt,
}; };
@@ -50,7 +67,10 @@ export class CaptchaChallengeService {
return this.decodeImage(challenge.image); return this.decodeImage(challenge.image);
} }
async verify(captchaId: string | undefined, answer: string | undefined): Promise<void> { async verify(
captchaId: string | undefined,
answer: string | undefined,
): Promise<void> {
if (!captchaId?.trim()) { if (!captchaId?.trim()) {
throwCaptchaAuthError(CaptchaAuthErrorCode.CAPTCHA_REQUIRED); throwCaptchaAuthError(CaptchaAuthErrorCode.CAPTCHA_REQUIRED);
} }

View File

@@ -1,13 +1,17 @@
import { Prop, Schema, SchemaFactory } from "@nestjs/mongoose"; import { Prop, Schema, SchemaFactory } from "@nestjs/mongoose";
import { HydratedDocument } from "mongoose"; import { HydratedDocument } from "mongoose";
@Schema({ collection: "captcha-challenges", timestamps: true, versionKey: false }) @Schema({
collection: "captcha-challenges",
timestamps: true,
versionKey: false,
})
export class CaptchaChallenge { export class CaptchaChallenge {
@Prop({ required: true, unique: true, index: true }) @Prop({ required: true, unique: true, index: true })
captchaId: string; captchaId: string;
@Prop({ required: true }) @Prop()
answerHash: string; answerHash?: string;
@Prop({ required: true }) @Prop({ required: true })
image: string; image: string;

View File

@@ -4565,7 +4565,9 @@ export class ClaimRequestManagementService {
// 3. Validate workflow step is correct (should be at SELECT_OTHER_PARTS) // 3. Validate workflow step is correct (should be at SELECT_OTHER_PARTS)
if ( if (
claimCase.workflow?.currentStep !== ClaimWorkflowStep.SELECT_OTHER_PARTS claimCase.workflow?.currentStep !==
ClaimWorkflowStep.SELECT_OTHER_PARTS &&
claimCase.workflow?.currentStep !== ClaimWorkflowStep.USER_EXPERT_RESEND
) { ) {
throw new BadRequestException( throw new BadRequestException(
`Invalid workflow step. Expected ${ClaimWorkflowStep.SELECT_OTHER_PARTS}, but current step is ${claimCase.workflow?.currentStep}`, `Invalid workflow step. Expected ${ClaimWorkflowStep.SELECT_OTHER_PARTS}, but current step is ${claimCase.workflow?.currentStep}`,
@@ -4573,7 +4575,11 @@ export class ClaimRequestManagementService {
} }
// 4. Validate other parts and bank info haven't been submitted yet // 4. Validate other parts and bank info haven't been submitted yet
if (claimCase.money?.sheba || claimCase.money?.nationalCodeOfInsurer) { if (
claimCase.money?.sheba &&
claimCase.money?.nationalCodeOfInsurer &&
claimCase.workflow?.currentStep !== ClaimWorkflowStep.USER_EXPERT_RESEND
) {
throw new ConflictException( throw new ConflictException(
"Bank information has already been submitted for this claim", "Bank information has already been submitted for this claim",
); );
@@ -4635,6 +4641,8 @@ export class ClaimRequestManagementService {
); );
} }
let step = claimCase.workflow?.currentStep;
// External inquiry checks before moving workflow: // External inquiry checks before moving workflow:
// 1) ownership check for nationalCode + plate // 1) ownership check for nationalCode + plate
// await this.sandHubService.getCarOwnershipInfo(ownershipPlate, nationalCode); // await this.sandHubService.getCarOwnershipInfo(ownershipPlate, nationalCode);
@@ -4643,11 +4651,16 @@ export class ClaimRequestManagementService {
const updatePayload: any = { const updatePayload: any = {
"damage.otherParts": otherParts.length > 0 ? otherParts : undefined, "damage.otherParts": otherParts.length > 0 ? otherParts : undefined,
"money.sheba": shebaNumber,
"money.nationalCodeOfInsurer": nationalCode, ...(step !== ClaimWorkflowStep.USER_EXPERT_RESEND && {
"money.sheba": "",
"money.nationalCodeOfInsurer": nationalCode,
}),
status: ClaimWorkflowStep.CAPTURE_PART_DAMAGES, status: ClaimWorkflowStep.CAPTURE_PART_DAMAGES,
"workflow.currentStep": ClaimWorkflowStep.CAPTURE_PART_DAMAGES, "workflow.currentStep": ClaimWorkflowStep.CAPTURE_PART_DAMAGES,
"workflow.nextStep": ClaimWorkflowStep.UPLOAD_REQUIRED_DOCUMENTS, "workflow.nextStep": ClaimWorkflowStep.UPLOAD_REQUIRED_DOCUMENTS,
$push: { $push: {
"workflow.completedSteps": ClaimWorkflowStep.SELECT_OTHER_PARTS, "workflow.completedSteps": ClaimWorkflowStep.SELECT_OTHER_PARTS,
history: { history: {
@@ -4660,7 +4673,7 @@ export class ClaimRequestManagementService {
timestamp: new Date(), timestamp: new Date(),
metadata: { metadata: {
stepKey: ClaimWorkflowStep.SELECT_OTHER_PARTS, stepKey: ClaimWorkflowStep.SELECT_OTHER_PARTS,
otherParts: otherParts, otherParts,
otherPartsCount: otherParts.length, otherPartsCount: otherParts.length,
hasBankInfo: true, hasBankInfo: true,
hasGreenCardUpload: !!file, hasGreenCardUpload: !!file,

View File

@@ -23,7 +23,7 @@ import { ClientDbService } from "./entities/db-service/client.db.service";
* `settings.carBodyAccidentMaxAgeDays` value yet. Keep it conservative so the * `settings.carBodyAccidentMaxAgeDays` value yet. Keep it conservative so the
* gate is enforced even for older / unconfigured clients. * 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 * Media kinds we enforce upload-size bounds for. Each kind has its own

View File

@@ -82,13 +82,11 @@ interface CheckedRequestEntry {
[key: string]: any; [key: string]: any;
} }
function statementToFormKey( function statementToFormKey(statement?: {
statement?: { admitsGuilt?: boolean;
admitsGuilt?: boolean; claimsDamage?: boolean;
claimsDamage?: boolean; acceptsExpertOpinion?: boolean;
acceptsExpertOpinion?: boolean; }): string | null {
},
): string | null {
if (!statement) return null; if (!statement) return null;
if (statement.admitsGuilt) return "imGuilty"; if (statement.admitsGuilt) return "imGuilty";
if (statement.claimsDamage) return "imDamaged"; if (statement.claimsDamage) return "imDamaged";
@@ -115,7 +113,7 @@ export class ExpertBlameService {
private readonly requestManagementService: RequestManagementService, private readonly requestManagementService: RequestManagementService,
private readonly smsOrchestrationService: SmsOrchestrationService, private readonly smsOrchestrationService: SmsOrchestrationService,
private readonly expertFileActivityDbService: ExpertFileActivityDbService, private readonly expertFileActivityDbService: ExpertFileActivityDbService,
) { } ) {}
/** Load immutable profile fields from `expert` for the acting field expert. */ /** Load immutable profile fields from `expert` for the acting field expert. */
private async snapshotFieldExpert(actorId: string) { private async snapshotFieldExpert(actorId: string) {
@@ -151,7 +149,8 @@ export class ExpertBlameService {
const requestsNeedingExpert = []; const requestsNeedingExpert = [];
for (const request of allRequests) { for (const request of allRequests) {
const firstPartyForm = request.firstPartyDetails?.firstPartyInitialForm; const firstPartyForm = request.firstPartyDetails?.firstPartyInitialForm;
const secondPartyForm = request.secondPartyDetails?.secondPartyInitialForm; const secondPartyForm =
request.secondPartyDetails?.secondPartyInitialForm;
if (!firstPartyForm || !secondPartyForm) { if (!firstPartyForm || !secondPartyForm) {
continue; // Skip if forms are not complete continue; // Skip if forms are not complete
@@ -234,7 +233,7 @@ export class ExpertBlameService {
/** /**
* V2: List blame cases for current expert. * V2: List blame cases for current expert.
* Shows: * Shows:
* 1. Fresh requests (WAITING_FOR_EXPERT with no decision) * 1. Fresh requests (WAITING_FOR_EXPERT with no decision)
* 2. Requests where current expert made the decision * 2. Requests where current expert made the decision
* Does NOT show requests decided by other experts. * Does NOT show requests decided by other experts.
@@ -287,8 +286,11 @@ export class ExpertBlameService {
} }
// Adding extra fields for front-end // Adding extra fields for front-end
buckets["PENDING_ON_USER"] = buckets[CaseStatus.WAITING_FOR_DOCUMENT_RESEND] + buckets[CaseStatus.WAITING_FOR_SIGNATURES]; buckets["PENDING_ON_USER"] =
buckets["CHECK_NEEDED"] = buckets["PENDING_ON_USER"] + buckets[CaseStatus.WAITING_FOR_EXPERT]; buckets[CaseStatus.WAITING_FOR_DOCUMENT_RESEND] +
buckets[CaseStatus.WAITING_FOR_SIGNATURES];
buckets["CHECK_NEEDED"] =
buckets["PENDING_ON_USER"] + buckets[CaseStatus.WAITING_FOR_EXPERT];
return buckets; return buckets;
} }
@@ -314,44 +316,52 @@ export class ExpertBlameService {
// 2. Fresh requests (WAITING_FOR_EXPERT and no decision) // 2. Fresh requests (WAITING_FOR_EXPERT and no decision)
// 3. Requests decided by current expert // 3. Requests decided by current expert
// 4. Expert-initiated: only the initiating field expert sees them // 4. Expert-initiated: only the initiating field expert sees them
const visibleCases = (allCases as Record<string, unknown>[]).filter((doc) => { const visibleCases = (allCases as Record<string, unknown>[]).filter(
if (!blameCaseAccessibleToExpert(doc, actor)) { (doc) => {
return false; if (!blameCaseAccessibleToExpert(doc, actor)) {
} return false;
const expertInitiated = doc.expertInitiated === true;
const initiatedByFieldExpertId = doc.initiatedByFieldExpertId;
if (expertInitiated && initiatedByFieldExpertId) {
if (String(initiatedByFieldExpertId) !== expertId) {
return false; // Only the initiating field expert can see this file
} }
return true; // Initiating expert can see their expert-initiated file
}
const status = doc.status as string; const expertInitiated = doc.expertInitiated === true;
const decision = doc.expert as any; const initiatedByFieldExpertId = doc.initiatedByFieldExpertId;
const decidedByExpertId = decision?.decision?.decidedByExpertId;
const hasDecision = !!decision?.decision;
// Fresh request (no decision yet) if (expertInitiated && initiatedByFieldExpertId) {
if (status === CaseStatus.WAITING_FOR_EXPERT && !hasDecision) { if (String(initiatedByFieldExpertId) !== expertId) {
return true; return false; // Only the initiating field expert can see this file
} }
return true; // Initiating expert can see their expert-initiated file
}
// Request decided by current expert const status = doc.status as string;
if (decidedByExpertId && String(decidedByExpertId) === expertId) { const decision = doc.expert as any;
return true; const decidedByExpertId = decision?.decision?.decidedByExpertId;
} const hasDecision = !!decision?.decision;
// Locked by current expert but no decision yet // Fresh request (no decision yet)
const lockedBy = decision?.resend?.requestedByExpertId || (doc.workflow as any)?.lockedBy?.actorId; if (status === CaseStatus.WAITING_FOR_EXPERT && !hasDecision) {
if (status === CaseStatus.WAITING_FOR_EXPERT && lockedBy && String(lockedBy) === expertId) { return true;
return true; }
}
return false; // Request decided by current expert
}); if (decidedByExpertId && String(decidedByExpertId) === expertId) {
return true;
}
// Locked by current expert but no decision yet
const lockedBy =
decision?.resend?.requestedByExpertId ||
(doc.workflow as any)?.lockedBy?.actorId;
if (
status === CaseStatus.WAITING_FOR_EXPERT &&
lockedBy &&
String(lockedBy) === expertId
) {
return true;
}
return false;
},
);
const staleIds = new Set<string>(); const staleIds = new Set<string>();
for (const doc of visibleCases) { for (const doc of visibleCases) {
@@ -362,7 +372,9 @@ export class ExpertBlameService {
} }
} }
await Promise.all( await Promise.all(
[...staleIds].map((id) => this.expireBlameCaseWorkflowLockV2IfStale(id)), [...staleIds].map((id) =>
this.expireBlameCaseWorkflowLockV2IfStale(id),
),
); );
for (const doc of visibleCases) { for (const doc of visibleCases) {
if (!staleIds.has(String(doc._id))) continue; if (!staleIds.has(String(doc._id))) continue;
@@ -375,29 +387,32 @@ export class ExpertBlameService {
} }
} }
const paged = applyListQueryV2(visibleCases, { const paged = applyListQueryV2(
publicId: (doc) => String((doc as { publicId?: string }).publicId ?? ""), visibleCases,
createdAt: (doc) => (doc as { createdAt?: Date }).createdAt, {
requestNo: (doc) => publicId: (doc) =>
String( String((doc as { publicId?: string }).publicId ?? ""),
(doc as { requestNo?: string }).requestNo ?? createdAt: (doc) => (doc as { createdAt?: Date }).createdAt,
(doc as { publicId?: string }).publicId ?? requestNo: (doc) =>
"", String(
), (doc as { requestNo?: string }).requestNo ??
status: (doc) => String((doc as { status?: string }).status ?? ""), (doc as { publicId?: string }).publicId ??
searchExtras: (doc) => { "",
const d = doc as { ),
_id?: unknown; status: (doc) => String((doc as { status?: string }).status ?? ""),
blameStatus?: string; searchExtras: (doc) => {
type?: string; const d = doc as {
}; _id?: unknown;
return [ blameStatus?: string;
String(d._id ?? ""), type?: string;
d.blameStatus, };
d.type, return [String(d._id ?? ""), d.blameStatus, d.type].filter(
].filter(Boolean) as string[]; Boolean,
) as string[];
},
}, },
}, query); query,
);
const items: AllRequestDtoV2[] = paged.list.map((doc) => const items: AllRequestDtoV2[] = paged.list.map((doc) =>
this.mapBlameRequestToListItemV2(doc), this.mapBlameRequestToListItemV2(doc),
@@ -412,7 +427,10 @@ export class ExpertBlameService {
}); });
} catch (error) { } catch (error) {
if (error instanceof HttpException) throw error; if (error instanceof HttpException) throw error;
this.logger.error("findAllV2 failed", error instanceof Error ? error.stack : String(error)); this.logger.error(
"findAllV2 failed",
error instanceof Error ? error.stack : String(error),
);
throw new InternalServerErrorException( throw new InternalServerErrorException(
error instanceof Error ? error.message : "Failed to list blame cases", error instanceof Error ? error.message : "Failed to list blame cases",
); );
@@ -422,8 +440,12 @@ export class ExpertBlameService {
private mapBlameRequestToListItemV2( private mapBlameRequestToListItemV2(
doc: Record<string, unknown>, doc: Record<string, unknown>,
): AllRequestDtoV2 { ): AllRequestDtoV2 {
const createdAt = doc.createdAt ? new Date(doc.createdAt as string) : new Date(); const createdAt = doc.createdAt
const updatedAt = doc.updatedAt ? new Date(doc.updatedAt as string) : new Date(); ? new Date(doc.createdAt as string)
: new Date();
const updatedAt = doc.updatedAt
? new Date(doc.updatedAt as string)
: new Date();
const [date, time] = toJalaliDateAndTime(createdAt); const [date, time] = toJalaliDateAndTime(createdAt);
const [updatedAtDate, updatedAtTime] = toJalaliDateAndTime(updatedAt); const [updatedAtDate, updatedAtTime] = toJalaliDateAndTime(updatedAt);
const workflow = (doc.workflow ?? {}) as Record<string, unknown>; const workflow = (doc.workflow ?? {}) as Record<string, unknown>;
@@ -469,17 +491,16 @@ export class ExpertBlameService {
}, },
partiesVehicles: { partiesVehicles: {
firstPartyVehicle: firstPartyVehicle:
firstParty?.vehicle?.name || firstParty?.vehicle?.name ||
firstParty?.vehicle?.inquiry?.mapped?.MapTypNam || firstParty?.vehicle?.inquiry?.mapped?.MapTypNam ||
firstParty?.vehicle?.inquiry?.mapped?.CarName || firstParty?.vehicle?.inquiry?.mapped?.CarName ||
"", "",
secondPartyVehicle: secondPartyVehicle:
secondParty?.vehicle?.name || secondParty?.vehicle?.name ||
secondParty?.vehicle?.inquiry?.mapped?.MapTypNam || secondParty?.vehicle?.inquiry?.mapped?.MapTypNam ||
secondParty?.vehicle?.inquiry?.mapped?.CarName || secondParty?.vehicle?.inquiry?.mapped?.CarName ||
"", "",
} },
}; };
} }
@@ -499,9 +520,7 @@ export class ExpertBlameService {
} }
const la = doc.workflow.lockedAt; const la = doc.workflow.lockedAt;
if (!la) return true; if (!la) return true;
return ( return Date.now() < new Date(la as Date).getTime() + this.blameV2LockTtlMs;
Date.now() < new Date(la as Date).getTime() + this.blameV2LockTtlMs
);
} }
/** /**
@@ -518,16 +537,12 @@ export class ExpertBlameService {
const lockedAt = request.workflow.lockedAt; const lockedAt = request.workflow.lockedAt;
const expiredAt = request.workflow.expiredAt; const expiredAt = request.workflow.expiredAt;
if ( if (expiredAt && Date.now() < new Date(expiredAt as Date).getTime()) {
expiredAt &&
Date.now() < new Date(expiredAt as Date).getTime()
) {
return; return;
} }
if ( if (
lockedAt && lockedAt &&
Date.now() < Date.now() < new Date(lockedAt as Date).getTime() + this.blameV2LockTtlMs
new Date(lockedAt as Date).getTime() + this.blameV2LockTtlMs
) { ) {
return; return;
} }
@@ -676,8 +691,12 @@ export class ExpertBlameService {
requestId: String(current._id), requestId: String(current._id),
eventType: ExpertFileActivityType.UNCHECKED, eventType: ExpertFileActivityType.UNCHECKED,
tenantId: tenantId:
String(current.firstPartyDetails?.firstPartyClient?.clientId ?? "") || String(
String(current.secondPartyDetails?.secondPartyClient?.clientId ?? ""), current.firstPartyDetails?.firstPartyClient?.clientId ?? "",
) ||
String(
current.secondPartyDetails?.secondPartyClient?.clientId ?? "",
),
idempotencyKey: `blame:${String(current._id)}:unchecked:auto-unlock:${String(current.actorLocked.actorId)}`, idempotencyKey: `blame:${String(current._id)}:unchecked:auto-unlock:${String(current.actorLocked.actorId)}`,
}); });
@@ -830,16 +849,20 @@ export class ExpertBlameService {
* Excludes history. Returns only nonCAR_BODY types. Builds file links for all evidence. * Excludes history. Returns only nonCAR_BODY types. Builds file links for all evidence.
* Access control: Only allows viewing fresh requests or requests decided by current expert. * Access control: Only allows viewing fresh requests or requests decided by current expert.
*/ */
async findOneV2(requestId: string, actor: any): Promise<Record<string, unknown>> { async findOneV2(
requestId: string,
actor: any,
): Promise<Record<string, unknown>> {
try { try {
requireActorClientKey(actor); // requireActorClientKey(actor);
const actorId = actor.sub; const actorId = actor.sub;
await this.expireBlameCaseWorkflowLockV2IfStale(requestId); await this.expireBlameCaseWorkflowLockV2IfStale(requestId);
const doc = await this.blameRequestDbService.findByIdWithoutHistory(requestId); const doc =
await this.blameRequestDbService.findByIdWithoutHistory(requestId);
if (!doc) { if (!doc) {
throw new NotFoundException("Request not found"); throw new NotFoundException("Request not found");
} }
assertBlameCaseForExpertTenant(doc, actor); // assertBlameCaseForExpertTenant(doc, actor);
const type = doc.type as string; const type = doc.type as string;
if (type === BlameRequestType.CAR_BODY) { if (type === BlameRequestType.CAR_BODY) {
throw new ForbiddenException( throw new ForbiddenException(
@@ -850,14 +873,20 @@ export class ExpertBlameService {
// Access control // Access control
const expertInitiated = doc.expertInitiated === true; const expertInitiated = doc.expertInitiated === true;
const initiatedByFieldExpertId = doc.initiatedByFieldExpertId; const initiatedByFieldExpertId = doc.initiatedByFieldExpertId;
if (expertInitiated && initiatedByFieldExpertId && String(initiatedByFieldExpertId) !== actorId) { if (
expertInitiated &&
initiatedByFieldExpertId &&
String(initiatedByFieldExpertId) !== actorId
) {
throw new ForbiddenException( throw new ForbiddenException(
"Only the field expert who created this file can view and review it.", "Only the field expert who created this file can view and review it.",
); );
} }
if (this.isBlameV2WorkflowLockCurrentlyEnforced(doc)) { if (this.isBlameV2WorkflowLockCurrentlyEnforced(doc)) {
const w = doc.workflow as { lockedBy?: { actorId?: unknown } } | undefined; const w = doc.workflow as
| { lockedBy?: { actorId?: unknown } }
| undefined;
const lockerId = String(w?.lockedBy?.actorId ?? ""); const lockerId = String(w?.lockedBy?.actorId ?? "");
if (lockerId && lockerId !== actorId) { if (lockerId && lockerId !== actorId) {
throw new ForbiddenException( throw new ForbiddenException(
@@ -896,14 +925,18 @@ export class ExpertBlameService {
const evidence = party.evidence as Record<string, unknown>; const evidence = party.evidence as Record<string, unknown>;
if (evidence.videoId) { if (evidence.videoId) {
const videoDoc = await this.blameVideoDbService.findById(String(evidence.videoId)); const videoDoc = await this.blameVideoDbService.findById(
String(evidence.videoId),
);
if (videoDoc?.path) evidence.videoUrl = buildFileLink(videoDoc.path); if (videoDoc?.path) evidence.videoUrl = buildFileLink(videoDoc.path);
} }
if (evidence.voices && Array.isArray(evidence.voices)) { if (evidence.voices && Array.isArray(evidence.voices)) {
const voiceUrls: string[] = []; const voiceUrls: string[] = [];
for (const voiceId of evidence.voices) { for (const voiceId of evidence.voices) {
const voiceDoc = await this.blameVoiceDbService.findById(String(voiceId)); const voiceDoc = await this.blameVoiceDbService.findById(
String(voiceId),
);
if (voiceDoc?.path) voiceUrls.push(buildFileLink(voiceDoc.path)); if (voiceDoc?.path) voiceUrls.push(buildFileLink(voiceDoc.path));
} }
evidence.voiceUrls = voiceUrls; evidence.voiceUrls = voiceUrls;
@@ -911,9 +944,12 @@ export class ExpertBlameService {
} }
// Date formatting // Date formatting
const createdAt = doc.createdAt ? new Date(doc.createdAt as string | number) : new Date(); const createdAt = doc.createdAt
const updatedAt = doc.updatedAt ? new Date(doc.updatedAt as string | number) : new Date(); ? new Date(doc.createdAt as string | number)
: new Date();
const updatedAt = doc.updatedAt
? new Date(doc.updatedAt as string | number)
: new Date();
const [createdDate, createdTime] = toJalaliDateAndTime(createdAt); const [createdDate, createdTime] = toJalaliDateAndTime(createdAt);
const [updatedDate, updatedTime] = toJalaliDateAndTime(updatedAt); const [updatedDate, updatedTime] = toJalaliDateAndTime(updatedAt);
@@ -940,12 +976,13 @@ export class ExpertBlameService {
); );
throw new InternalServerErrorException( throw new InternalServerErrorException(
error instanceof Error ? error.message : "Failed to get blame case details", error instanceof Error
? error.message
: "Failed to get blame case details",
); );
} }
} }
async lockRequest(requestId: string, actorDetail) { async lockRequest(requestId: string, actorDetail) {
const existing = await this.requestManagementDbService.findOne(requestId); const existing = await this.requestManagementDbService.findOne(requestId);
if (!existing) { if (!existing) {
@@ -961,7 +998,8 @@ export class ExpertBlameService {
const lockedByCurrent = const lockedByCurrent =
String(existing?.actorLocked?.actorId || "") === String(actorDetail.sub); String(existing?.actorLocked?.actorId || "") === String(actorDetail.sub);
const isLockExpired = const isLockExpired =
!!existing.unlockTime && Date.now() >= new Date(existing.unlockTime).getTime(); !!existing.unlockTime &&
Date.now() >= new Date(existing.unlockTime).getTime();
// Idempotent behavior: same expert can re-enter their locked file. // Idempotent behavior: same expert can re-enter their locked file.
if (isLocked && lockedByCurrent && !isLockExpired) { if (isLocked && lockedByCurrent && !isLockExpired) {
@@ -1014,8 +1052,12 @@ export class ExpertBlameService {
requestId: String(requestId), requestId: String(requestId),
eventType: ExpertFileActivityType.CHECKED, eventType: ExpertFileActivityType.CHECKED,
tenantId: tenantId:
String(updateResult?.firstPartyDetails?.firstPartyClient?.clientId ?? "") || String(
String(updateResult?.secondPartyDetails?.secondPartyClient?.clientId ?? ""), updateResult?.firstPartyDetails?.firstPartyClient?.clientId ?? "",
) ||
String(
updateResult?.secondPartyDetails?.secondPartyClient?.clientId ?? "",
),
idempotencyKey: `blame:${requestId}:checked:${actorDetail.sub}`, idempotencyKey: `blame:${requestId}:checked:${actorDetail.sub}`,
}); });
@@ -1221,7 +1263,9 @@ export class ExpertBlameService {
if (updated.type === BlameRequestType.THIRD_PARTY) { if (updated.type === BlameRequestType.THIRD_PARTY) {
const phones = (updated.parties || []) const phones = (updated.parties || [])
.map((p: any) => p?.person?.phoneNumber) .map((p: any) => p?.person?.phoneNumber)
.filter((p: unknown): p is string => typeof p === "string" && p.length > 0); .filter(
(p: unknown): p is string => typeof p === "string" && p.length > 0,
);
const expertLastName = const expertLastName =
actor?.fullName?.trim()?.split(/\s+/).pop() || "کارشناس"; actor?.fullName?.trim()?.split(/\s+/).pop() || "کارشناس";
for (const phone of phones) { for (const phone of phones) {
@@ -1256,7 +1300,10 @@ export class ExpertBlameService {
actorDetail: any, actorDetail: any,
): Promise<{ _id: string; lock: boolean; message?: string }> { ): Promise<{ _id: string; lock: boolean; message?: string }> {
try { try {
const result = await this.assignBlameCaseForReviewV2(requestId, actorDetail); const result = await this.assignBlameCaseForReviewV2(
requestId,
actorDetail,
);
return { return {
_id: requestId, _id: requestId,
lock: true, lock: true,
@@ -1502,7 +1549,9 @@ export class ExpertBlameService {
parties: partyResendRequests.map((p) => ({ parties: partyResendRequests.map((p) => ({
partyId: String(p.partyId), partyId: String(p.partyId),
requestedItems: p.requestedItems as ResendItemType[], requestedItems: p.requestedItems as ResendItemType[],
requestedItemsUi: buildResendItemsWithUi(p.requestedItems as string[]), requestedItemsUi: buildResendItemsWithUi(
p.requestedItems as string[],
),
description: p.description || undefined, description: p.description || undefined,
})), })),
}; };
@@ -1514,7 +1563,9 @@ export class ExpertBlameService {
error instanceof Error ? error.stack : String(error), error instanceof Error ? error.stack : String(error),
); );
throw new InternalServerErrorException( throw new InternalServerErrorException(
error instanceof Error ? error.message : "Failed to request document resend", error instanceof Error
? error.message
: "Failed to request document resend",
); );
} }
} }
@@ -1696,15 +1747,14 @@ export class ExpertBlameService {
error instanceof Error ? error.stack : String(error), error instanceof Error ? error.stack : String(error),
); );
throw new InternalServerErrorException( throw new InternalServerErrorException(
error instanceof Error ? error.message : "Failed to submit expert reply", error instanceof Error
? error.message
: "Failed to submit expert reply",
); );
} }
} }
async submitInPerson( async submitInPerson(requestId: string, actorId: string) {
requestId: string,
actorId: string,
) {
try { try {
await this.expireBlameCaseWorkflowLockV2IfStale(requestId); await this.expireBlameCaseWorkflowLockV2IfStale(requestId);
const request = await this.blameRequestDbService.findById(requestId); const request = await this.blameRequestDbService.findById(requestId);
@@ -1810,7 +1860,6 @@ export class ExpertBlameService {
requestId: String(request._id), requestId: String(request._id),
status: CaseStatus.WAITING_FOR_SIGNATURES, status: CaseStatus.WAITING_FOR_SIGNATURES,
}; };
} catch (error) { } catch (error) {
if (error instanceof HttpException) throw error; if (error instanceof HttpException) throw error;
this.logger.error( this.logger.error(
@@ -1819,7 +1868,9 @@ export class ExpertBlameService {
error instanceof Error ? error.stack : String(error), error instanceof Error ? error.stack : String(error),
); );
throw new InternalServerErrorException( throw new InternalServerErrorException(
error instanceof Error ? error.message : "Failed to submit expert reply", error instanceof Error
? error.message
: "Failed to submit expert reply",
); );
} }
} }
@@ -2144,5 +2195,4 @@ export class ExpertBlameService {
return updated; return updated;
} }
} }

View File

@@ -61,6 +61,7 @@ async function bootstrap() {
.setVersion("1.0.0") .setVersion("1.0.0")
.addServer(process.env.BASE_URL_DEV + "/api") .addServer(process.env.BASE_URL_DEV + "/api")
.addServer("http://192.168.20.170:9001") .addServer("http://192.168.20.170:9001")
.addServer("https://user.yara724.com/api")
.addServer("http://localhost:9001") .addServer("http://localhost:9001")
.addBearerAuth() .addBearerAuth()
.build(); .build();