From f44590b8ca090591d0c7b8662589360bc75393ef Mon Sep 17 00:00:00 2001 From: Vadim Date: Tue, 6 Oct 2026 23:18:00 +0300 Subject: [PATCH] update issue filter --- .../list-issues/list-issues.query.ts | 2 +- .../issue.repository.interface.ts | 2 +- .../repositories/issue.prisma-repository.ts | 4 +- .../presentation/dto/filter-issue.dto.spec.ts | 51 +++++++++++++++++++ .../presentation/dto/filter-issue.dto.ts | 20 +++++++- 5 files changed, 73 insertions(+), 6 deletions(-) create mode 100644 src/modules/issue/presentation/dto/filter-issue.dto.spec.ts diff --git a/src/modules/issue/application/use-cases/list-issues/list-issues.query.ts b/src/modules/issue/application/use-cases/list-issues/list-issues.query.ts index 53ab85f..c826f54 100644 --- a/src/modules/issue/application/use-cases/list-issues/list-issues.query.ts +++ b/src/modules/issue/application/use-cases/list-issues/list-issues.query.ts @@ -4,7 +4,7 @@ export class ListIssuesQuery { public readonly page: number, public readonly userId: string, public readonly projectId?: string, - public readonly status?: string, + public readonly status?: string[], public readonly text?: string, public readonly assigneeId?: string, ) {} diff --git a/src/modules/issue/domain/repositories/issue.repository.interface.ts b/src/modules/issue/domain/repositories/issue.repository.interface.ts index bc385c8..0b06d89 100644 --- a/src/modules/issue/domain/repositories/issue.repository.interface.ts +++ b/src/modules/issue/domain/repositories/issue.repository.interface.ts @@ -12,7 +12,7 @@ export interface IIssueRepository { limit: number; page: number; userId: string; - status?: string; + status?: string[]; text?: string; assigneeId?: string; projectId?: string; diff --git a/src/modules/issue/infrastructure/persistence/prisma/repositories/issue.prisma-repository.ts b/src/modules/issue/infrastructure/persistence/prisma/repositories/issue.prisma-repository.ts index ad6af71..1738158 100644 --- a/src/modules/issue/infrastructure/persistence/prisma/repositories/issue.prisma-repository.ts +++ b/src/modules/issue/infrastructure/persistence/prisma/repositories/issue.prisma-repository.ts @@ -40,7 +40,7 @@ export class IssuePrismaRepository implements IIssueRepository { limit: number; page: number; userId: string; - status?: string; + status?: string[]; text?: string; assigneeId?: string; projectId?: string; @@ -52,7 +52,7 @@ export class IssuePrismaRepository implements IIssueRepository { where: { AND: [ { OR: ownedByUser(params.userId) }, - ...(params.status ? [{ status: params.status }] : []), + ...(params.status?.length ? [{ status: { in: params.status } }] : []), ...(params.assigneeId ? [{ assigneeId: params.assigneeId }] : []), ...(params.projectId ? [{ projectId: params.projectId }] : []), ...(params.text diff --git a/src/modules/issue/presentation/dto/filter-issue.dto.spec.ts b/src/modules/issue/presentation/dto/filter-issue.dto.spec.ts new file mode 100644 index 0000000..86aaddc --- /dev/null +++ b/src/modules/issue/presentation/dto/filter-issue.dto.spec.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from 'vitest'; +import { plainToInstance } from 'class-transformer'; +import { validateSync } from 'class-validator'; +import { FilterIssueDto } from './filter-issue.dto.js'; + +const parse = (query: Record) => { + const dto = plainToInstance(FilterIssueDto, query); + return { dto, errors: validateSync(dto) }; +}; + +describe('FilterIssueDto.status', () => { + it('leaves status undefined when absent', () => { + const { dto, errors } = parse({}); + + expect(dto.status).toBeUndefined(); + expect(errors).toHaveLength(0); + }); + + it('wraps a single status into an array', () => { + const { dto, errors } = parse({ status: 'OPEN' }); + + expect(dto.status).toEqual(['OPEN']); + expect(errors).toHaveLength(0); + }); + + it('splits a comma-separated list', () => { + const { dto, errors } = parse({ status: 'OPEN, IN_PROGRESS' }); + + expect(dto.status).toEqual(['OPEN', 'IN_PROGRESS']); + expect(errors).toHaveLength(0); + }); + + it('accepts a repeated query param', () => { + const { dto, errors } = parse({ status: ['OPEN', 'DONE'] }); + + expect(dto.status).toEqual(['OPEN', 'DONE']); + expect(errors).toHaveLength(0); + }); + + it('rejects an unknown status', () => { + const { errors } = parse({ status: 'OPEN,UNKNOWN' }); + + expect(errors.map((el) => el.property)).toEqual(['status']); + }); + + it('rejects an empty value', () => { + const { errors } = parse({ status: '' }); + + expect(errors.map((el) => el.property)).toEqual(['status']); + }); +}); diff --git a/src/modules/issue/presentation/dto/filter-issue.dto.ts b/src/modules/issue/presentation/dto/filter-issue.dto.ts index 1c3b09c..3969584 100644 --- a/src/modules/issue/presentation/dto/filter-issue.dto.ts +++ b/src/modules/issue/presentation/dto/filter-issue.dto.ts @@ -1,4 +1,7 @@ +import { Transform } from 'class-transformer'; +import { ArrayNotEmpty, IsArray, IsIn, IsOptional } from 'class-validator'; import { IntDecorator, StringDecorator } from '../../../../common/decorators/validators.js'; +import { ISSUE_STATUSES } from '../../domain/value-objects/issue-status.vo.js'; export class FilterIssueDto { @IntDecorator({ isOptional: true }) @@ -13,8 +16,21 @@ export class FilterIssueDto { @StringDecorator({ isOptional: true }) assigneeId?: string; - @StringDecorator({ isOptional: true }) - status?: string; + /** Comma-separated (`?status=OPEN,IN_PROGRESS`) or repeated (`?status=OPEN&status=IN_PROGRESS`) */ + @IsOptional() + @Transform(({ value }) => + value === undefined || value === null + ? undefined + : ([] as unknown[]) + .concat(value) + .flatMap((el) => String(el).split(',')) + .map((el) => el.trim()) + .filter(Boolean), + ) + @IsArray() + @ArrayNotEmpty() + @IsIn(ISSUE_STATUSES.map((status) => status.alias), { each: true }) + status?: string[]; @StringDecorator({ isOptional: true }) projectId?: string;