From 7aecc6f96e1caeee722634ae5e7ea11fc9f1c3b1 Mon Sep 17 00:00:00 2001 From: Vadim Date: Mon, 12 Oct 2026 11:07:45 +0300 Subject: [PATCH] calendar due date --- .../migration.sql | 2 + prisma/schema.prisma | 1 + src/common/decorators/validators.ts | 20 ++++- .../application/use-cases/test-helpers.ts | 1 + .../get-issue-history.use-case.spec.ts | 1 + .../application/dto/calendar-issues.dto.ts | 7 ++ .../create-issue.use-case.spec.ts | 1 + .../delete-issue.use-case.spec.ts | 1 + .../get-issue-by-ckey.use-case.spec.ts | 1 + .../get-issue-stats.use-case.spec.ts | 1 + .../get-issue/get-issue.use-case.spec.ts | 1 + .../list-calendar-issues.query.ts | 10 +++ .../list-calendar-issues.use-case.spec.ts | 88 +++++++++++++++++++ .../list-calendar-issues.use-case.ts | 42 +++++++++ .../list-issues/list-issues.query.ts | 1 + .../list-issues/list-issues.use-case.spec.ts | 15 ++++ .../list-issues/list-issues.use-case.ts | 1 + .../update-issue.use-case.spec.ts | 1 + .../invalid-calendar-range.exception.ts | 6 ++ .../issue.repository.interface.ts | 11 +++ .../value-objects/calendar-range.vo.spec.ts | 37 ++++++++ .../domain/value-objects/calendar-range.vo.ts | 33 +++++++ .../repositories/issue.prisma-repository.ts | 42 ++++++++- src/modules/issue/issue.module.ts | 2 + .../controllers/issue.controller.ts | 27 ++++++ .../dto/calendar-issues-query.dto.spec.ts | 46 ++++++++++ .../dto/calendar-issues-query.dto.ts | 28 ++++++ .../presentation/dto/filter-issue.dto.spec.ts | 25 ++++++ .../presentation/dto/filter-issue.dto.ts | 25 ++---- .../dto/issue-status-list.decorator.ts | 26 ++++++ .../graphql/calendar-issues.type.ts | 12 +++ .../presentation/resolvers/issue.resolver.ts | 42 ++++++++- 32 files changed, 532 insertions(+), 25 deletions(-) create mode 100644 prisma/migrations/20261012120000_issue_project_due_date_index/migration.sql create mode 100644 src/modules/issue/application/dto/calendar-issues.dto.ts create mode 100644 src/modules/issue/application/use-cases/list-calendar-issues/list-calendar-issues.query.ts create mode 100644 src/modules/issue/application/use-cases/list-calendar-issues/list-calendar-issues.use-case.spec.ts create mode 100644 src/modules/issue/application/use-cases/list-calendar-issues/list-calendar-issues.use-case.ts create mode 100644 src/modules/issue/domain/exceptions/invalid-calendar-range.exception.ts create mode 100644 src/modules/issue/domain/value-objects/calendar-range.vo.spec.ts create mode 100644 src/modules/issue/domain/value-objects/calendar-range.vo.ts create mode 100644 src/modules/issue/presentation/dto/calendar-issues-query.dto.spec.ts create mode 100644 src/modules/issue/presentation/dto/calendar-issues-query.dto.ts create mode 100644 src/modules/issue/presentation/dto/issue-status-list.decorator.ts create mode 100644 src/modules/issue/presentation/graphql/calendar-issues.type.ts diff --git a/prisma/migrations/20261012120000_issue_project_due_date_index/migration.sql b/prisma/migrations/20261012120000_issue_project_due_date_index/migration.sql new file mode 100644 index 0000000..bc8edad --- /dev/null +++ b/prisma/migrations/20261012120000_issue_project_due_date_index/migration.sql @@ -0,0 +1,2 @@ +-- CreateIndex +CREATE INDEX "issue_projectId_dueDate_idx" ON "issue"("projectId", "dueDate"); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 2f43a53..30ca167 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -108,6 +108,7 @@ model Issue { @@index([createdAt]) @@index([projectId, createdAt]) @@index([dueDate]) + @@index([projectId, dueDate]) @@map("issue") } diff --git a/src/common/decorators/validators.ts b/src/common/decorators/validators.ts index 09a3212..5ad9a50 100644 --- a/src/common/decorators/validators.ts +++ b/src/common/decorators/validators.ts @@ -1,5 +1,5 @@ import { Transform, TransformFnParams } from 'class-transformer'; -import { IsInt, IsNotEmpty, IsOptional, IsString, Max, MaxLength, Min } from 'class-validator'; +import { IsBoolean, IsInt, IsNotEmpty, IsOptional, IsString, Max, Min } from 'class-validator'; import { applyDecorators } from '@nestjs/common'; interface ToStringOptions { @@ -11,6 +11,7 @@ interface BaseDecoratorParams { } export interface StringDecoratorParams extends BaseDecoratorParams {} +export interface BooleanDecoratorParams extends BaseDecoratorParams {} export interface IntDecoratorParams extends BaseDecoratorParams { min?: number; max?: number; @@ -24,6 +25,15 @@ const ToNumber = () => return Number.isNaN(parsed) ? undefined : parsed; }); +/** Query-параметры приходят строками: 'true'/'false' → boolean, остальное оставляем как есть для IsBoolean */ +const ToBoolean = () => + Transform(({ value }) => { + if (value === undefined || value === null || value === '') return undefined; + if (value === 'true') return true; + if (value === 'false') return false; + return value; + }); + const ToString = (options?: ToStringOptions) => Transform(({ value }: TransformFnParams) => { if (value === undefined || value === null) return undefined; @@ -52,3 +62,11 @@ export function IntDecorator(params?: IntDecoratorParams) { return applyDecorators(...args); } + +export function BooleanDecorator(params?: BooleanDecoratorParams) { + const args = [IsBoolean(), ToBoolean()]; + + if (params?.isOptional) args.push(IsOptional()); + + return applyDecorators(...args); +} diff --git a/src/modules/comment/application/use-cases/test-helpers.ts b/src/modules/comment/application/use-cases/test-helpers.ts index a4dec4b..fdc95f1 100644 --- a/src/modules/comment/application/use-cases/test-helpers.ts +++ b/src/modules/comment/application/use-cases/test-helpers.ts @@ -50,6 +50,7 @@ export const mockIssueRepository = (): IIssueRepository => ({ findById: vi.fn(), findByCKey: vi.fn(), findAll: vi.fn(), + findByDueDateRange: vi.fn(), countByStatus: vi.fn(), create: vi.fn(), update: vi.fn(), diff --git a/src/modules/history/application/use-cases/get-issue-history/get-issue-history.use-case.spec.ts b/src/modules/history/application/use-cases/get-issue-history/get-issue-history.use-case.spec.ts index 4c2f5c9..4cd5116 100644 --- a/src/modules/history/application/use-cases/get-issue-history/get-issue-history.use-case.spec.ts +++ b/src/modules/history/application/use-cases/get-issue-history/get-issue-history.use-case.spec.ts @@ -54,6 +54,7 @@ describe('GetIssueHistoryUseCase', () => { findById: vi.fn(), findByCKey: vi.fn(), findAll: vi.fn(), + findByDueDateRange: vi.fn(), countByStatus: vi.fn(), create: vi.fn(), update: vi.fn(), diff --git a/src/modules/issue/application/dto/calendar-issues.dto.ts b/src/modules/issue/application/dto/calendar-issues.dto.ts new file mode 100644 index 0000000..e4f4059 --- /dev/null +++ b/src/modules/issue/application/dto/calendar-issues.dto.ts @@ -0,0 +1,7 @@ +import { IssueWithUsersDto } from './issue-with-users.dto.js'; + +export class CalendarIssuesDto { + list: IssueWithUsersDto[]; + /** В диапазоне задач больше, чем вернули — клиенту стоит сузить фильтр */ + truncated: boolean; +} diff --git a/src/modules/issue/application/use-cases/create-issue/create-issue.use-case.spec.ts b/src/modules/issue/application/use-cases/create-issue/create-issue.use-case.spec.ts index f017d17..d55dee0 100644 --- a/src/modules/issue/application/use-cases/create-issue/create-issue.use-case.spec.ts +++ b/src/modules/issue/application/use-cases/create-issue/create-issue.use-case.spec.ts @@ -20,6 +20,7 @@ describe('CreateIssueUseCase', () => { findById: vi.fn(), findByCKey: vi.fn(), findAll: vi.fn(), + findByDueDateRange: vi.fn(), countByStatus: vi.fn(), create: vi.fn(), update: vi.fn(), diff --git a/src/modules/issue/application/use-cases/delete-issue/delete-issue.use-case.spec.ts b/src/modules/issue/application/use-cases/delete-issue/delete-issue.use-case.spec.ts index f94a332..0cebce5 100644 --- a/src/modules/issue/application/use-cases/delete-issue/delete-issue.use-case.spec.ts +++ b/src/modules/issue/application/use-cases/delete-issue/delete-issue.use-case.spec.ts @@ -32,6 +32,7 @@ describe('DeleteIssueUseCase', () => { findById: vi.fn(), findByCKey: vi.fn(), findAll: vi.fn(), + findByDueDateRange: vi.fn(), countByStatus: vi.fn(), create: vi.fn(), update: vi.fn(), diff --git a/src/modules/issue/application/use-cases/get-issue-by-ckey/get-issue-by-ckey.use-case.spec.ts b/src/modules/issue/application/use-cases/get-issue-by-ckey/get-issue-by-ckey.use-case.spec.ts index 9aca372..dcd25e2 100644 --- a/src/modules/issue/application/use-cases/get-issue-by-ckey/get-issue-by-ckey.use-case.spec.ts +++ b/src/modules/issue/application/use-cases/get-issue-by-ckey/get-issue-by-ckey.use-case.spec.ts @@ -33,6 +33,7 @@ describe('GetIssueByCkeyUseCase', () => { findById: vi.fn(), findByCKey: vi.fn(), findAll: vi.fn(), + findByDueDateRange: vi.fn(), countByStatus: vi.fn(), create: vi.fn(), update: vi.fn(), diff --git a/src/modules/issue/application/use-cases/get-issue-stats/get-issue-stats.use-case.spec.ts b/src/modules/issue/application/use-cases/get-issue-stats/get-issue-stats.use-case.spec.ts index f5b6cab..8503c74 100644 --- a/src/modules/issue/application/use-cases/get-issue-stats/get-issue-stats.use-case.spec.ts +++ b/src/modules/issue/application/use-cases/get-issue-stats/get-issue-stats.use-case.spec.ts @@ -11,6 +11,7 @@ describe('GetIssueStatsUseCase', () => { findById: vi.fn(), findByCKey: vi.fn(), findAll: vi.fn(), + findByDueDateRange: vi.fn(), countByStatus: vi.fn(), create: vi.fn(), update: vi.fn(), diff --git a/src/modules/issue/application/use-cases/get-issue/get-issue.use-case.spec.ts b/src/modules/issue/application/use-cases/get-issue/get-issue.use-case.spec.ts index 92b6336..295a9cc 100644 --- a/src/modules/issue/application/use-cases/get-issue/get-issue.use-case.spec.ts +++ b/src/modules/issue/application/use-cases/get-issue/get-issue.use-case.spec.ts @@ -33,6 +33,7 @@ describe('GetIssueUseCase', () => { findById: vi.fn(), findByCKey: vi.fn(), findAll: vi.fn(), + findByDueDateRange: vi.fn(), countByStatus: vi.fn(), create: vi.fn(), update: vi.fn(), diff --git a/src/modules/issue/application/use-cases/list-calendar-issues/list-calendar-issues.query.ts b/src/modules/issue/application/use-cases/list-calendar-issues/list-calendar-issues.query.ts new file mode 100644 index 0000000..993f786 --- /dev/null +++ b/src/modules/issue/application/use-cases/list-calendar-issues/list-calendar-issues.query.ts @@ -0,0 +1,10 @@ +export class ListCalendarIssuesQuery { + constructor( + public readonly userId: string, + public readonly from: Date, + public readonly to: Date, + public readonly projectId?: string, + public readonly status?: string[], + public readonly assigneeId?: string, + ) {} +} diff --git a/src/modules/issue/application/use-cases/list-calendar-issues/list-calendar-issues.use-case.spec.ts b/src/modules/issue/application/use-cases/list-calendar-issues/list-calendar-issues.use-case.spec.ts new file mode 100644 index 0000000..6216941 --- /dev/null +++ b/src/modules/issue/application/use-cases/list-calendar-issues/list-calendar-issues.use-case.spec.ts @@ -0,0 +1,88 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { IIssueRepository } from '../../../domain/repositories/issue.repository.interface.js'; +import { Issue } from '../../../domain/entities/issue.entity.js'; +import { InvalidCalendarRangeException } from '../../../domain/exceptions/invalid-calendar-range.exception.js'; +import { ListCalendarIssuesUseCase } from './list-calendar-issues.use-case.js'; +import { ListCalendarIssuesQuery } from './list-calendar-issues.query.js'; +import type { IssueUserExpanderService } from '../../services/issue-user-expander.service.js'; +import type { IssueWithUsersDto } from '../../dto/issue-with-users.dto.js'; + +const FROM = new Date('2026-09-28T00:00:00+03:00'); +const TO = new Date('2026-11-09T00:00:00+03:00'); + +const buildIssue = (id: number) => + Issue.reconstitute({ + id, + key: `ISSUE-${id}`, + title: `Issue ${id}`, + content: null, + status: 'OPEN', + priority: 0, + assignee: null, + reporter: 'user-1', + projectId: 'project-1', + dueDate: new Date('2026-10-15T12:00:00Z'), + createdAt: new Date(), + updatedAt: new Date(), + }); + +describe('ListCalendarIssuesUseCase', () => { + let repository: IIssueRepository; + let issueUserExpander: IssueUserExpanderService; + let useCase: ListCalendarIssuesUseCase; + + beforeEach(() => { + repository = { + findById: vi.fn(), + findByCKey: vi.fn(), + findAll: vi.fn(), + findByDueDateRange: vi.fn(), + countByStatus: vi.fn(), + create: vi.fn(), + update: vi.fn(), + delete: vi.fn(), + }; + issueUserExpander = { + expandAll: vi.fn(async (issues: Issue[]) => issues.map((issue) => ({ id: issue.id }) as IssueWithUsersDto)), + } as unknown as IssueUserExpanderService; + useCase = new ListCalendarIssuesUseCase(repository, issueUserExpander); + }); + + it('forwards the range and filters, asking for one issue more than the limit', async () => { + vi.mocked(repository.findByDueDateRange).mockResolvedValue([buildIssue(1), buildIssue(2)]); + + const result = await useCase.execute( + new ListCalendarIssuesQuery('user-1', FROM, TO, 'project-1', ['OPEN'], 'user-2'), + ); + + expect(repository.findByDueDateRange).toHaveBeenCalledWith({ + from: FROM, + to: TO, + limit: ListCalendarIssuesUseCase.MAX_ISSUES + 1, + userId: 'user-1', + projectId: 'project-1', + status: ['OPEN'], + assigneeId: 'user-2', + }); + expect(result).toEqual({ list: [{ id: 1 }, { id: 2 }], truncated: false }); + }); + + it('cuts the list to the limit and marks it as truncated', async () => { + const issues = Array.from({ length: ListCalendarIssuesUseCase.MAX_ISSUES + 1 }, (_, index) => + buildIssue(index + 1), + ); + vi.mocked(repository.findByDueDateRange).mockResolvedValue(issues); + + const result = await useCase.execute(new ListCalendarIssuesQuery('user-1', FROM, TO)); + + expect(result.truncated).toBe(true); + expect(result.list).toHaveLength(ListCalendarIssuesUseCase.MAX_ISSUES); + }); + + it('rejects an invalid range without querying the repository', async () => { + await expect(useCase.execute(new ListCalendarIssuesQuery('user-1', TO, FROM))).rejects.toThrow( + InvalidCalendarRangeException, + ); + expect(repository.findByDueDateRange).not.toHaveBeenCalled(); + }); +}); diff --git a/src/modules/issue/application/use-cases/list-calendar-issues/list-calendar-issues.use-case.ts b/src/modules/issue/application/use-cases/list-calendar-issues/list-calendar-issues.use-case.ts new file mode 100644 index 0000000..c188436 --- /dev/null +++ b/src/modules/issue/application/use-cases/list-calendar-issues/list-calendar-issues.use-case.ts @@ -0,0 +1,42 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { ISSUE_REPOSITORY, type IIssueRepository } from '../../../domain/repositories/issue.repository.interface.js'; +import { CalendarRange } from '../../../domain/value-objects/calendar-range.vo.js'; +import { CalendarIssuesDto } from '../../dto/calendar-issues.dto.js'; +import { IssueUserExpanderService } from '../../services/issue-user-expander.service.js'; +import { ListCalendarIssuesQuery } from './list-calendar-issues.query.js'; + +/** + * Задачи для календаря: все задачи с dueDate в [from, to) без пагинации, + * но не больше MAX_ISSUES — иначе ответ помечается как truncated. + * Задачи без dueDate в календарь не попадают. + */ +@Injectable() +export class ListCalendarIssuesUseCase { + static readonly MAX_ISSUES = 500; + + constructor( + @Inject(ISSUE_REPOSITORY) + private readonly issueRepository: IIssueRepository, + private readonly issueUserExpander: IssueUserExpanderService, + ) {} + + async execute(query: ListCalendarIssuesQuery): Promise { + const range = CalendarRange.create({ from: query.from, to: query.to }); + + // Берём на одну больше лимита, чтобы узнать, есть ли что-то за ним + const issues = await this.issueRepository.findByDueDateRange({ + from: range.from, + to: range.to, + limit: ListCalendarIssuesUseCase.MAX_ISSUES + 1, + userId: query.userId, + projectId: query.projectId, + status: query.status, + assigneeId: query.assigneeId, + }); + + const truncated = issues.length > ListCalendarIssuesUseCase.MAX_ISSUES; + const list = truncated ? issues.slice(0, ListCalendarIssuesUseCase.MAX_ISSUES) : issues; + + return { list: await this.issueUserExpander.expandAll(list), truncated }; + } +} 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 c826f54..ad424f8 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 @@ -7,5 +7,6 @@ export class ListIssuesQuery { public readonly status?: string[], public readonly text?: string, public readonly assigneeId?: string, + public readonly hasDueDate?: boolean, ) {} } diff --git a/src/modules/issue/application/use-cases/list-issues/list-issues.use-case.spec.ts b/src/modules/issue/application/use-cases/list-issues/list-issues.use-case.spec.ts index 5d83443..f5498e1 100644 --- a/src/modules/issue/application/use-cases/list-issues/list-issues.use-case.spec.ts +++ b/src/modules/issue/application/use-cases/list-issues/list-issues.use-case.spec.ts @@ -32,6 +32,7 @@ describe('ListIssuesUseCase', () => { findById: vi.fn(), findByCKey: vi.fn(), findAll: vi.fn(), + findByDueDateRange: vi.fn(), countByStatus: vi.fn(), create: vi.fn(), update: vi.fn(), @@ -61,6 +62,20 @@ describe('ListIssuesUseCase', () => { expect(result.meta).toEqual({ totalCount: 2, page: 1, totalPages: 1 }); }); + it('forwards the hasDueDate filter', async () => { + vi.mocked(repository.findAll).mockResolvedValue({ + list: [], + meta: { totalCount: 0, page: 1, totalPages: 0 }, + }); + vi.mocked(issueUserExpander.expandAll).mockResolvedValue([]); + + await useCase.execute(new ListIssuesQuery(20, 1, 'user-1', 'project-1', undefined, undefined, undefined, false)); + + expect(repository.findAll).toHaveBeenCalledWith( + expect.objectContaining({ projectId: 'project-1', hasDueDate: false }), + ); + }); + it('returns an empty array when there are no issues', async () => { vi.mocked(repository.findAll).mockResolvedValue({ list: [], diff --git a/src/modules/issue/application/use-cases/list-issues/list-issues.use-case.ts b/src/modules/issue/application/use-cases/list-issues/list-issues.use-case.ts index 5478b3d..cf2c6a1 100644 --- a/src/modules/issue/application/use-cases/list-issues/list-issues.use-case.ts +++ b/src/modules/issue/application/use-cases/list-issues/list-issues.use-case.ts @@ -22,6 +22,7 @@ export class ListIssuesUseCase { status: query.status, text: query.text, assigneeId: query.assigneeId, + hasDueDate: query.hasDueDate, }); return { list: await this.issueUserExpander.expandAll(list), meta }; diff --git a/src/modules/issue/application/use-cases/update-issue/update-issue.use-case.spec.ts b/src/modules/issue/application/use-cases/update-issue/update-issue.use-case.spec.ts index 86ff49a..d4f1560 100644 --- a/src/modules/issue/application/use-cases/update-issue/update-issue.use-case.spec.ts +++ b/src/modules/issue/application/use-cases/update-issue/update-issue.use-case.spec.ts @@ -37,6 +37,7 @@ describe('UpdateIssueUseCase', () => { findById: vi.fn(), findByCKey: vi.fn(), findAll: vi.fn(), + findByDueDateRange: vi.fn(), countByStatus: vi.fn(), create: vi.fn(), update: vi.fn(), diff --git a/src/modules/issue/domain/exceptions/invalid-calendar-range.exception.ts b/src/modules/issue/domain/exceptions/invalid-calendar-range.exception.ts new file mode 100644 index 0000000..8606d88 --- /dev/null +++ b/src/modules/issue/domain/exceptions/invalid-calendar-range.exception.ts @@ -0,0 +1,6 @@ +export class InvalidCalendarRangeException extends Error { + constructor(message: string) { + super(message); + this.name = 'InvalidCalendarRangeException'; + } +} diff --git a/src/modules/issue/domain/repositories/issue.repository.interface.ts b/src/modules/issue/domain/repositories/issue.repository.interface.ts index ccb8ca8..8997fe4 100644 --- a/src/modules/issue/domain/repositories/issue.repository.interface.ts +++ b/src/modules/issue/domain/repositories/issue.repository.interface.ts @@ -16,7 +16,18 @@ export interface IIssueRepository { text?: string; assigneeId?: string; projectId?: string; + hasDueDate?: boolean; }): Promise<{ list: Issue[]; meta: PaginationMeta }>; + /** Задачи с dueDate в [from, to), по возрастанию dueDate, не больше limit */ + findByDueDateRange(params: { + from: Date; + to: Date; + limit: number; + userId: string; + status?: string[]; + assigneeId?: string; + projectId?: string; + }): Promise; countByStatus(userId: string, projectId?: string): Promise>; /** Сохраняет задачу и записи о её создании в историю (автор — reporter) */ create(issue: Issue): Promise; diff --git a/src/modules/issue/domain/value-objects/calendar-range.vo.spec.ts b/src/modules/issue/domain/value-objects/calendar-range.vo.spec.ts new file mode 100644 index 0000000..e6661f6 --- /dev/null +++ b/src/modules/issue/domain/value-objects/calendar-range.vo.spec.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from 'vitest'; +import { CalendarRange } from './calendar-range.vo.js'; +import { InvalidCalendarRangeException } from '../exceptions/invalid-calendar-range.exception.js'; + +describe('CalendarRange', () => { + it('accepts a 6-week month grid', () => { + const from = new Date('2026-09-28T00:00:00+03:00'); + const to = new Date('2026-11-09T00:00:00+03:00'); + + const range = CalendarRange.create({ from, to }); + + expect(range.from).toBe(from); + expect(range.to).toBe(to); + }); + + it('rejects an invalid date', () => { + expect(() => CalendarRange.create({ from: new Date('nope'), to: new Date() })).toThrow( + InvalidCalendarRangeException, + ); + }); + + it('rejects from equal to or after to', () => { + const date = new Date('2026-10-01T00:00:00Z'); + + expect(() => CalendarRange.create({ from: date, to: date })).toThrow(InvalidCalendarRangeException); + expect(() => CalendarRange.create({ from: new Date('2026-10-02T00:00:00Z'), to: date })).toThrow( + InvalidCalendarRangeException, + ); + }); + + it(`rejects a range longer than ${CalendarRange.MAX_DAYS} days`, () => { + const from = new Date('2026-10-01T00:00:00Z'); + const to = new Date(from.getTime() + (CalendarRange.MAX_DAYS * 24 * 60 * 60 * 1000 + 1)); + + expect(() => CalendarRange.create({ from, to })).toThrow(InvalidCalendarRangeException); + }); +}); diff --git a/src/modules/issue/domain/value-objects/calendar-range.vo.ts b/src/modules/issue/domain/value-objects/calendar-range.vo.ts new file mode 100644 index 0000000..d461875 --- /dev/null +++ b/src/modules/issue/domain/value-objects/calendar-range.vo.ts @@ -0,0 +1,33 @@ +import { InvalidCalendarRangeException } from '../exceptions/invalid-calendar-range.exception.js'; + +const DAY_MS = 24 * 60 * 60 * 1000; + +/** + * Диапазон календаря по dueDate — полуоткрытый интервал [from, to). + * Границы — моменты времени: клиент сам считает начало/конец видимой сетки + * в своём часовом поясе, сервер про месяцы и часовые пояса не знает. + */ +export class CalendarRange { + /** Сетка месяца — 6 недель (42 дня), плюс запас на переход на летнее/зимнее время */ + static readonly MAX_DAYS = 45; + + private constructor( + public readonly from: Date, + public readonly to: Date, + ) {} + + static create(params: { from: Date; to: Date }): CalendarRange { + const { from, to } = params; + if (Number.isNaN(from.getTime()) || Number.isNaN(to.getTime())) { + throw new InvalidCalendarRangeException('Некорректная дата from или to'); + } + if (from >= to) { + throw new InvalidCalendarRangeException('Дата from должна быть раньше даты to'); + } + if (to.getTime() - from.getTime() > CalendarRange.MAX_DAYS * DAY_MS) { + throw new InvalidCalendarRangeException(`Диапазон больше ${CalendarRange.MAX_DAYS} дней`); + } + + return new CalendarRange(from, to); + } +} 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 6272bfe..f18f39c 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 @@ -18,6 +18,21 @@ function ownedByUser(userId: string) { return [{ assigneeId: userId }, { reporterId: userId }]; } +/** Видимость и фильтры, общие для списка и календаря */ +function commonFilters(params: { + userId: string; + status?: string[]; + assigneeId?: string; + projectId?: string; +}): Prisma.IssueWhereInput[] { + return [ + { OR: ownedByUser(params.userId) }, + ...(params.status?.length ? [{ status: { in: params.status } }] : []), + ...(params.assigneeId ? [{ assigneeId: params.assigneeId }] : []), + ...(params.projectId ? [{ projectId: params.projectId }] : []), + ]; +} + /** Строки истории одного сохранения задачи — с общим changeId */ function toChangeRows( issueId: number, @@ -57,6 +72,7 @@ export class IssuePrismaRepository implements IIssueRepository { text?: string; assigneeId?: string; projectId?: string; + hasDueDate?: boolean; }): Promise<{ list: Issue[]; meta: PaginationMeta }> { const page = Math.max(1, params?.page); const limit = Math.max(1, params?.limit); @@ -64,10 +80,8 @@ export class IssuePrismaRepository implements IIssueRepository { const queryArgs: IssueFindManyArgs = { where: { AND: [ - { OR: ownedByUser(params.userId) }, - ...(params.status?.length ? [{ status: { in: params.status } }] : []), - ...(params.assigneeId ? [{ assigneeId: params.assigneeId }] : []), - ...(params.projectId ? [{ projectId: params.projectId }] : []), + ...commonFilters(params), + ...(params.hasDueDate !== undefined ? [{ dueDate: params.hasDueDate ? { not: null } : null }] : []), ...(params.text ? [ { @@ -98,6 +112,26 @@ export class IssuePrismaRepository implements IIssueRepository { }; } + async findByDueDateRange(params: { + from: Date; + to: Date; + limit: number; + userId: string; + status?: string[]; + assigneeId?: string; + projectId?: string; + }): Promise { + const records = await this.prisma.issue.findMany({ + where: { + AND: [...commonFilters(params), { dueDate: { gte: params.from, lt: params.to } }], + }, + take: Math.max(1, params.limit), + orderBy: [{ dueDate: 'asc' }, { id: 'asc' }], + }); + + return records.map(IssuePrismaMapper.toDomain); + } + async countByStatus(userId: string, projectId?: string): Promise> { const groups = await this.prisma.issue.groupBy({ by: ['status'], diff --git a/src/modules/issue/issue.module.ts b/src/modules/issue/issue.module.ts index 6fbc7a3..94cd066 100644 --- a/src/modules/issue/issue.module.ts +++ b/src/modules/issue/issue.module.ts @@ -15,6 +15,7 @@ import { GetIssueByCkeyUseCase } from './application/use-cases/get-issue-by-ckey import { IssueEventsService } from './application/services/issue-events.service.js'; import { IssueUserExpanderService } from './application/services/issue-user-expander.service.js'; import { GetIssueStatsUseCase } from './application/use-cases/get-issue-stats/get-issue-stats.use-case.js'; +import { ListCalendarIssuesUseCase } from './application/use-cases/list-calendar-issues/list-calendar-issues.use-case.js'; @Module({ imports: [PrismaModule, ProjectModule, UserModule], @@ -27,6 +28,7 @@ import { GetIssueStatsUseCase } from './application/use-cases/get-issue-stats/ge UpdateIssueUseCase, DeleteIssueUseCase, GetIssueStatsUseCase, + ListCalendarIssuesUseCase, IssueResolver, IssueEventsService, IssueUserExpanderService, diff --git a/src/modules/issue/presentation/controllers/issue.controller.ts b/src/modules/issue/presentation/controllers/issue.controller.ts index a699d9f..18ead69 100644 --- a/src/modules/issue/presentation/controllers/issue.controller.ts +++ b/src/modules/issue/presentation/controllers/issue.controller.ts @@ -1,4 +1,5 @@ import { + BadRequestException, Body, Controller, Delete, @@ -37,6 +38,10 @@ import { GetIssueByCkeyUseCase } from '../../application/use-cases/get-issue-by- import { GetIssueByCkeyQuery } from '../../application/use-cases/get-issue-by-ckey/get-issue-by-ckey.query.js'; import { IssueEventsService } from '../../application/services/issue-events.service.js'; import { GetIssueStatsUseCase } from '../../application/use-cases/get-issue-stats/get-issue-stats.use-case.js'; +import { ListCalendarIssuesUseCase } from '../../application/use-cases/list-calendar-issues/list-calendar-issues.use-case.js'; +import { ListCalendarIssuesQuery } from '../../application/use-cases/list-calendar-issues/list-calendar-issues.query.js'; +import { CalendarIssuesQueryDto } from '../dto/calendar-issues-query.dto.js'; +import { InvalidCalendarRangeException } from '../../domain/exceptions/invalid-calendar-range.exception.js'; @Controller('issues') export class IssueController { @@ -48,6 +53,7 @@ export class IssueController { private readonly updateIssueUseCase: UpdateIssueUseCase, private readonly deleteIssueUseCase: DeleteIssueUseCase, private readonly getIssueStatsUseCase: GetIssueStatsUseCase, + private readonly listCalendarIssuesUseCase: ListCalendarIssuesUseCase, private readonly issueEventsService: IssueEventsService, ) {} @@ -80,6 +86,24 @@ export class IssueController { query.status, query.text, query.assigneeId, + query.hasDueDate, + ), + ); + } + + /** Задачи с dueDate в [from, to) — для календаря, без пагинации */ + @Get('calendar') + calendar(@Query() query: CalendarIssuesQueryDto, @Session() session: UserSession) { + return this.handleErrors(() => + this.listCalendarIssuesUseCase.execute( + new ListCalendarIssuesQuery( + session.user.id, + new Date(query.from), + new Date(query.to), + query.projectId, + query.status, + query.assigneeId, + ), ), ); } @@ -162,6 +186,9 @@ export class IssueController { if (error instanceof IssueNotFoundException || error instanceof IssueProjectNotFoundException) { throw new NotFoundException(error.message); } + if (error instanceof InvalidCalendarRangeException) { + throw new BadRequestException(error.message); + } throw error; } } diff --git a/src/modules/issue/presentation/dto/calendar-issues-query.dto.spec.ts b/src/modules/issue/presentation/dto/calendar-issues-query.dto.spec.ts new file mode 100644 index 0000000..c9ce079 --- /dev/null +++ b/src/modules/issue/presentation/dto/calendar-issues-query.dto.spec.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from 'vitest'; +import { plainToInstance } from 'class-transformer'; +import { validateSync } from 'class-validator'; +import { CalendarIssuesQueryDto } from './calendar-issues-query.dto.js'; + +const parse = (query: Record) => { + const dto = plainToInstance(CalendarIssuesQueryDto, query); + return { dto, errors: validateSync(dto) }; +}; + +describe('CalendarIssuesQueryDto', () => { + it('accepts moments with an offset or Z', () => { + const { errors } = parse({ from: '2026-09-28T00:00:00+03:00', to: '2026-11-09T00:00:00.000Z' }); + + expect(errors).toHaveLength(0); + }); + + it('requires from and to', () => { + const { errors } = parse({}); + + expect(errors.map((el) => el.property)).toEqual(['from', 'to']); + }); + + it('rejects a moment without an offset', () => { + const { errors } = parse({ from: '2026-09-28T00:00:00', to: '2026-11-09T00:00:00Z' }); + + expect(errors.map((el) => el.property)).toEqual(['from']); + }); + + it('rejects a plain date', () => { + const { errors } = parse({ from: '2026-09-28', to: '2026-11-09T00:00:00Z' }); + + expect(errors.map((el) => el.property)).toEqual(['from']); + }); + + it('parses the status list', () => { + const { dto, errors } = parse({ + from: '2026-09-28T00:00:00Z', + to: '2026-11-09T00:00:00Z', + status: 'OPEN,IN_PROGRESS', + }); + + expect(dto.status).toEqual(['OPEN', 'IN_PROGRESS']); + expect(errors).toHaveLength(0); + }); +}); diff --git a/src/modules/issue/presentation/dto/calendar-issues-query.dto.ts b/src/modules/issue/presentation/dto/calendar-issues-query.dto.ts new file mode 100644 index 0000000..a7eb25d --- /dev/null +++ b/src/modules/issue/presentation/dto/calendar-issues-query.dto.ts @@ -0,0 +1,28 @@ +import { IsISO8601, Matches } from 'class-validator'; +import { StringDecorator } from '../../../../common/decorators/validators.js'; +import { IssueStatusListDecorator } from './issue-status-list.decorator.js'; + +/** Без смещения дата трактовалась бы в часовом поясе сервера */ +const WITH_OFFSET = /(Z|[+-]\d{2}:\d{2})$/; +const MOMENT_MESSAGE = '$property: ожидается ISO 8601 со смещением, например 2026-10-01T00:00:00+03:00'; + +export class CalendarIssuesQueryDto { + /** Начало видимой сетки календаря, включительно */ + @IsISO8601({ strict: true }, { message: MOMENT_MESSAGE }) + @Matches(WITH_OFFSET, { message: MOMENT_MESSAGE }) + from: string; + + /** Конец видимой сетки календаря, не включительно */ + @IsISO8601({ strict: true }, { message: MOMENT_MESSAGE }) + @Matches(WITH_OFFSET, { message: MOMENT_MESSAGE }) + to: string; + + @StringDecorator({ isOptional: true }) + projectId?: string; + + @StringDecorator({ isOptional: true }) + assigneeId?: string; + + @IssueStatusListDecorator() + status?: string[]; +} diff --git a/src/modules/issue/presentation/dto/filter-issue.dto.spec.ts b/src/modules/issue/presentation/dto/filter-issue.dto.spec.ts index 86aaddc..d118dd3 100644 --- a/src/modules/issue/presentation/dto/filter-issue.dto.spec.ts +++ b/src/modules/issue/presentation/dto/filter-issue.dto.spec.ts @@ -49,3 +49,28 @@ describe('FilterIssueDto.status', () => { expect(errors.map((el) => el.property)).toEqual(['status']); }); }); + +describe('FilterIssueDto.hasDueDate', () => { + it('leaves hasDueDate undefined when absent', () => { + const { dto, errors } = parse({}); + + expect(dto.hasDueDate).toBeUndefined(); + expect(errors).toHaveLength(0); + }); + + it.each([ + ['true', true], + ['false', false], + ])('parses "%s"', (value, expected) => { + const { dto, errors } = parse({ hasDueDate: value }); + + expect(dto.hasDueDate).toBe(expected); + expect(errors).toHaveLength(0); + }); + + it('rejects a non-boolean value', () => { + const { errors } = parse({ hasDueDate: 'yes' }); + + expect(errors.map((el) => el.property)).toEqual(['hasDueDate']); + }); +}); diff --git a/src/modules/issue/presentation/dto/filter-issue.dto.ts b/src/modules/issue/presentation/dto/filter-issue.dto.ts index 3969584..dbd5bc3 100644 --- a/src/modules/issue/presentation/dto/filter-issue.dto.ts +++ b/src/modules/issue/presentation/dto/filter-issue.dto.ts @@ -1,7 +1,5 @@ -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'; +import { BooleanDecorator, IntDecorator, StringDecorator } from '../../../../common/decorators/validators.js'; +import { IssueStatusListDecorator } from './issue-status-list.decorator.js'; export class FilterIssueDto { @IntDecorator({ isOptional: true }) @@ -16,22 +14,13 @@ export class FilterIssueDto { @StringDecorator({ isOptional: true }) assigneeId?: 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 }) + @IssueStatusListDecorator() status?: string[]; @StringDecorator({ isOptional: true }) projectId?: string; + + /** false — только задачи без срока (панель «без срока» рядом с календарём), true — только со сроком */ + @BooleanDecorator({ isOptional: true }) + hasDueDate?: boolean; } diff --git a/src/modules/issue/presentation/dto/issue-status-list.decorator.ts b/src/modules/issue/presentation/dto/issue-status-list.decorator.ts new file mode 100644 index 0000000..199a0fb --- /dev/null +++ b/src/modules/issue/presentation/dto/issue-status-list.decorator.ts @@ -0,0 +1,26 @@ +import { applyDecorators } from '@nestjs/common'; +import { Transform } from 'class-transformer'; +import { ArrayNotEmpty, IsArray, IsIn, IsOptional } from 'class-validator'; +import { ISSUE_STATUSES } from '../../domain/value-objects/issue-status.vo.js'; + +/** Comma-separated (`?status=OPEN,IN_PROGRESS`) or repeated (`?status=OPEN&status=IN_PROGRESS`) */ +export function IssueStatusListDecorator() { + return applyDecorators( + 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 }, + ), + ); +} diff --git a/src/modules/issue/presentation/graphql/calendar-issues.type.ts b/src/modules/issue/presentation/graphql/calendar-issues.type.ts new file mode 100644 index 0000000..4f6522c --- /dev/null +++ b/src/modules/issue/presentation/graphql/calendar-issues.type.ts @@ -0,0 +1,12 @@ +import { Field, ObjectType } from '@nestjs/graphql'; +import { IssueDetailType } from './issue-detail.type.js'; + +@ObjectType('CalendarIssues') +export class CalendarIssuesType { + @Field(() => [IssueDetailType]) + list: IssueDetailType[]; + + /** В диапазоне задач больше, чем вернули — клиенту стоит сузить фильтр */ + @Field() + truncated: boolean; +} diff --git a/src/modules/issue/presentation/resolvers/issue.resolver.ts b/src/modules/issue/presentation/resolvers/issue.resolver.ts index 5116297..588011a 100644 --- a/src/modules/issue/presentation/resolvers/issue.resolver.ts +++ b/src/modules/issue/presentation/resolvers/issue.resolver.ts @@ -1,4 +1,4 @@ -import { NotFoundException } from '@nestjs/common'; +import { BadRequestException, NotFoundException } from '@nestjs/common'; import { Args, Int, Mutation, Query, Resolver } from '@nestjs/graphql'; import { Session, type UserSession } from '@thallesp/nestjs-better-auth'; import { CreateIssueUseCase } from '../../application/use-cases/create-issue/create-issue.use-case.js'; @@ -21,6 +21,11 @@ import { IssueDetailType } from '../graphql/issue-detail.type.js'; import { IssuePageType } from '../graphql/issue-page.type.js'; import { CreateIssueInput } from '../graphql/create-issue.input.js'; import { UpdateIssueInput } from '../graphql/update-issue.input.js'; +import { CalendarIssuesType } from '../graphql/calendar-issues.type.js'; +import { ListCalendarIssuesUseCase } from '../../application/use-cases/list-calendar-issues/list-calendar-issues.use-case.js'; +import { ListCalendarIssuesQuery } from '../../application/use-cases/list-calendar-issues/list-calendar-issues.query.js'; +import { CalendarIssuesDto } from '../../application/dto/calendar-issues.dto.js'; +import { InvalidCalendarRangeException } from '../../domain/exceptions/invalid-calendar-range.exception.js'; import { PaginationMeta } from '../../../../common/interfaces/pagination-meta.interface.js'; @Resolver(() => IssueType) @@ -32,6 +37,7 @@ export class IssueResolver { private readonly listIssuesUseCase: ListIssuesUseCase, private readonly updateIssueUseCase: UpdateIssueUseCase, private readonly deleteIssueUseCase: DeleteIssueUseCase, + private readonly listCalendarIssuesUseCase: ListCalendarIssuesUseCase, ) {} @Query(() => IssueDetailType, { name: 'issue' }) @@ -50,8 +56,37 @@ export class IssueResolver { @Args('page', { type: () => Int, defaultValue: 0 }) page: number, @Session() session: UserSession, @Args('projectId', { type: () => String, nullable: true }) projectId?: string, + @Args('hasDueDate', { type: () => Boolean, nullable: true }) hasDueDate?: boolean, ): Promise<{ list: IssueWithUsersDto[]; meta: PaginationMeta }> { - return this.listIssuesUseCase.execute(new ListIssuesQuery(limit, page, session.user.id, projectId)); + return this.listIssuesUseCase.execute( + new ListIssuesQuery( + limit, + page, + session.user.id, + projectId, + undefined, + undefined, + undefined, + hasDueDate ?? undefined, + ), + ); + } + + /** Задачи с dueDate в [from, to) — для календаря, без пагинации */ + @Query(() => CalendarIssuesType, { name: 'calendarIssues' }) + calendar( + @Args('from', { type: () => Date }) from: Date, + @Args('to', { type: () => Date }) to: Date, + @Session() session: UserSession, + @Args('projectId', { type: () => String, nullable: true }) projectId?: string, + @Args('status', { type: () => [String], nullable: true }) status?: string[], + @Args('assigneeId', { type: () => String, nullable: true }) assigneeId?: string, + ): Promise { + return this.handleErrors(() => + this.listCalendarIssuesUseCase.execute( + new ListCalendarIssuesQuery(session.user.id, from, to, projectId, status, assigneeId), + ), + ); } @Mutation(() => IssueType, { name: 'createIssue' }) @@ -108,6 +143,9 @@ export class IssueResolver { if (error instanceof IssueNotFoundException || error instanceof IssueProjectNotFoundException) { throw new NotFoundException(error.message); } + if (error instanceof InvalidCalendarRangeException) { + throw new BadRequestException(error.message); + } throw error; } }