Fixed Captcha

This commit is contained in:
SepehrYahyaee
2026-05-31 14:01:04 +03:30
parent 389133e1c9
commit 2b7192151d
6 changed files with 225 additions and 137 deletions

View File

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

View File

@@ -1,13 +1,17 @@
import { Prop, Schema, SchemaFactory } from "@nestjs/mongoose";
import { HydratedDocument } from "mongoose";
@Schema({ collection: "captcha-challenges", timestamps: true, versionKey: false })
@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()
answerHash?: string;
@Prop({ required: true })
image: string;

View File

@@ -4565,7 +4565,9 @@ export class ClaimRequestManagementService {
// 3. Validate workflow step is correct (should be at SELECT_OTHER_PARTS)
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(
`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
if (claimCase.money?.sheba || claimCase.money?.nationalCodeOfInsurer) {
if (
claimCase.money?.sheba &&
claimCase.money?.nationalCodeOfInsurer &&
claimCase.workflow?.currentStep !== ClaimWorkflowStep.USER_EXPERT_RESEND
) {
throw new ConflictException(
"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:
// 1) ownership check for nationalCode + plate
// await this.sandHubService.getCarOwnershipInfo(ownershipPlate, nationalCode);
@@ -4643,11 +4651,16 @@ export class ClaimRequestManagementService {
const updatePayload: any = {
"damage.otherParts": otherParts.length > 0 ? otherParts : undefined,
"money.sheba": shebaNumber,
...(step !== ClaimWorkflowStep.USER_EXPERT_RESEND && {
"money.sheba": "",
"money.nationalCodeOfInsurer": nationalCode,
}),
status: ClaimWorkflowStep.CAPTURE_PART_DAMAGES,
"workflow.currentStep": ClaimWorkflowStep.CAPTURE_PART_DAMAGES,
"workflow.nextStep": ClaimWorkflowStep.UPLOAD_REQUIRED_DOCUMENTS,
$push: {
"workflow.completedSteps": ClaimWorkflowStep.SELECT_OTHER_PARTS,
history: {
@@ -4660,7 +4673,7 @@ export class ClaimRequestManagementService {
timestamp: new Date(),
metadata: {
stepKey: ClaimWorkflowStep.SELECT_OTHER_PARTS,
otherParts: otherParts,
otherParts,
otherPartsCount: otherParts.length,
hasBankInfo: true,
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
* 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

View File

@@ -82,13 +82,11 @@ interface CheckedRequestEntry {
[key: string]: any;
}
function statementToFormKey(
statement?: {
function statementToFormKey(statement?: {
admitsGuilt?: boolean;
claimsDamage?: boolean;
acceptsExpertOpinion?: boolean;
},
): string | null {
}): string | null {
if (!statement) return null;
if (statement.admitsGuilt) return "imGuilty";
if (statement.claimsDamage) return "imDamaged";
@@ -115,7 +113,7 @@ export class ExpertBlameService {
private readonly requestManagementService: RequestManagementService,
private readonly smsOrchestrationService: SmsOrchestrationService,
private readonly expertFileActivityDbService: ExpertFileActivityDbService,
) { }
) {}
/** Load immutable profile fields from `expert` for the acting field expert. */
private async snapshotFieldExpert(actorId: string) {
@@ -151,7 +149,8 @@ export class ExpertBlameService {
const requestsNeedingExpert = [];
for (const request of allRequests) {
const firstPartyForm = request.firstPartyDetails?.firstPartyInitialForm;
const secondPartyForm = request.secondPartyDetails?.secondPartyInitialForm;
const secondPartyForm =
request.secondPartyDetails?.secondPartyInitialForm;
if (!firstPartyForm || !secondPartyForm) {
continue; // Skip if forms are not complete
@@ -287,8 +286,11 @@ export class ExpertBlameService {
}
// Adding extra fields for front-end
buckets["PENDING_ON_USER"] = buckets[CaseStatus.WAITING_FOR_DOCUMENT_RESEND] + buckets[CaseStatus.WAITING_FOR_SIGNATURES];
buckets["CHECK_NEEDED"] = buckets["PENDING_ON_USER"] + buckets[CaseStatus.WAITING_FOR_EXPERT];
buckets["PENDING_ON_USER"] =
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;
}
@@ -314,7 +316,8 @@ export class ExpertBlameService {
// 2. Fresh requests (WAITING_FOR_EXPERT and no decision)
// 3. Requests decided by current expert
// 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(
(doc) => {
if (!blameCaseAccessibleToExpert(doc, actor)) {
return false;
}
@@ -345,13 +348,20 @@ export class ExpertBlameService {
}
// 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) {
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>();
for (const doc of visibleCases) {
@@ -362,7 +372,9 @@ export class ExpertBlameService {
}
}
await Promise.all(
[...staleIds].map((id) => this.expireBlameCaseWorkflowLockV2IfStale(id)),
[...staleIds].map((id) =>
this.expireBlameCaseWorkflowLockV2IfStale(id),
),
);
for (const doc of visibleCases) {
if (!staleIds.has(String(doc._id))) continue;
@@ -375,8 +387,11 @@ export class ExpertBlameService {
}
}
const paged = applyListQueryV2(visibleCases, {
publicId: (doc) => String((doc as { publicId?: string }).publicId ?? ""),
const paged = applyListQueryV2(
visibleCases,
{
publicId: (doc) =>
String((doc as { publicId?: string }).publicId ?? ""),
createdAt: (doc) => (doc as { createdAt?: Date }).createdAt,
requestNo: (doc) =>
String(
@@ -391,13 +406,13 @@ export class ExpertBlameService {
blameStatus?: string;
type?: string;
};
return [
String(d._id ?? ""),
d.blameStatus,
d.type,
].filter(Boolean) as string[];
return [String(d._id ?? ""), d.blameStatus, d.type].filter(
Boolean,
) as string[];
},
}, query);
},
query,
);
const items: AllRequestDtoV2[] = paged.list.map((doc) =>
this.mapBlameRequestToListItemV2(doc),
@@ -412,7 +427,10 @@ export class ExpertBlameService {
});
} catch (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(
error instanceof Error ? error.message : "Failed to list blame cases",
);
@@ -422,8 +440,12 @@ export class ExpertBlameService {
private mapBlameRequestToListItemV2(
doc: Record<string, unknown>,
): AllRequestDtoV2 {
const createdAt = doc.createdAt ? new Date(doc.createdAt as string) : new Date();
const updatedAt = doc.updatedAt ? new Date(doc.updatedAt as string) : new Date();
const createdAt = doc.createdAt
? 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 [updatedAtDate, updatedAtTime] = toJalaliDateAndTime(updatedAt);
const workflow = (doc.workflow ?? {}) as Record<string, unknown>;
@@ -478,8 +500,7 @@ export class ExpertBlameService {
secondParty?.vehicle?.inquiry?.mapped?.MapTypNam ||
secondParty?.vehicle?.inquiry?.mapped?.CarName ||
"",
}
},
};
}
@@ -499,9 +520,7 @@ export class ExpertBlameService {
}
const la = doc.workflow.lockedAt;
if (!la) return true;
return (
Date.now() < new Date(la as Date).getTime() + this.blameV2LockTtlMs
);
return Date.now() < new Date(la as Date).getTime() + this.blameV2LockTtlMs;
}
/**
@@ -518,16 +537,12 @@ export class ExpertBlameService {
const lockedAt = request.workflow.lockedAt;
const expiredAt = request.workflow.expiredAt;
if (
expiredAt &&
Date.now() < new Date(expiredAt as Date).getTime()
) {
if (expiredAt && Date.now() < new Date(expiredAt as Date).getTime()) {
return;
}
if (
lockedAt &&
Date.now() <
new Date(lockedAt as Date).getTime() + this.blameV2LockTtlMs
Date.now() < new Date(lockedAt as Date).getTime() + this.blameV2LockTtlMs
) {
return;
}
@@ -676,8 +691,12 @@ export class ExpertBlameService {
requestId: String(current._id),
eventType: ExpertFileActivityType.UNCHECKED,
tenantId:
String(current.firstPartyDetails?.firstPartyClient?.clientId ?? "") ||
String(current.secondPartyDetails?.secondPartyClient?.clientId ?? ""),
String(
current.firstPartyDetails?.firstPartyClient?.clientId ?? "",
) ||
String(
current.secondPartyDetails?.secondPartyClient?.clientId ?? "",
),
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.
* 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 {
requireActorClientKey(actor);
// requireActorClientKey(actor);
const actorId = actor.sub;
await this.expireBlameCaseWorkflowLockV2IfStale(requestId);
const doc = await this.blameRequestDbService.findByIdWithoutHistory(requestId);
const doc =
await this.blameRequestDbService.findByIdWithoutHistory(requestId);
if (!doc) {
throw new NotFoundException("Request not found");
}
assertBlameCaseForExpertTenant(doc, actor);
// assertBlameCaseForExpertTenant(doc, actor);
const type = doc.type as string;
if (type === BlameRequestType.CAR_BODY) {
throw new ForbiddenException(
@@ -850,14 +873,20 @@ export class ExpertBlameService {
// Access control
const expertInitiated = doc.expertInitiated === true;
const initiatedByFieldExpertId = doc.initiatedByFieldExpertId;
if (expertInitiated && initiatedByFieldExpertId && String(initiatedByFieldExpertId) !== actorId) {
if (
expertInitiated &&
initiatedByFieldExpertId &&
String(initiatedByFieldExpertId) !== actorId
) {
throw new ForbiddenException(
"Only the field expert who created this file can view and review it.",
);
}
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 ?? "");
if (lockerId && lockerId !== actorId) {
throw new ForbiddenException(
@@ -896,14 +925,18 @@ export class ExpertBlameService {
const evidence = party.evidence as Record<string, unknown>;
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 (evidence.voices && Array.isArray(evidence.voices)) {
const voiceUrls: string[] = [];
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));
}
evidence.voiceUrls = voiceUrls;
@@ -911,9 +944,12 @@ export class ExpertBlameService {
}
// Date formatting
const createdAt = doc.createdAt ? new Date(doc.createdAt as string | number) : new Date();
const updatedAt = doc.updatedAt ? new Date(doc.updatedAt as string | number) : new Date();
const createdAt = doc.createdAt
? 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 [updatedDate, updatedTime] = toJalaliDateAndTime(updatedAt);
@@ -940,12 +976,13 @@ export class ExpertBlameService {
);
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) {
const existing = await this.requestManagementDbService.findOne(requestId);
if (!existing) {
@@ -961,7 +998,8 @@ export class ExpertBlameService {
const lockedByCurrent =
String(existing?.actorLocked?.actorId || "") === String(actorDetail.sub);
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.
if (isLocked && lockedByCurrent && !isLockExpired) {
@@ -1014,8 +1052,12 @@ export class ExpertBlameService {
requestId: String(requestId),
eventType: ExpertFileActivityType.CHECKED,
tenantId:
String(updateResult?.firstPartyDetails?.firstPartyClient?.clientId ?? "") ||
String(updateResult?.secondPartyDetails?.secondPartyClient?.clientId ?? ""),
String(
updateResult?.firstPartyDetails?.firstPartyClient?.clientId ?? "",
) ||
String(
updateResult?.secondPartyDetails?.secondPartyClient?.clientId ?? "",
),
idempotencyKey: `blame:${requestId}:checked:${actorDetail.sub}`,
});
@@ -1221,7 +1263,9 @@ export class ExpertBlameService {
if (updated.type === BlameRequestType.THIRD_PARTY) {
const phones = (updated.parties || [])
.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 =
actor?.fullName?.trim()?.split(/\s+/).pop() || "کارشناس";
for (const phone of phones) {
@@ -1256,7 +1300,10 @@ export class ExpertBlameService {
actorDetail: any,
): Promise<{ _id: string; lock: boolean; message?: string }> {
try {
const result = await this.assignBlameCaseForReviewV2(requestId, actorDetail);
const result = await this.assignBlameCaseForReviewV2(
requestId,
actorDetail,
);
return {
_id: requestId,
lock: true,
@@ -1502,7 +1549,9 @@ export class ExpertBlameService {
parties: partyResendRequests.map((p) => ({
partyId: String(p.partyId),
requestedItems: p.requestedItems as ResendItemType[],
requestedItemsUi: buildResendItemsWithUi(p.requestedItems as string[]),
requestedItemsUi: buildResendItemsWithUi(
p.requestedItems as string[],
),
description: p.description || undefined,
})),
};
@@ -1514,7 +1563,9 @@ export class ExpertBlameService {
error instanceof Error ? error.stack : String(error),
);
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),
);
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(
requestId: string,
actorId: string,
) {
async submitInPerson(requestId: string, actorId: string) {
try {
await this.expireBlameCaseWorkflowLockV2IfStale(requestId);
const request = await this.blameRequestDbService.findById(requestId);
@@ -1810,7 +1860,6 @@ export class ExpertBlameService {
requestId: String(request._id),
status: CaseStatus.WAITING_FOR_SIGNATURES,
};
} catch (error) {
if (error instanceof HttpException) throw error;
this.logger.error(
@@ -1819,7 +1868,9 @@ export class ExpertBlameService {
error instanceof Error ? error.stack : String(error),
);
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;
}
}

View File

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