From 44723259d6db0f297c8de80b39324e1d159f9f68 Mon Sep 17 00:00:00 2001 From: SepehrYahyaee <7heycallmegray@gmail.com> Date: Wed, 20 May 2026 14:57:10 +0330 Subject: [PATCH] Fix SMS, and added pagination+sorting+searching for GETs --- src/auth/dto/user/login.dto.ts | 12 +- src/auth/dto/user/verify.dto.ts | 17 ++- .../claim-request-management.service.ts | 23 +++- .../claim-request-management.v2.controller.ts | 15 ++- .../dto/my-claims-v2.dto.ts | 15 ++- src/common/dto/list-query-v2.dto.ts | 78 ++++++++++++ src/expert-blame/dto/all-request.dto.ts | 28 ++++- src/expert-blame/expert-blame.service.ts | 44 ++++++- .../expert-blame.v2.controller.ts | 15 ++- src/expert-claim/dto/claim-list-v2.dto.ts | 11 +- src/expert-claim/expert-claim.service.ts | 30 ++++- .../expert-claim.v2.controller.ts | 12 +- src/helpers/list-query-v2.spec.ts | 51 ++++++++ src/helpers/list-query-v2.ts | 116 ++++++++++++++++++ .../dto/blame-list-user-v2.dto.ts | 23 ++++ .../request-management.service.ts | 45 ++++++- .../request-management.v2.controller.ts | 13 +- .../provider/sms-gateway.service.ts | 1 + 18 files changed, 519 insertions(+), 30 deletions(-) create mode 100644 src/common/dto/list-query-v2.dto.ts create mode 100644 src/helpers/list-query-v2.spec.ts create mode 100644 src/helpers/list-query-v2.ts create mode 100644 src/request-management/dto/blame-list-user-v2.dto.ts diff --git a/src/auth/dto/user/login.dto.ts b/src/auth/dto/user/login.dto.ts index e8a9ca2..9340863 100644 --- a/src/auth/dto/user/login.dto.ts +++ b/src/auth/dto/user/login.dto.ts @@ -1,12 +1,16 @@ import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"; +import { IsNotEmpty, IsOptional, IsString, MaxLength } from "class-validator"; import { UserModel } from "src/users/entities/schema/user.schema"; export class UserLoginDto { @ApiProperty({ example: "09226187419", type: "string", - description: "User login dto", + description: "Mobile number (username for OTP login)", }) + @IsString() + @IsNotEmpty() + @MaxLength(20) mobile: string; @ApiPropertyOptional({ @@ -14,6 +18,9 @@ export class UserLoginDto { type: "string", description: "Raw token from linked SMS URL (?token=...).", }) + @IsOptional() + @IsString() + @MaxLength(128) linkToken?: string; @ApiPropertyOptional({ @@ -21,6 +28,9 @@ export class UserLoginDto { type: "string", description: "Optional route/context hint for linked SMS login.", }) + @IsOptional() + @IsString() + @MaxLength(64) linkContext?: string; } diff --git a/src/auth/dto/user/verify.dto.ts b/src/auth/dto/user/verify.dto.ts index abf46f3..1c915c6 100644 --- a/src/auth/dto/user/verify.dto.ts +++ b/src/auth/dto/user/verify.dto.ts @@ -1,18 +1,25 @@ import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"; +import { IsNotEmpty, IsOptional, IsString, MaxLength } from "class-validator"; export class UserVerifyOtp { @ApiProperty({ example: "09226187419", type: "string", - description: "User login dto", + description: "Mobile number (same value sent to send-otp)", }) + @IsString() + @IsNotEmpty() + @MaxLength(20) username: string; @ApiProperty({ example: "258567", type: "string", - description: "User login verify dto", + description: "OTP code from SMS", }) + @IsString() + @IsNotEmpty() + @MaxLength(16) password: string; @ApiPropertyOptional({ @@ -20,6 +27,9 @@ export class UserVerifyOtp { type: "string", description: "Raw token from linked SMS URL (?token=...).", }) + @IsOptional() + @IsString() + @MaxLength(128) linkToken?: string; @ApiPropertyOptional({ @@ -27,5 +37,8 @@ export class UserVerifyOtp { type: "string", description: "Optional route/context hint for linked SMS login.", }) + @IsOptional() + @IsString() + @MaxLength(64) linkContext?: string; } diff --git a/src/claim-request-management/claim-request-management.service.ts b/src/claim-request-management/claim-request-management.service.ts index 76ca242..13678f1 100644 --- a/src/claim-request-management/claim-request-management.service.ts +++ b/src/claim-request-management/claim-request-management.service.ts @@ -48,6 +48,8 @@ import { VideoCaptureV2ResponseDto, } from "./dto/capture-part-v2.dto"; import { GetMyClaimsV2ResponseDto, ClaimListItemV2Dto } from "./dto/my-claims-v2.dto"; +import { ListQueryV2Dto } from "src/common/dto/list-query-v2.dto"; +import { applyListQueryV2 } from "src/helpers/list-query-v2"; import { ClaimDetailsV2ResponseDto } from "./dto/claim-details-v2.dto"; import { ClaimCaseStatus } from "src/Types&Enums/claim-request-management/claim-case-status.enum"; import { ClaimRequiredDocumentType, CarAngle } from "src/Types&Enums/claim-request-management/required-document-type.enum"; @@ -6009,6 +6011,7 @@ export class ClaimRequestManagementService { async getMyClaimsV2( currentUserId: string, actor?: { sub: string; role?: string }, + query: ListQueryV2Dto = {}, ): Promise { try { let claims: any[]; @@ -6050,7 +6053,25 @@ export class ClaimRequestManagementService { createdAt: c.createdAt, blameRequestId: c.blameRequestId?.toString(), })) as ClaimListItemV2Dto[]; - return { list, total: list.length }; + + const paged = applyListQueryV2(list, { + publicId: (r) => r.publicId, + createdAt: (r) => r.createdAt, + requestNo: (r) => r.requestNo, + status: (r) => r.status, + searchExtras: (r) => + [r.claimRequestId, r.claimStatus, r.currentStep, r.blameRequestId].filter( + Boolean, + ) as string[], + }, query); + + return { + list: paged.list, + total: paged.total, + page: paged.page, + limit: paged.limit, + totalPages: paged.totalPages, + }; } catch (error) { if (error instanceof HttpException) throw error; this.logger.error('getMyClaimsV2 failed', error); diff --git a/src/claim-request-management/claim-request-management.v2.controller.ts b/src/claim-request-management/claim-request-management.v2.controller.ts index 0de6cab..8a2fae7 100644 --- a/src/claim-request-management/claim-request-management.v2.controller.ts +++ b/src/claim-request-management/claim-request-management.v2.controller.ts @@ -41,6 +41,7 @@ import { VideoCaptureV2ResponseDto, } from "./dto/capture-part-v2.dto"; import { GetMyClaimsV2ResponseDto } from "./dto/my-claims-v2.dto"; +import { ListQueryV2Dto } from "src/common/dto/list-query-v2.dto"; import { ClaimDetailsV2ResponseDto } from "./dto/claim-details-v2.dto"; import { UserObjectionV2Dto } from "./dto/user-objection-v2.dto"; import { UserRatingDto } from "./dto/user-rating.dto"; @@ -60,16 +61,24 @@ export class ClaimRequestManagementV2Controller { @Get("requests") @ApiOperation({ summary: "Get My Claims (V2)", - description: "Get list of all claim requests for the current user.", + description: + "Claims for the current user (or field-expert in-person files). Optional query: `search`, `sortBy` (publicId | createdAt | requestNo | status), `sortOrder`, `page`, `limit`. Without `page`/`limit`, returns the full filtered list.", }) @ApiResponse({ status: 200, description: "List of user claims", type: GetMyClaimsV2ResponseDto, }) - async getMyClaims(@CurrentUser() user: any): Promise { + async getMyClaims( + @CurrentUser() user: any, + @Query() query: ListQueryV2Dto, + ): Promise { try { - return await this.claimRequestManagementService.getMyClaimsV2(user.sub, user); + return await this.claimRequestManagementService.getMyClaimsV2( + user.sub, + user, + query, + ); } catch (error) { if (error instanceof HttpException) throw error; throw new InternalServerErrorException( diff --git a/src/claim-request-management/dto/my-claims-v2.dto.ts b/src/claim-request-management/dto/my-claims-v2.dto.ts index a6572a1..287a062 100644 --- a/src/claim-request-management/dto/my-claims-v2.dto.ts +++ b/src/claim-request-management/dto/my-claims-v2.dto.ts @@ -1,4 +1,4 @@ -import { ApiProperty } from '@nestjs/swagger'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; export class ClaimListItemV2Dto { @ApiProperty({ description: 'Claim case ID', example: '507f1f77bcf86cd799439011' }) @@ -34,6 +34,17 @@ export class GetMyClaimsV2ResponseDto { @ApiProperty({ description: 'List of user claims', type: [ClaimListItemV2Dto] }) list: ClaimListItemV2Dto[]; - @ApiProperty({ description: 'Total count', example: 5 }) + @ApiProperty({ description: 'Total count after search filter', example: 5 }) total: number; + + @ApiPropertyOptional({ + description: 'Current page when `page` or `limit` query params were sent', + }) + page?: number; + + @ApiPropertyOptional({ description: 'Page size when paginating' }) + limit?: number; + + @ApiPropertyOptional({ description: 'Total pages when paginating' }) + totalPages?: number; } diff --git a/src/common/dto/list-query-v2.dto.ts b/src/common/dto/list-query-v2.dto.ts new file mode 100644 index 0000000..0b97123 --- /dev/null +++ b/src/common/dto/list-query-v2.dto.ts @@ -0,0 +1,78 @@ +import { ApiPropertyOptional } from "@nestjs/swagger"; +import { Type } from "class-transformer"; +import { + IsIn, + IsInt, + IsOptional, + IsString, + Max, + MaxLength, + Min, +} from "class-validator"; + +/** Allowed sort keys for V2 list endpoints (claims / blame). */ +export const LIST_SORT_BY_V2 = [ + "publicId", + "createdAt", + "requestNo", + "status", +] as const; + +export type ListSortByV2 = (typeof LIST_SORT_BY_V2)[number]; + +/** + * Optional query params for V2 lists. If neither `page` nor `limit` is sent, + * the full filtered+sorted list is returned (backward compatible). + */ +export class ListQueryV2Dto { + @ApiPropertyOptional({ + description: "1-based page index (use together with `limit` for pagination).", + example: 1, + minimum: 1, + }) + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + page?: number; + + @ApiPropertyOptional({ + description: "Page size (max 100). If only `page` is set, defaults to 20.", + example: 20, + minimum: 1, + maximum: 100, + }) + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + @Max(100) + limit?: number; + + @ApiPropertyOptional({ + enum: LIST_SORT_BY_V2, + description: "Sort field. Default: `createdAt`.", + }) + @IsOptional() + @IsIn([...LIST_SORT_BY_V2]) + sortBy?: ListSortByV2; + + @ApiPropertyOptional({ + enum: ["asc", "desc"], + description: "Sort direction. Default: `desc` for dates, `asc` for publicId/requestNo.", + }) + @IsOptional() + @IsIn(["asc", "desc"]) + sortOrder?: "asc" | "desc"; + + @ApiPropertyOptional({ + description: + "Case-insensitive substring match on `publicId` and `requestNo` (and other endpoint-specific fields).", + example: "A142", + maxLength: 64, + }) + @IsOptional() + @IsString() + @MaxLength(64) + search?: string; +} diff --git a/src/expert-blame/dto/all-request.dto.ts b/src/expert-blame/dto/all-request.dto.ts index 76ff171..0d045a4 100644 --- a/src/expert-blame/dto/all-request.dto.ts +++ b/src/expert-blame/dto/all-request.dto.ts @@ -1,3 +1,4 @@ +import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"; import { RequestManagementModel } from "src/request-management/entities/schema/request-management.schema"; export class AllRequestDto { @@ -122,7 +123,30 @@ export class AllRequestDtoV2 { export class AllRequestDtoRsV2 { data: AllRequestDtoV2[]; - constructor(items: AllRequestDtoV2[]) { - this.data = items; + + @ApiProperty({ description: "Total rows after search filter" }) + total: number; + + @ApiPropertyOptional() + page?: number; + + @ApiPropertyOptional() + limit?: number; + + @ApiPropertyOptional() + totalPages?: number; + + constructor(payload: { + data: AllRequestDtoV2[]; + total: number; + page?: number; + limit?: number; + totalPages?: number; + }) { + this.data = payload.data; + this.total = payload.total; + this.page = payload.page; + this.limit = payload.limit; + this.totalPages = payload.totalPages; } } diff --git a/src/expert-blame/expert-blame.service.ts b/src/expert-blame/expert-blame.service.ts index 0b4405b..765cb92 100644 --- a/src/expert-blame/expert-blame.service.ts +++ b/src/expert-blame/expert-blame.service.ts @@ -21,6 +21,8 @@ import { expertPortfolioFileIdsFromActivityEvents, objectIdsFromStringSet, } from "src/helpers/expert-portfolio"; +import { applyListQueryV2 } from "src/helpers/list-query-v2"; +import { ListQueryV2Dto } from "src/common/dto/list-query-v2.dto"; import { RequestManagementDbService } from "src/request-management/entities/db-service/request-management.db.service"; import { BlameRequestDbService } from "src/request-management/entities/db-service/blame-request.db.service"; import { @@ -275,7 +277,10 @@ export class ExpertBlameService { return buckets; } - async findAllV2(actor: any): Promise { + async findAllV2( + actor: any, + query: ListQueryV2Dto = {}, + ): Promise { try { requireActorClientKey(actor); const expertId = actor.sub; @@ -354,10 +359,41 @@ export class ExpertBlameService { } } - const items: AllRequestDtoV2[] = visibleCases.map( - (doc) => this.mapBlameRequestToListItemV2(doc), + const paged = applyListQueryV2(visibleCases, { + publicId: (doc) => String((doc as { publicId?: string }).publicId ?? ""), + createdAt: (doc) => (doc as { createdAt?: Date }).createdAt, + requestNo: (doc) => + String( + (doc as { requestNo?: string }).requestNo ?? + (doc as { publicId?: string }).publicId ?? + "", + ), + status: (doc) => String((doc as { status?: string }).status ?? ""), + searchExtras: (doc) => { + const d = doc as { + _id?: unknown; + blameStatus?: string; + type?: string; + }; + return [ + String(d._id ?? ""), + d.blameStatus, + d.type, + ].filter(Boolean) as string[]; + }, + }, query); + + const items: AllRequestDtoV2[] = paged.list.map((doc) => + this.mapBlameRequestToListItemV2(doc), ); - return new AllRequestDtoRsV2(items); + + return new AllRequestDtoRsV2({ + data: items, + total: paged.total, + page: paged.page, + limit: paged.limit, + totalPages: paged.totalPages, + }); } catch (error) { if (error instanceof HttpException) throw error; this.logger.error("findAllV2 failed", error instanceof Error ? error.stack : String(error)); diff --git a/src/expert-blame/expert-blame.v2.controller.ts b/src/expert-blame/expert-blame.v2.controller.ts index 17fda8f..58a7191 100644 --- a/src/expert-blame/expert-blame.v2.controller.ts +++ b/src/expert-blame/expert-blame.v2.controller.ts @@ -6,6 +6,7 @@ import { InternalServerErrorException, Param, Put, + Query, UseGuards, } from "@nestjs/common"; import { ApiBearerAuth, ApiBody, ApiOperation, ApiParam, ApiTags } from "@nestjs/swagger"; @@ -15,6 +16,8 @@ import { Roles } from "src/decorators/roles.decorator"; 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 { AllRequestDtoRsV2 } from "./dto/all-request.dto"; import { SubmitReplyDto } from "./dto/reply.dto"; import { ResendRequestDto } from "./dto/resend.dto"; @@ -27,9 +30,17 @@ export class ExpertBlameV2Controller { constructor(private readonly expertBlameService: ExpertBlameService) { } @Get() - async findAll(@CurrentUser() actor: any) { + @ApiOperation({ + summary: "List blame cases for field expert (V2)", + description: + "Optional query: `search`, `sortBy` (publicId | createdAt | requestNo | status), `sortOrder`, `page`, `limit`. Without `page`/`limit`, returns the full filtered list.", + }) + async findAll( + @CurrentUser() actor: any, + @Query() query: ListQueryV2Dto, + ): Promise { try { - return await this.expertBlameService.findAllV2(actor); + return await this.expertBlameService.findAllV2(actor, query); } catch (error) { if (error instanceof HttpException) throw error; throw new InternalServerErrorException( diff --git a/src/expert-claim/dto/claim-list-v2.dto.ts b/src/expert-claim/dto/claim-list-v2.dto.ts index 58ff9ca..88e7885 100644 --- a/src/expert-claim/dto/claim-list-v2.dto.ts +++ b/src/expert-claim/dto/claim-list-v2.dto.ts @@ -64,6 +64,15 @@ export class GetClaimListV2ResponseDto { @ApiProperty({ type: [ClaimListItemV2Dto] }) list: ClaimListItemV2Dto[]; - @ApiProperty({ description: 'Total count' }) + @ApiProperty({ description: 'Total count after search filter' }) total: number; + + @ApiPropertyOptional({ description: 'Current page when paginating' }) + page?: number; + + @ApiPropertyOptional({ description: 'Page size when paginating' }) + limit?: number; + + @ApiPropertyOptional({ description: 'Total pages when paginating' }) + totalPages?: number; } diff --git a/src/expert-claim/expert-claim.service.ts b/src/expert-claim/expert-claim.service.ts index 9ec4155..dc08863 100644 --- a/src/expert-claim/expert-claim.service.ts +++ b/src/expert-claim/expert-claim.service.ts @@ -116,6 +116,8 @@ import { /** Maximum sum of line `totalPayment` across the claim (Toman; priced parts + factor lines after validation). */ import { CLAIM_V2_TOTAL_PAYMENT_CAP_TOMAN } from "src/constants/repair-amount-limits"; +import { ListQueryV2Dto } from "src/common/dto/list-query-v2.dto"; +import { applyListQueryV2 } from "src/helpers/list-query-v2"; const CLAIM_V2_TOTAL_PAYMENT_CAP = CLAIM_V2_TOTAL_PAYMENT_CAP_TOMAN; @@ -3082,7 +3084,10 @@ export class ExpertClaimService { * 2. Locked by this expert (in-progress) * 3. EXPERT_VALIDATING_REPAIR_FACTORS (or legacy WAITING_FOR_INSURER_APPROVAL) + UNDER_REVIEW at EXPERT_COST_EVALUATION (factor validation queue) */ - async getClaimListV2(actor: any): Promise { + async getClaimListV2( + actor: any, + query: ListQueryV2Dto = {}, + ): Promise { requireActorClientKey(actor); const actorId = actor.sub; const clientKey = actor.clientKey as string; @@ -3216,7 +3221,28 @@ export class ExpertClaimService { }; }) as ClaimListItemV2Dto[]; - return { list, total: list.length }; + const paged = applyListQueryV2(list, { + publicId: (r) => r.publicId, + createdAt: (r) => r.createdAt, + requestNo: (r) => r.publicId, + status: (r) => r.status, + searchExtras: (r) => + [ + r.claimRequestId, + r.currentStep, + r.vehicle?.carName, + r.vehicle?.carModel, + r.blameRequestType, + ].filter(Boolean) as string[], + }, query); + + return { + list: paged.list, + total: paged.total, + page: paged.page, + limit: paged.limit, + totalPages: paged.totalPages, + }; } /** diff --git a/src/expert-claim/expert-claim.v2.controller.ts b/src/expert-claim/expert-claim.v2.controller.ts index dd515a1..170c7b9 100644 --- a/src/expert-claim/expert-claim.v2.controller.ts +++ b/src/expert-claim/expert-claim.v2.controller.ts @@ -16,6 +16,8 @@ import { Roles } from "src/decorators/roles.decorator"; import { CurrentUser } from "src/decorators/user.decorator"; import { RoleEnum } from "src/Types&Enums/role.enum"; import { ExpertClaimService } from "./expert-claim.service"; +import { ListQueryV2Dto } from "src/common/dto/list-query-v2.dto"; +import { GetClaimListV2ResponseDto } from "./dto/claim-list-v2.dto"; import { ClaimSubmitResendV2Dto, SubmitExpertReplyV2Dto } from "./dto/expert-claim-v2.dto"; import { UpdateClaimDamagedPartsV2Dto } from "./dto/update-claim-damaged-parts-v2.dto"; import { @@ -96,10 +98,14 @@ export class ExpertClaimV2Controller { @ApiOperation({ summary: "List available claim requests for damage expert", description: - "Returns claims that are WAITING_FOR_DAMAGE_EXPERT and not locked by another expert, this expert's locked/in-progress claims, and claims awaiting factor validation (`status=EXPERT_VALIDATING_REPAIR_FACTORS` or legacy `WAITING_FOR_INSURER_APPROVAL` with UNDER_REVIEW at EXPERT_COST_EVALUATION).", + "Returns claims that are WAITING_FOR_DAMAGE_EXPERT and not locked by another expert, this expert's locked/in-progress claims, and claims awaiting factor validation. Optional query: `search`, `sortBy` (publicId | createdAt | requestNo | status), `sortOrder`, `page`, `limit`. Without `page`/`limit`, returns the full filtered list.", }) - async getClaimListV2(@CurrentUser() actor) { - return await this.expertClaimService.getClaimListV2(actor); + @ApiResponse({ status: 200, type: GetClaimListV2ResponseDto }) + async getClaimListV2( + @CurrentUser() actor, + @Query() query: ListQueryV2Dto, + ): Promise { + return await this.expertClaimService.getClaimListV2(actor, query); } @Get("request/:claimRequestId") diff --git a/src/helpers/list-query-v2.spec.ts b/src/helpers/list-query-v2.spec.ts new file mode 100644 index 0000000..b7c8bc1 --- /dev/null +++ b/src/helpers/list-query-v2.spec.ts @@ -0,0 +1,51 @@ +import { applyListQueryV2 } from "./list-query-v2"; + +describe("applyListQueryV2", () => { + const rows = [ + { publicId: "B100", createdAt: "2026-01-01", status: "OPEN" }, + { publicId: "A200", createdAt: "2026-02-01", status: "DONE" }, + { publicId: "C050", createdAt: "2026-03-01", status: "OPEN" }, + ]; + + const getters = { + publicId: (r: (typeof rows)[0]) => r.publicId, + createdAt: (r: (typeof rows)[0]) => r.createdAt, + status: (r: (typeof rows)[0]) => r.status, + }; + + it("returns all rows when no pagination query", () => { + const r = applyListQueryV2(rows, getters, {}); + expect(r.total).toBe(3); + expect(r.list).toHaveLength(3); + expect(r.page).toBeUndefined(); + }); + + it("filters by search on publicId", () => { + const r = applyListQueryV2(rows, getters, { search: "a200" }); + expect(r.total).toBe(1); + expect(r.list[0].publicId).toBe("A200"); + }); + + it("sorts by publicId ascending", () => { + const r = applyListQueryV2(rows, getters, { + sortBy: "publicId", + sortOrder: "asc", + }); + expect(r.list.map((x) => x.publicId)).toEqual(["A200", "B100", "C050"]); + }); + + it("paginates when page and limit are set", () => { + const r = applyListQueryV2(rows, getters, { + sortBy: "publicId", + sortOrder: "asc", + page: 2, + limit: 1, + }); + expect(r.total).toBe(3); + expect(r.list).toHaveLength(1); + expect(r.list[0].publicId).toBe("B100"); + expect(r.page).toBe(2); + expect(r.limit).toBe(1); + expect(r.totalPages).toBe(3); + }); +}); diff --git a/src/helpers/list-query-v2.ts b/src/helpers/list-query-v2.ts new file mode 100644 index 0000000..0abc2dc --- /dev/null +++ b/src/helpers/list-query-v2.ts @@ -0,0 +1,116 @@ +import type { ListQueryV2Dto } from "src/common/dto/list-query-v2.dto"; + +const MAX_LIMIT = 100; +const DEFAULT_LIMIT_WHEN_PAGE_SET = 20; + +export type ListItemGetters = { + publicId: (row: T) => string | undefined; + createdAt: (row: T) => Date | string | number | undefined; + requestNo?: (row: T) => string | undefined; + status: (row: T) => string | undefined; + /** Extra strings to match against `search` (e.g. claimRequestId, vehicle). */ + searchExtras?: (row: T) => string[]; +}; + +function normalizeSearch(raw?: string): string | undefined { + const t = raw?.trim().toLowerCase(); + return t || undefined; +} + +function toTime(v: Date | string | number | undefined): number { + if (v == null) return 0; + const n = v instanceof Date ? v.getTime() : new Date(v as string | number).getTime(); + return Number.isFinite(n) ? n : 0; +} + +/** + * Filter, sort, and optionally paginate in-memory list results. + * Pagination applies only when `query.page` or `query.limit` is set. + */ +export function applyListQueryV2( + rows: T[], + getters: ListItemGetters, + query: ListQueryV2Dto, +): { + list: T[]; + total: number; + page?: number; + limit?: number; + totalPages?: number; +} { + const search = normalizeSearch(query.search); + let filtered = rows; + if (search) { + filtered = rows.filter((row) => { + const chunks: string[] = []; + const pid = getters.publicId(row); + if (pid) chunks.push(pid); + const rno = getters.requestNo?.(row); + if (rno) chunks.push(rno); + const st = getters.status(row); + if (st) chunks.push(st); + const extras = getters.searchExtras?.(row) ?? []; + for (const e of extras) { + if (e) chunks.push(e); + } + const hay = chunks.join(" ").toLowerCase(); + return hay.includes(search); + }); + } + + const sortBy = query.sortBy ?? "createdAt"; + let sortOrder = query.sortOrder; + if (!sortOrder) { + sortOrder = + sortBy === "publicId" || sortBy === "requestNo" ? "asc" : "desc"; + } + const dir = sortOrder === "asc" ? 1 : -1; + + const sorted = [...filtered].sort((a, b) => { + switch (sortBy) { + case "publicId": { + const va = String(getters.publicId(a) ?? ""); + const vb = String(getters.publicId(b) ?? ""); + return dir * va.localeCompare(vb, "en", { numeric: true, sensitivity: "base" }); + } + case "requestNo": { + const va = String( + getters.requestNo?.(a) ?? getters.publicId(a) ?? "", + ); + const vb = String( + getters.requestNo?.(b) ?? getters.publicId(b) ?? "", + ); + return dir * va.localeCompare(vb, "en", { numeric: true, sensitivity: "base" }); + } + case "status": { + const va = String(getters.status(a) ?? ""); + const vb = String(getters.status(b) ?? ""); + return dir * va.localeCompare(vb, "en", { sensitivity: "base" }); + } + case "createdAt": + default: { + const ta = toTime(getters.createdAt(a)); + const tb = toTime(getters.createdAt(b)); + return dir * (ta - tb); + } + } + }); + + const total = sorted.length; + const wantsPage = query.page != null; + const wantsLimit = query.limit != null; + if (!wantsPage && !wantsLimit) { + return { list: sorted, total }; + } + + const limit = Math.min( + MAX_LIMIT, + Math.max(1, query.limit ?? (wantsPage ? DEFAULT_LIMIT_WHEN_PAGE_SET : MAX_LIMIT)), + ); + const page = Math.max(1, query.page ?? 1); + const skip = (page - 1) * limit; + const list = sorted.slice(skip, skip + limit); + const totalPages = Math.max(1, Math.ceil(total / limit)); + + return { list, total, page, limit, totalPages }; +} diff --git a/src/request-management/dto/blame-list-user-v2.dto.ts b/src/request-management/dto/blame-list-user-v2.dto.ts new file mode 100644 index 0000000..e4dd106 --- /dev/null +++ b/src/request-management/dto/blame-list-user-v2.dto.ts @@ -0,0 +1,23 @@ +import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"; + +/** User / field-expert blame list (v2 GET /v2/blame-request-management). */ +export class GetUserBlameListV2ResponseDto { + @ApiProperty({ + type: "array", + description: "Blame requests for the current user (parties stripped)", + items: { type: "object", additionalProperties: true }, + }) + list: Record[]; + + @ApiProperty({ description: "Count after `search` filter" }) + total: number; + + @ApiPropertyOptional() + page?: number; + + @ApiPropertyOptional() + limit?: number; + + @ApiPropertyOptional() + totalPages?: number; +} diff --git a/src/request-management/request-management.service.ts b/src/request-management/request-management.service.ts index 7f6c873..1d953ee 100644 --- a/src/request-management/request-management.service.ts +++ b/src/request-management/request-management.service.ts @@ -42,6 +42,9 @@ import { StepsEnum } from "src/Types&Enums/blame-request-management/steps.enum"; import { ExpertDbService } from "src/users/entities/db-service/expert.db.service"; import { UserDbService } from "src/users/entities/db-service/user.db.service"; import { parseIranLocalDateTime } from "src/helpers/iran-datetime"; +import { applyListQueryV2 } from "src/helpers/list-query-v2"; +import { ListQueryV2Dto } from "src/common/dto/list-query-v2.dto"; +import { GetUserBlameListV2ResponseDto } from "src/request-management/dto/blame-list-user-v2.dto"; import { AutoCloseRequestService } from "src/utils/cron/cron.service"; import { SmsOrchestrationService } from "src/sms-orchestration/sms-orchestration.service"; import { @@ -4147,7 +4150,10 @@ export class RequestManagementService { })); } - async getAllBlameRequestsV2(user: any): Promise { + async getAllBlameRequestsV2( + user: any, + query: ListQueryV2Dto = {}, + ): Promise { try { const userIdFilter = user?.sub && Types.ObjectId.isValid(user.sub) @@ -4158,13 +4164,16 @@ export class RequestManagementService { : null; const filters = [userIdFilter, phoneFilter].filter(Boolean); - if (filters.length === 0) return []; + if (filters.length === 0) { + return { list: [], total: 0 }; + } const requests = await this.blameRequestDbService.find( filters.length === 1 ? (filters[0] as any) : ({ $or: filters } as any), { - select: "requestNo type status blameStatus createdAt updatedAt parties", - } + select: + "publicId requestNo type status blameStatus createdAt updatedAt parties", + }, ); const enriched = requests.map((req: any) => { @@ -4184,7 +4193,33 @@ export class RequestManagementService { }; }); - return enriched; + const paged = applyListQueryV2(enriched, { + publicId: (r) => String((r as { publicId?: string }).publicId ?? ""), + createdAt: (r) => (r as { createdAt?: Date }).createdAt, + requestNo: (r) => String((r as { requestNo?: string }).requestNo ?? ""), + status: (r) => String((r as { status?: string }).status ?? ""), + searchExtras: (r) => { + const row = r as { + blameStatus?: string; + type?: string; + userSide?: string; + }; + return [ + row.blameStatus, + row.type, + row.userSide, + String((r as { _id?: unknown })._id ?? ""), + ].filter(Boolean) as string[]; + }, + }, query); + + return { + list: paged.list, + total: paged.total, + page: paged.page, + limit: paged.limit, + totalPages: paged.totalPages, + }; } catch (err) { this.logger.error("Error: ", err); throw new InternalServerErrorException("Something Went Wrong"); diff --git a/src/request-management/request-management.v2.controller.ts b/src/request-management/request-management.v2.controller.ts index f9924de..2e50478 100644 --- a/src/request-management/request-management.v2.controller.ts +++ b/src/request-management/request-management.v2.controller.ts @@ -7,6 +7,7 @@ import { Param, Post, Put, + Query, Req, UploadedFile, UploadedFiles, @@ -39,6 +40,8 @@ import { CarBodyFormDto, } from "./dto/create-request-management.dto"; import { RequestManagementService } from "./request-management.service"; +import { ListQueryV2Dto } from "src/common/dto/list-query-v2.dto"; +import { GetUserBlameListV2ResponseDto } from "./dto/blame-list-user-v2.dto"; /** * V2 API surface for the redesigned `BlameRequest` model. @@ -71,10 +74,16 @@ export class RequestManagementV2Controller { @Get() @Roles(RoleEnum.USER, RoleEnum.FIELD_EXPERT) + @ApiOperation({ + summary: "List my blame requests (V2)", + description: + "All blame files for the current user. Optional query: `search`, `sortBy` (publicId | createdAt | requestNo | status), `sortOrder`, `page`, `limit`. Without `page`/`limit`, returns the full filtered list.", + }) async getAllBlameRequestsV2( @CurrentUser() user: any, - ) { - return this.requestManagementService.getAllBlameRequestsV2(user); + @Query() query: ListQueryV2Dto, + ): Promise { + return this.requestManagementService.getAllBlameRequestsV2(user, query); } /** diff --git a/src/sms-orchestration/provider/sms-gateway.service.ts b/src/sms-orchestration/provider/sms-gateway.service.ts index df6e1d3..e7eb66c 100644 --- a/src/sms-orchestration/provider/sms-gateway.service.ts +++ b/src/sms-orchestration/provider/sms-gateway.service.ts @@ -63,6 +63,7 @@ export class SmsGatewayService { async verifyLookUp(data: VerifyLookUpMessage) { try { + console.log(data) const body = await this.sender.verifyLookup(data); if (!isKavenegarSuccess(body)) { const normalized = normalizeKavenegarBody(body);