diff --git a/.env.example b/.env.example index 7edc6d9..1575ba6 100644 --- a/.env.example +++ b/.env.example @@ -60,6 +60,7 @@ SMS_PROVIDER = SMS_API_KEY = AUTH_SMS_TEMPLATE = EXP_OTP_TIME = +FAKE_OTP = # --------------------------------------------- # ⚙️ Application Features / Flags diff --git a/src/auth/auth-services/user.auth.service.ts b/src/auth/auth-services/user.auth.service.ts index 3555f6f..3d1ba7f 100644 --- a/src/auth/auth-services/user.auth.service.ts +++ b/src/auth/auth-services/user.auth.service.ts @@ -13,6 +13,8 @@ import { } from "src/helpers/iran-mobile"; import { computeOtpExpireMs, + FAKE_OTP_CODE, + isFakeOtpEnabled, isOtpExpiryActive, readOtpExpireMinutesFromEnv, } from "src/helpers/user-otp-expiry"; @@ -109,7 +111,7 @@ export class UserAuthService { const userExist = await this.userDbService.findOne( buildUserLookupByPhone(canonicalMobile), ); - const otp = this.otpCreator.create(); + const otp = this.createOtpForRequest(); const hashOtp = await this.hashService.hash(otp); const expireMinutes = readOtpExpireMinutesFromEnv(); const nowMs = Date.now(); @@ -158,7 +160,23 @@ export class UserAuthService { return new LoginDtoRs(userExist); } + private createOtpForRequest(): string { + if (isFakeOtpEnabled()) { + this.logger.warn( + "FAKE_OTP=true — using fixed dev OTP; SMS provider is not called", + ); + return FAKE_OTP_CODE; + } + return this.otpCreator.create(); + } + private async smsSender(otp: string, mobile: string) { + if (isFakeOtpEnabled()) { + this.logger.log( + `FAKE_OTP=true — skipped SMS for phone=${mobile} (use OTP ${FAKE_OTP_CODE})`, + ); + return; + } const ok = await this.smsOrchestrationService.sendAuthOtp( mobile, otp, diff --git a/src/common/dto/list-query-v2.dto.ts b/src/common/dto/list-query-v2.dto.ts index 2e33072..0c22ac6 100644 --- a/src/common/dto/list-query-v2.dto.ts +++ b/src/common/dto/list-query-v2.dto.ts @@ -9,9 +9,10 @@ import { MaxLength, Min, } from "class-validator"; +import { BlameRequestType } from "src/Types&Enums/blame-request-management/blameRequestType.enum"; import { UNIFIED_FILE_STATUS_KEYS } from "src/helpers/unified-file-status"; -/** Allowed sort keys for V2 list endpoints (claims / blame). */ +/** Allowed sort keys for V2 list endpoints (claims / blame / insurer files). */ export const LIST_SORT_BY_V2 = [ "publicId", "createdAt", @@ -21,9 +22,16 @@ export const LIST_SORT_BY_V2 = [ export type ListSortByV2 = (typeof LIST_SORT_BY_V2)[number]; +export const LIST_FILE_TYPE_V2 = [ + BlameRequestType.THIRD_PARTY, + BlameRequestType.CAR_BODY, +] as const; + +export type ListFileTypeV2 = (typeof LIST_FILE_TYPE_V2)[number]; + /** - * Optional query params for V2 lists. If neither `page` nor `limit` is sent, - * the full filtered+sorted list is returned (backward compatible). + * Optional query params for V2 expert/insurer file lists. + * If neither `page` nor `limit` is sent, the full filtered+sorted list is returned. */ export class ListQueryV2Dto { @ApiPropertyOptional({ @@ -68,7 +76,7 @@ export class ListQueryV2Dto { @ApiPropertyOptional({ description: - "Case-insensitive substring match on `publicId` and `requestNo` (and other endpoint-specific fields).", + "Case-insensitive substring match on `publicId`, `requestNo`, status, and other endpoint-specific fields.", example: "A142", maxLength: 64, }) @@ -85,4 +93,13 @@ export class ListQueryV2Dto { @IsOptional() @IsIn([...UNIFIED_FILE_STATUS_KEYS]) unifiedStatus?: (typeof UNIFIED_FILE_STATUS_KEYS)[number]; + + @ApiPropertyOptional({ + enum: LIST_FILE_TYPE_V2, + description: + "Filter by blame file type: third-party liability (`THIRD_PARTY`) or car body (`CAR_BODY`).", + }) + @IsOptional() + @IsIn([...LIST_FILE_TYPE_V2]) + fileType?: ListFileTypeV2; } diff --git a/src/common/dto/unified-file-status-report.dto.ts b/src/common/dto/unified-file-status-report.dto.ts index 3fee153..187260b 100644 --- a/src/common/dto/unified-file-status-report.dto.ts +++ b/src/common/dto/unified-file-status-report.dto.ts @@ -1,10 +1,42 @@ -import { ApiProperty } from "@nestjs/swagger"; +import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"; +import { IsIn, IsISO8601, IsOptional } from "class-validator"; +import { + LIST_FILE_TYPE_V2, + ListFileTypeV2, +} from "src/common/dto/list-query-v2.dto"; import { UNIFIED_FILE_STATUS_KEYS, UnifiedFileStatusDefinition, UnifiedFileStatusKey, } from "src/helpers/unified-file-status"; +export class UnifiedFileStatusReportQueryDto { + @ApiPropertyOptional({ + description: "Optional start datetime (ISO 8601) for portfolio date filter.", + example: "2026-01-01T00:00:00.000Z", + }) + @IsOptional() + @IsISO8601({ strict: false }) + from?: string; + + @ApiPropertyOptional({ + description: "Optional end datetime (ISO 8601) for portfolio date filter.", + example: "2026-12-31T23:59:59.999Z", + }) + @IsOptional() + @IsISO8601({ strict: false }) + to?: string; + + @ApiPropertyOptional({ + enum: LIST_FILE_TYPE_V2, + description: + "Count only files of this blame type (`THIRD_PARTY` or `CAR_BODY`).", + }) + @IsOptional() + @IsIn([...LIST_FILE_TYPE_V2]) + fileType?: ListFileTypeV2; +} + export class UnifiedFileStatusDefinitionDto implements UnifiedFileStatusDefinition { @ApiProperty({ enum: UNIFIED_FILE_STATUS_KEYS }) key: UnifiedFileStatusKey; diff --git a/src/expert-blame/expert-blame.service.ts b/src/expert-blame/expert-blame.service.ts index 19d18e4..f7de7e0 100644 --- a/src/expert-blame/expert-blame.service.ts +++ b/src/expert-blame/expert-blame.service.ts @@ -28,9 +28,13 @@ import { getUnifiedFileStatusCatalog, resolveUnifiedFileStatus, } from "src/helpers/unified-file-status"; -import { applyListQueryV2 } from "src/helpers/list-query-v2"; +import { applyListQueryV2, isInListDateRange, parseListDateRange } from "src/helpers/list-query-v2"; import { ClaimCaseDbService } from "src/claim-request-management/entites/db-service/claim-case.db.service"; import { ListQueryV2Dto } from "src/common/dto/list-query-v2.dto"; +import { + UnifiedFileStatusReportDto, + UnifiedFileStatusReportQueryDto, +} from "src/common/dto/unified-file-status-report.dto"; import { ExpertFileAssignResultDto, ExpertFileAssignStatus, @@ -156,8 +160,25 @@ export class ExpertBlameService { }); } - async getUnifiedFileStatusReportV2(actor: any) { - const rows = await this.collectBlamePortfolioDocs(actor); + async getUnifiedFileStatusReportV2( + actor: any, + query: UnifiedFileStatusReportQueryDto = {}, + ): Promise { + let rows = await this.collectBlamePortfolioDocs(actor); + const { fromDate, toDate } = parseListDateRange(query.from, query.to); + rows = rows.filter((doc) => + isInListDateRange( + (doc as { createdAt?: Date }).createdAt, + fromDate, + toDate, + ), + ); + if (query.fileType) { + rows = rows.filter( + (doc) => + String((doc as { type?: string }).type ?? "") === query.fileType, + ); + } const claimByPublicId = await this.loadClaimsByPublicIds( rows.map((d) => String((d as { publicId?: string }).publicId ?? "")), ); @@ -590,6 +611,8 @@ export class ExpertBlameService { claimStatus: claim?.status, }); }, + fileType: (doc) => + String((doc as { type?: string }).type ?? "") || undefined, searchExtras: (doc) => { const d = doc as { _id?: unknown; diff --git a/src/expert-blame/expert-blame.v2.controller.ts b/src/expert-blame/expert-blame.v2.controller.ts index a7c4b96..c0bd256 100644 --- a/src/expert-blame/expert-blame.v2.controller.ts +++ b/src/expert-blame/expert-blame.v2.controller.ts @@ -26,6 +26,10 @@ import { CurrentUser } from "src/decorators/user.decorator"; import { RoleEnum } from "src/Types&Enums/role.enum"; import { ExpertBlameService } from "./expert-blame.service"; import { ListQueryV2Dto } from "src/common/dto/list-query-v2.dto"; +import { + UnifiedFileStatusReportDto, + UnifiedFileStatusReportQueryDto, +} from "src/common/dto/unified-file-status-report.dto"; import { AllRequestDtoRsV2 } from "./dto/all-request.dto"; import { SubmitReplyDto } from "./dto/reply.dto"; import { ResendRequestDto } from "./dto/resend.dto"; @@ -44,8 +48,9 @@ export class ExpertBlameV2Controller { description: "Damage experts (`expert`): tenant-scoped **DISAGREEMENT** queue (available, locked, or decided by you). " + "Field experts (`field_expert`): all expert-initiated blame files they created (LINK + IN_PERSON). Use expert-claim for claims after blame completion. " + - "Optional query: `search`, `sortBy` (publicId | createdAt | requestNo | status), `sortOrder`, `page`, `limit`, `unifiedStatus` (calculated blame+claim status).", + "Optional query: `search`, `sortBy` (publicId | createdAt | requestNo | status), `sortOrder`, `page`, `limit`, `unifiedStatus`, `fileType` (THIRD_PARTY | CAR_BODY).", }) + @ApiResponse({ status: 200, type: AllRequestDtoRsV2 }) async findAll( @CurrentUser() actor: any, @Query() query: ListQueryV2Dto, @@ -64,11 +69,18 @@ export class ExpertBlameV2Controller { @ApiOperation({ summary: "Unified file status catalog + counts (blame + linked claim)", description: - "Calculatable statuses for this expert's blame portfolio. Filter lists with `unifiedStatus` query param.", + "Calculatable statuses for this expert's blame portfolio. Filter lists with `unifiedStatus` and `fileType` query params. Optional `from` / `to` ISO dates narrow the counted portfolio.", }) - async getUnifiedFileStatusReportV2(@CurrentUser() actor: any) { + @ApiResponse({ status: 200, type: UnifiedFileStatusReportDto }) + async getUnifiedFileStatusReportV2( + @CurrentUser() actor: any, + @Query() query: UnifiedFileStatusReportQueryDto, + ): Promise { try { - return await this.expertBlameService.getUnifiedFileStatusReportV2(actor); + return await this.expertBlameService.getUnifiedFileStatusReportV2( + actor, + query, + ); } catch (error) { if (error instanceof HttpException) throw error; throw new InternalServerErrorException( diff --git a/src/expert-claim/expert-claim.service.ts b/src/expert-claim/expert-claim.service.ts index aab018a..85825d1 100644 --- a/src/expert-claim/expert-claim.service.ts +++ b/src/expert-claim/expert-claim.service.ts @@ -141,7 +141,15 @@ import { /** Maximum sum of line `totalPayment` across the claim (Toman; priced parts + factor lines after validation). */ import { getClaimV2TotalPaymentCapToman } from "src/constants/repair-amount-limits"; import { ListQueryV2Dto } from "src/common/dto/list-query-v2.dto"; -import { applyListQueryV2 } from "src/helpers/list-query-v2"; +import { + UnifiedFileStatusReportDto, + UnifiedFileStatusReportQueryDto, +} from "src/common/dto/unified-file-status-report.dto"; +import { + applyListQueryV2, + isInListDateRange, + parseListDateRange, +} from "src/helpers/list-query-v2"; import { countUnifiedFileStatuses, getUnifiedFileStatusCatalog, @@ -3416,13 +3424,37 @@ export class ExpertClaimService { return buckets; } - async getUnifiedFileStatusReportV2(actor: any) { + async getUnifiedFileStatusReportV2( + actor: any, + query: UnifiedFileStatusReportQueryDto = {}, + ): Promise { const { claims, blameById } = await this.collectClaimPortfolioWithBlame(actor); + const { fromDate, toDate } = parseListDateRange(query.from, query.to); + const filteredClaims = claims.filter((c) => { + if ( + !isInListDateRange( + c.createdAt as Date | string | undefined, + fromDate, + toDate, + ) + ) { + return false; + } + if (!query.fileType) return true; + const blame = c.blameRequestId + ? blameById.get(String(c.blameRequestId)) + : undefined; + const type = + blame?.type ?? + (c as { snapshot?: { accident?: { type?: string } } })?.snapshot + ?.accident?.type; + return type === query.fileType; + }); return { statuses: getUnifiedFileStatusCatalog(), counts: countUnifiedFileStatuses( - claims.map((c) => { + filteredClaims.map((c) => { const blame = c.blameRequestId ? blameById.get(String(c.blameRequestId)) : undefined; @@ -3532,6 +3564,7 @@ export class ExpertClaimService { createdAt: (r) => r.createdAt, requestNo: (r) => r.publicId, status: (r) => r.unifiedFileStatus ?? r.status, + fileType: (r) => r.blameRequestType, searchExtras: (r) => [ r.claimRequestId, diff --git a/src/expert-claim/expert-claim.v2.controller.ts b/src/expert-claim/expert-claim.v2.controller.ts index 22d812c..bd6030a 100644 --- a/src/expert-claim/expert-claim.v2.controller.ts +++ b/src/expert-claim/expert-claim.v2.controller.ts @@ -31,6 +31,10 @@ import { RoleEnum } from "src/Types&Enums/role.enum"; import { ExpertFileAssignResultDto } from "src/common/dto/expert-file-assign-result.dto"; import { ExpertClaimService } from "./expert-claim.service"; import { ListQueryV2Dto } from "src/common/dto/list-query-v2.dto"; +import { + UnifiedFileStatusReportDto, + UnifiedFileStatusReportQueryDto, +} from "src/common/dto/unified-file-status-report.dto"; import { GetClaimListV2ResponseDto } from "./dto/claim-list-v2.dto"; import { ClaimSubmitResendV2Dto, @@ -69,10 +73,17 @@ export class ExpertClaimV2Controller { @ApiOperation({ summary: "Unified file status catalog + counts (claim + linked blame)", description: - "Calculatable statuses for this expert's claim portfolio. Filter lists with `unifiedStatus` query param.", + "Calculatable statuses for this expert's claim portfolio. Filter lists with `unifiedStatus` and `fileType` query params. Optional `from` / `to` ISO dates narrow the counted portfolio.", }) - async getUnifiedFileStatusReportV2(@CurrentUser() actor: any) { - return await this.expertClaimService.getUnifiedFileStatusReportV2(actor); + @ApiResponse({ status: 200, type: UnifiedFileStatusReportDto }) + async getUnifiedFileStatusReportV2( + @CurrentUser() actor: any, + @Query() query: UnifiedFileStatusReportQueryDto, + ): Promise { + return await this.expertClaimService.getUnifiedFileStatusReportV2( + actor, + query, + ); } @Get("report/status-counts") @@ -127,7 +138,8 @@ export class ExpertClaimV2Controller { summary: "List available claim requests for damage expert", description: "Damage experts: tenant queue (`WAITING_FOR_DAMAGE_EXPERT`), locked/in-progress, factor validation. " + - "Field experts: all claims linked to blame files they initiated (`initiatedByFieldExpertId` or matching `blameRequestId`). Optional query: `search`, `sortBy`, `sortOrder`, `page`, `limit`, `unifiedStatus`.", + "Field experts: all claims linked to blame files they initiated (`initiatedByFieldExpertId` or matching `blameRequestId`). " + + "Optional query: `search`, `sortBy`, `sortOrder`, `page`, `limit`, `unifiedStatus`, `fileType` (THIRD_PARTY | CAR_BODY).", }) @ApiResponse({ status: 200, type: GetClaimListV2ResponseDto }) async getClaimListV2( diff --git a/src/expert-insurer/expert-insurer.controller.ts b/src/expert-insurer/expert-insurer.controller.ts index 6fcbbba..8a2932e 100644 --- a/src/expert-insurer/expert-insurer.controller.ts +++ b/src/expert-insurer/expert-insurer.controller.ts @@ -16,6 +16,7 @@ import { ApiOperation, ApiParam, ApiQuery, + ApiResponse, ApiTags, } from "@nestjs/swagger"; import { Types } from "mongoose"; @@ -27,6 +28,10 @@ import { FileRating } from "src/request-management/entities/schema/request-manag import { RoleEnum } from "src/Types&Enums/role.enum"; import { ExpertInsurerService } from "./expert-insurer.service"; import { ListQueryV2Dto } from "src/common/dto/list-query-v2.dto"; +import { + UnifiedFileStatusReportDto, + UnifiedFileStatusReportQueryDto, +} from "src/common/dto/unified-file-status-report.dto"; import { CreateBranchDto } from "src/client/dto/create-branch.dto"; import { CreateBlameExpertByInsurerDto, @@ -166,7 +171,7 @@ export class ExpertInsurerController { @ApiOperation({ summary: "List insurer files (blame + claim merged by publicId)", description: - "Optional query: `search`, `sortBy` (publicId | createdAt | requestNo | status), `sortOrder`, `page`, `limit`, `unifiedStatus`.", + "Optional query: `search`, `sortBy` (publicId | createdAt | requestNo | status), `sortOrder`, `page`, `limit`, `unifiedStatus`, `fileType` (THIRD_PARTY | CAR_BODY).", }) async getAllFiles( @CurrentUser() insurer, @@ -182,27 +187,16 @@ export class ExpertInsurerController { @ApiOperation({ summary: "Unified file status catalog + counts (blame + claim)", description: - "Returns calculatable unified statuses and per-status counts for this insurer's files. Use `unifiedStatus` on GET files to filter.", - }) - @ApiQuery({ - name: "from", - required: false, - description: "Optional start datetime (ISO string)", - }) - @ApiQuery({ - name: "to", - required: false, - description: "Optional end datetime (ISO string)", + "Calculatable unified statuses and per-status counts for this insurer's files. Filter lists with `unifiedStatus` and `fileType`. Optional `from` / `to` ISO dates narrow the counted portfolio.", }) + @ApiResponse({ status: 200, type: UnifiedFileStatusReportDto }) async getUnifiedFileStatusReport( @CurrentUser() actor, - @Query("from") from?: string, - @Query("to") to?: string, - ) { + @Query() query: UnifiedFileStatusReportQueryDto, + ): Promise { return await this.expertInsurerService.getInsurerUnifiedFileStatusReport( actor, - from, - to, + query, ); } diff --git a/src/expert-insurer/expert-insurer.service.ts b/src/expert-insurer/expert-insurer.service.ts index d35ff02..9da7f6b 100644 --- a/src/expert-insurer/expert-insurer.service.ts +++ b/src/expert-insurer/expert-insurer.service.ts @@ -40,7 +40,15 @@ import { enrichBlamePartiesForAgreementView } from "src/helpers/blame-party-agre import { BlameRequestType } from "src/Types&Enums/blame-request-management/blameRequestType.enum"; import { PartyRole } from "src/request-management/entities/schema/partyRole.enum"; import { ListQueryV2Dto } from "src/common/dto/list-query-v2.dto"; -import { applyListQueryV2 } from "src/helpers/list-query-v2"; +import { + UnifiedFileStatusReportDto, + UnifiedFileStatusReportQueryDto, +} from "src/common/dto/unified-file-status-report.dto"; +import { + applyListQueryV2, + isInListDateRange, + parseListDateRange, +} from "src/helpers/list-query-v2"; import { countUnifiedFileStatuses, getUnifiedFileStatusCatalog, @@ -1328,6 +1336,7 @@ export class ExpertInsurerService { createdAt: (f) => f.updatedAt ?? f.createdAt, requestNo: (f) => f.publicId, status: (f) => f.unifiedFileStatus, + fileType: (f) => f.fileType, searchExtras: (f) => [ f.fileType, @@ -1352,16 +1361,18 @@ export class ExpertInsurerService { async getInsurerUnifiedFileStatusReport( actor: any, - from?: string, - to?: string, - ) { + query: UnifiedFileStatusReportQueryDto = {}, + ): Promise { const clientObjectId = this.getClientId(actor); - const { fromDate, toDate } = this.parseDateRange(from, to); + const { fromDate, toDate } = parseListDateRange(query.from, query.to); const files = await this.buildMergedInsurerFiles(clientObjectId); - const inRange = files.filter((f) => - this.isInDateRange(f.createdAt, fromDate, toDate), + let inRange = files.filter((f) => + isInListDateRange(f.createdAt, fromDate, toDate), ); + if (query.fileType) { + inRange = inRange.filter((f) => f.fileType === query.fileType); + } return { statuses: getUnifiedFileStatusCatalog(), @@ -1946,7 +1957,10 @@ export class ExpertInsurerService { under_review: number; waiting_for_documents_resend: number; }> { - const report = await this.getInsurerUnifiedFileStatusReport(actor, from, to); + const report = await this.getInsurerUnifiedFileStatusReport(actor, { + from, + to, + }); const counts = report.counts; return { all: counts.all ?? 0, diff --git a/src/helpers/list-query-v2.ts b/src/helpers/list-query-v2.ts index 0abc2dc..06a0fc6 100644 --- a/src/helpers/list-query-v2.ts +++ b/src/helpers/list-query-v2.ts @@ -1,4 +1,5 @@ import type { ListQueryV2Dto } from "src/common/dto/list-query-v2.dto"; +import { BadRequestException } from "@nestjs/common"; const MAX_LIMIT = 100; const DEFAULT_LIMIT_WHEN_PAGE_SET = 20; @@ -8,10 +9,45 @@ export type ListItemGetters = { createdAt: (row: T) => Date | string | number | undefined; requestNo?: (row: T) => string | undefined; status: (row: T) => string | undefined; + /** Blame file type (`THIRD_PARTY` | `CAR_BODY`) when available. */ + fileType?: (row: T) => string | undefined; /** Extra strings to match against `search` (e.g. claimRequestId, vehicle). */ searchExtras?: (row: T) => string[]; }; +export function parseListDateRange( + from?: string, + to?: string, +): { fromDate?: Date; toDate?: Date } { + const fromDate = from ? new Date(from) : undefined; + const toDate = to ? new Date(to) : undefined; + + if (fromDate && Number.isNaN(fromDate.getTime())) { + throw new BadRequestException("Invalid 'from' date"); + } + if (toDate && Number.isNaN(toDate.getTime())) { + throw new BadRequestException("Invalid 'to' date"); + } + if (fromDate && toDate && fromDate > toDate) { + throw new BadRequestException("'from' must be before or equal to 'to'"); + } + return { fromDate, toDate }; +} + +export function isInListDateRange( + value: Date | string | number | undefined, + fromDate?: Date, + toDate?: Date, +): boolean { + if (!fromDate && !toDate) return true; + if (value == null) return false; + const d = value instanceof Date ? value : new Date(value); + if (Number.isNaN(d.getTime())) return false; + if (fromDate && d < fromDate) return false; + if (toDate && d > toDate) return false; + return true; +} + function normalizeSearch(raw?: string): string | undefined { const t = raw?.trim().toLowerCase(); return t || undefined; @@ -40,8 +76,13 @@ export function applyListQueryV2( } { const search = normalizeSearch(query.search); let filtered = rows; + if (query.fileType && getters.fileType) { + filtered = filtered.filter( + (row) => getters.fileType!(row) === query.fileType, + ); + } if (search) { - filtered = rows.filter((row) => { + filtered = filtered.filter((row) => { const chunks: string[] = []; const pid = getters.publicId(row); if (pid) chunks.push(pid); diff --git a/src/helpers/user-otp-expiry.ts b/src/helpers/user-otp-expiry.ts index 43c5f03..85352bd 100644 --- a/src/helpers/user-otp-expiry.ts +++ b/src/helpers/user-otp-expiry.ts @@ -41,3 +41,10 @@ export function readOtpExpireMinutesFromEnv(): number { const raw = Number(process.env.EXP_OTP_TIME ?? "2"); return Number.isFinite(raw) && raw > 0 ? raw : 2; } + +/** Dev-only: skip SMS provider and accept a fixed OTP for any mobile. */ +export const FAKE_OTP_CODE = "12345"; + +export function isFakeOtpEnabled(): boolean { + return process.env.FAKE_OTP === "true"; +}