1
0
forked from Yara724/api

Merge pull request 'Fix SMS, and added pagination+sorting+searching for GETs' (#77) from s.yahyaee/yara724-api:main into main

Reviewed-on: Yara724/api#77
This commit is contained in:
2026-05-20 14:57:31 +03:30
18 changed files with 519 additions and 30 deletions

View File

@@ -1,12 +1,16 @@
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"; import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
import { IsNotEmpty, IsOptional, IsString, MaxLength } from "class-validator";
import { UserModel } from "src/users/entities/schema/user.schema"; import { UserModel } from "src/users/entities/schema/user.schema";
export class UserLoginDto { export class UserLoginDto {
@ApiProperty({ @ApiProperty({
example: "09226187419", example: "09226187419",
type: "string", type: "string",
description: "User login dto", description: "Mobile number (username for OTP login)",
}) })
@IsString()
@IsNotEmpty()
@MaxLength(20)
mobile: string; mobile: string;
@ApiPropertyOptional({ @ApiPropertyOptional({
@@ -14,6 +18,9 @@ export class UserLoginDto {
type: "string", type: "string",
description: "Raw token from linked SMS URL (?token=...).", description: "Raw token from linked SMS URL (?token=...).",
}) })
@IsOptional()
@IsString()
@MaxLength(128)
linkToken?: string; linkToken?: string;
@ApiPropertyOptional({ @ApiPropertyOptional({
@@ -21,6 +28,9 @@ export class UserLoginDto {
type: "string", type: "string",
description: "Optional route/context hint for linked SMS login.", description: "Optional route/context hint for linked SMS login.",
}) })
@IsOptional()
@IsString()
@MaxLength(64)
linkContext?: string; linkContext?: string;
} }

View File

@@ -1,18 +1,25 @@
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"; import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
import { IsNotEmpty, IsOptional, IsString, MaxLength } from "class-validator";
export class UserVerifyOtp { export class UserVerifyOtp {
@ApiProperty({ @ApiProperty({
example: "09226187419", example: "09226187419",
type: "string", type: "string",
description: "User login dto", description: "Mobile number (same value sent to send-otp)",
}) })
@IsString()
@IsNotEmpty()
@MaxLength(20)
username: string; username: string;
@ApiProperty({ @ApiProperty({
example: "258567", example: "258567",
type: "string", type: "string",
description: "User login verify dto", description: "OTP code from SMS",
}) })
@IsString()
@IsNotEmpty()
@MaxLength(16)
password: string; password: string;
@ApiPropertyOptional({ @ApiPropertyOptional({
@@ -20,6 +27,9 @@ export class UserVerifyOtp {
type: "string", type: "string",
description: "Raw token from linked SMS URL (?token=...).", description: "Raw token from linked SMS URL (?token=...).",
}) })
@IsOptional()
@IsString()
@MaxLength(128)
linkToken?: string; linkToken?: string;
@ApiPropertyOptional({ @ApiPropertyOptional({
@@ -27,5 +37,8 @@ export class UserVerifyOtp {
type: "string", type: "string",
description: "Optional route/context hint for linked SMS login.", description: "Optional route/context hint for linked SMS login.",
}) })
@IsOptional()
@IsString()
@MaxLength(64)
linkContext?: string; linkContext?: string;
} }

View File

@@ -48,6 +48,8 @@ import {
VideoCaptureV2ResponseDto, VideoCaptureV2ResponseDto,
} from "./dto/capture-part-v2.dto"; } from "./dto/capture-part-v2.dto";
import { GetMyClaimsV2ResponseDto, ClaimListItemV2Dto } from "./dto/my-claims-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 { ClaimDetailsV2ResponseDto } from "./dto/claim-details-v2.dto";
import { ClaimCaseStatus } from "src/Types&Enums/claim-request-management/claim-case-status.enum"; 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"; import { ClaimRequiredDocumentType, CarAngle } from "src/Types&Enums/claim-request-management/required-document-type.enum";
@@ -6009,6 +6011,7 @@ export class ClaimRequestManagementService {
async getMyClaimsV2( async getMyClaimsV2(
currentUserId: string, currentUserId: string,
actor?: { sub: string; role?: string }, actor?: { sub: string; role?: string },
query: ListQueryV2Dto = {},
): Promise<GetMyClaimsV2ResponseDto> { ): Promise<GetMyClaimsV2ResponseDto> {
try { try {
let claims: any[]; let claims: any[];
@@ -6050,7 +6053,25 @@ export class ClaimRequestManagementService {
createdAt: c.createdAt, createdAt: c.createdAt,
blameRequestId: c.blameRequestId?.toString(), blameRequestId: c.blameRequestId?.toString(),
})) as ClaimListItemV2Dto[]; })) 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) { } catch (error) {
if (error instanceof HttpException) throw error; if (error instanceof HttpException) throw error;
this.logger.error('getMyClaimsV2 failed', error); this.logger.error('getMyClaimsV2 failed', error);

View File

@@ -41,6 +41,7 @@ import {
VideoCaptureV2ResponseDto, VideoCaptureV2ResponseDto,
} from "./dto/capture-part-v2.dto"; } from "./dto/capture-part-v2.dto";
import { GetMyClaimsV2ResponseDto } from "./dto/my-claims-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 { ClaimDetailsV2ResponseDto } from "./dto/claim-details-v2.dto";
import { UserObjectionV2Dto } from "./dto/user-objection-v2.dto"; import { UserObjectionV2Dto } from "./dto/user-objection-v2.dto";
import { UserRatingDto } from "./dto/user-rating.dto"; import { UserRatingDto } from "./dto/user-rating.dto";
@@ -60,16 +61,24 @@ export class ClaimRequestManagementV2Controller {
@Get("requests") @Get("requests")
@ApiOperation({ @ApiOperation({
summary: "Get My Claims (V2)", 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({ @ApiResponse({
status: 200, status: 200,
description: "List of user claims", description: "List of user claims",
type: GetMyClaimsV2ResponseDto, type: GetMyClaimsV2ResponseDto,
}) })
async getMyClaims(@CurrentUser() user: any): Promise<GetMyClaimsV2ResponseDto> { async getMyClaims(
@CurrentUser() user: any,
@Query() query: ListQueryV2Dto,
): Promise<GetMyClaimsV2ResponseDto> {
try { try {
return await this.claimRequestManagementService.getMyClaimsV2(user.sub, user); return await this.claimRequestManagementService.getMyClaimsV2(
user.sub,
user,
query,
);
} catch (error) { } catch (error) {
if (error instanceof HttpException) throw error; if (error instanceof HttpException) throw error;
throw new InternalServerErrorException( throw new InternalServerErrorException(

View File

@@ -1,4 +1,4 @@
import { ApiProperty } from '@nestjs/swagger'; import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
export class ClaimListItemV2Dto { export class ClaimListItemV2Dto {
@ApiProperty({ description: 'Claim case ID', example: '507f1f77bcf86cd799439011' }) @ApiProperty({ description: 'Claim case ID', example: '507f1f77bcf86cd799439011' })
@@ -34,6 +34,17 @@ export class GetMyClaimsV2ResponseDto {
@ApiProperty({ description: 'List of user claims', type: [ClaimListItemV2Dto] }) @ApiProperty({ description: 'List of user claims', type: [ClaimListItemV2Dto] })
list: ClaimListItemV2Dto[]; list: ClaimListItemV2Dto[];
@ApiProperty({ description: 'Total count', example: 5 }) @ApiProperty({ description: 'Total count after search filter', example: 5 })
total: number; 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;
} }

View File

@@ -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;
}

View File

@@ -1,3 +1,4 @@
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
import { RequestManagementModel } from "src/request-management/entities/schema/request-management.schema"; import { RequestManagementModel } from "src/request-management/entities/schema/request-management.schema";
export class AllRequestDto { export class AllRequestDto {
@@ -122,7 +123,30 @@ export class AllRequestDtoV2 {
export class AllRequestDtoRsV2 { export class AllRequestDtoRsV2 {
data: AllRequestDtoV2[]; 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;
} }
} }

View File

@@ -21,6 +21,8 @@ import {
expertPortfolioFileIdsFromActivityEvents, expertPortfolioFileIdsFromActivityEvents,
objectIdsFromStringSet, objectIdsFromStringSet,
} from "src/helpers/expert-portfolio"; } 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 { 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 { BlameRequestDbService } from "src/request-management/entities/db-service/blame-request.db.service";
import { import {
@@ -275,7 +277,10 @@ export class ExpertBlameService {
return buckets; return buckets;
} }
async findAllV2(actor: any): Promise<AllRequestDtoRsV2> { async findAllV2(
actor: any,
query: ListQueryV2Dto = {},
): Promise<AllRequestDtoRsV2> {
try { try {
requireActorClientKey(actor); requireActorClientKey(actor);
const expertId = actor.sub; const expertId = actor.sub;
@@ -354,10 +359,41 @@ export class ExpertBlameService {
} }
} }
const items: AllRequestDtoV2[] = visibleCases.map( const paged = applyListQueryV2(visibleCases, {
(doc) => this.mapBlameRequestToListItemV2(doc), 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) { } 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));

View File

@@ -6,6 +6,7 @@ import {
InternalServerErrorException, InternalServerErrorException,
Param, Param,
Put, Put,
Query,
UseGuards, UseGuards,
} from "@nestjs/common"; } from "@nestjs/common";
import { ApiBearerAuth, ApiBody, ApiOperation, ApiParam, ApiTags } from "@nestjs/swagger"; 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 { CurrentUser } from "src/decorators/user.decorator";
import { RoleEnum } from "src/Types&Enums/role.enum"; import { RoleEnum } from "src/Types&Enums/role.enum";
import { ExpertBlameService } from "./expert-blame.service"; 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 { SubmitReplyDto } from "./dto/reply.dto";
import { ResendRequestDto } from "./dto/resend.dto"; import { ResendRequestDto } from "./dto/resend.dto";
@@ -27,9 +30,17 @@ export class ExpertBlameV2Controller {
constructor(private readonly expertBlameService: ExpertBlameService) { } constructor(private readonly expertBlameService: ExpertBlameService) { }
@Get() @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<AllRequestDtoRsV2> {
try { try {
return await this.expertBlameService.findAllV2(actor); return await this.expertBlameService.findAllV2(actor, query);
} catch (error) { } catch (error) {
if (error instanceof HttpException) throw error; if (error instanceof HttpException) throw error;
throw new InternalServerErrorException( throw new InternalServerErrorException(

View File

@@ -64,6 +64,15 @@ export class GetClaimListV2ResponseDto {
@ApiProperty({ type: [ClaimListItemV2Dto] }) @ApiProperty({ type: [ClaimListItemV2Dto] })
list: ClaimListItemV2Dto[]; list: ClaimListItemV2Dto[];
@ApiProperty({ description: 'Total count' }) @ApiProperty({ description: 'Total count after search filter' })
total: number; 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;
} }

View File

@@ -116,6 +116,8 @@ import {
/** Maximum sum of line `totalPayment` across the claim (Toman; priced parts + factor lines after validation). */ /** 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 { 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; 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) * 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) * 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<GetClaimListV2ResponseDto> { async getClaimListV2(
actor: any,
query: ListQueryV2Dto = {},
): Promise<GetClaimListV2ResponseDto> {
requireActorClientKey(actor); requireActorClientKey(actor);
const actorId = actor.sub; const actorId = actor.sub;
const clientKey = actor.clientKey as string; const clientKey = actor.clientKey as string;
@@ -3216,7 +3221,28 @@ export class ExpertClaimService {
}; };
}) as ClaimListItemV2Dto[]; }) 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,
};
} }
/** /**

View File

@@ -16,6 +16,8 @@ import { Roles } from "src/decorators/roles.decorator";
import { CurrentUser } from "src/decorators/user.decorator"; import { CurrentUser } from "src/decorators/user.decorator";
import { RoleEnum } from "src/Types&Enums/role.enum"; import { RoleEnum } from "src/Types&Enums/role.enum";
import { ExpertClaimService } from "./expert-claim.service"; 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 { ClaimSubmitResendV2Dto, SubmitExpertReplyV2Dto } from "./dto/expert-claim-v2.dto";
import { UpdateClaimDamagedPartsV2Dto } from "./dto/update-claim-damaged-parts-v2.dto"; import { UpdateClaimDamagedPartsV2Dto } from "./dto/update-claim-damaged-parts-v2.dto";
import { import {
@@ -96,10 +98,14 @@ export class ExpertClaimV2Controller {
@ApiOperation({ @ApiOperation({
summary: "List available claim requests for damage expert", summary: "List available claim requests for damage expert",
description: 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) { @ApiResponse({ status: 200, type: GetClaimListV2ResponseDto })
return await this.expertClaimService.getClaimListV2(actor); async getClaimListV2(
@CurrentUser() actor,
@Query() query: ListQueryV2Dto,
): Promise<GetClaimListV2ResponseDto> {
return await this.expertClaimService.getClaimListV2(actor, query);
} }
@Get("request/:claimRequestId") @Get("request/:claimRequestId")

View File

@@ -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);
});
});

View File

@@ -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<T> = {
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<T>(
rows: T[],
getters: ListItemGetters<T>,
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 };
}

View File

@@ -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<string, unknown>[];
@ApiProperty({ description: "Count after `search` filter" })
total: number;
@ApiPropertyOptional()
page?: number;
@ApiPropertyOptional()
limit?: number;
@ApiPropertyOptional()
totalPages?: number;
}

View File

@@ -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 { ExpertDbService } from "src/users/entities/db-service/expert.db.service";
import { UserDbService } from "src/users/entities/db-service/user.db.service"; import { UserDbService } from "src/users/entities/db-service/user.db.service";
import { parseIranLocalDateTime } from "src/helpers/iran-datetime"; 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 { AutoCloseRequestService } from "src/utils/cron/cron.service";
import { SmsOrchestrationService } from "src/sms-orchestration/sms-orchestration.service"; import { SmsOrchestrationService } from "src/sms-orchestration/sms-orchestration.service";
import { import {
@@ -4147,7 +4150,10 @@ export class RequestManagementService {
})); }));
} }
async getAllBlameRequestsV2(user: any): Promise<any> { async getAllBlameRequestsV2(
user: any,
query: ListQueryV2Dto = {},
): Promise<GetUserBlameListV2ResponseDto> {
try { try {
const userIdFilter = const userIdFilter =
user?.sub && Types.ObjectId.isValid(user.sub) user?.sub && Types.ObjectId.isValid(user.sub)
@@ -4158,13 +4164,16 @@ export class RequestManagementService {
: null; : null;
const filters = [userIdFilter, phoneFilter].filter(Boolean); 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( const requests = await this.blameRequestDbService.find(
filters.length === 1 ? (filters[0] as any) : ({ $or: filters } as any), 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) => { 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) { } catch (err) {
this.logger.error("Error: ", err); this.logger.error("Error: ", err);
throw new InternalServerErrorException("Something Went Wrong"); throw new InternalServerErrorException("Something Went Wrong");

View File

@@ -7,6 +7,7 @@ import {
Param, Param,
Post, Post,
Put, Put,
Query,
Req, Req,
UploadedFile, UploadedFile,
UploadedFiles, UploadedFiles,
@@ -39,6 +40,8 @@ import {
CarBodyFormDto, CarBodyFormDto,
} from "./dto/create-request-management.dto"; } from "./dto/create-request-management.dto";
import { RequestManagementService } from "./request-management.service"; 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. * V2 API surface for the redesigned `BlameRequest` model.
@@ -71,10 +74,16 @@ export class RequestManagementV2Controller {
@Get() @Get()
@Roles(RoleEnum.USER, RoleEnum.FIELD_EXPERT) @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( async getAllBlameRequestsV2(
@CurrentUser() user: any, @CurrentUser() user: any,
) { @Query() query: ListQueryV2Dto,
return this.requestManagementService.getAllBlameRequestsV2(user); ): Promise<GetUserBlameListV2ResponseDto> {
return this.requestManagementService.getAllBlameRequestsV2(user, query);
} }
/** /**

View File

@@ -63,6 +63,7 @@ export class SmsGatewayService {
async verifyLookUp(data: VerifyLookUpMessage) { async verifyLookUp(data: VerifyLookUpMessage) {
try { try {
console.log(data)
const body = await this.sender.verifyLookup(data); const body = await this.sender.verifyLookup(data);
if (!isKavenegarSuccess(body)) { if (!isKavenegarSuccess(body)) {
const normalized = normalizeKavenegarBody(body); const normalized = normalizeKavenegarBody(body);