1 parent
cb29c7d2c4
commit
7aecc6f96e
32 files changed
+532
-25
No files matched your search
@@ -0,0 +1,2 @@
|
|||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "issue_projectId_dueDate_idx" ON "issue"("projectId", "dueDate");
|
||||||
@@ -108,6 +108,7 @@ model Issue {
|
|||||||
@@index([createdAt])
|
@@index([createdAt])
|
||||||
@@index([projectId, createdAt])
|
@@index([projectId, createdAt])
|
||||||
@@index([dueDate])
|
@@index([dueDate])
|
||||||
|
@@index([projectId, dueDate])
|
||||||
@@map("issue")
|
@@map("issue")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Transform, TransformFnParams } from 'class-transformer';
|
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';
|
import { applyDecorators } from '@nestjs/common';
|
||||||
|
|
||||||
interface ToStringOptions {
|
interface ToStringOptions {
|
||||||
@@ -11,6 +11,7 @@ interface BaseDecoratorParams {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface StringDecoratorParams extends BaseDecoratorParams {}
|
export interface StringDecoratorParams extends BaseDecoratorParams {}
|
||||||
|
export interface BooleanDecoratorParams extends BaseDecoratorParams {}
|
||||||
export interface IntDecoratorParams extends BaseDecoratorParams {
|
export interface IntDecoratorParams extends BaseDecoratorParams {
|
||||||
min?: number;
|
min?: number;
|
||||||
max?: number;
|
max?: number;
|
||||||
@@ -24,6 +25,15 @@ const ToNumber = () =>
|
|||||||
return Number.isNaN(parsed) ? undefined : parsed;
|
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) =>
|
const ToString = (options?: ToStringOptions) =>
|
||||||
Transform(({ value }: TransformFnParams) => {
|
Transform(({ value }: TransformFnParams) => {
|
||||||
if (value === undefined || value === null) return undefined;
|
if (value === undefined || value === null) return undefined;
|
||||||
@@ -52,3 +62,11 @@ export function IntDecorator(params?: IntDecoratorParams) {
|
|||||||
|
|
||||||
return applyDecorators(...args);
|
return applyDecorators(...args);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function BooleanDecorator(params?: BooleanDecoratorParams) {
|
||||||
|
const args = [IsBoolean(), ToBoolean()];
|
||||||
|
|
||||||
|
if (params?.isOptional) args.push(IsOptional());
|
||||||
|
|
||||||
|
return applyDecorators(...args);
|
||||||
|
}
|
||||||
@@ -50,6 +50,7 @@ export const mockIssueRepository = (): IIssueRepository => ({
|
|||||||
findById: vi.fn(),
|
findById: vi.fn(),
|
||||||
findByCKey: vi.fn(),
|
findByCKey: vi.fn(),
|
||||||
findAll: vi.fn(),
|
findAll: vi.fn(),
|
||||||
|
findByDueDateRange: vi.fn(),
|
||||||
countByStatus: vi.fn(),
|
countByStatus: vi.fn(),
|
||||||
create: vi.fn(),
|
create: vi.fn(),
|
||||||
update: vi.fn(),
|
update: vi.fn(),
|
||||||
|
|||||||
+1
@@ -54,6 +54,7 @@ describe('GetIssueHistoryUseCase', () => {
|
|||||||
findById: vi.fn(),
|
findById: vi.fn(),
|
||||||
findByCKey: vi.fn(),
|
findByCKey: vi.fn(),
|
||||||
findAll: vi.fn(),
|
findAll: vi.fn(),
|
||||||
|
findByDueDateRange: vi.fn(),
|
||||||
countByStatus: vi.fn(),
|
countByStatus: vi.fn(),
|
||||||
create: vi.fn(),
|
create: vi.fn(),
|
||||||
update: vi.fn(),
|
update: vi.fn(),
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import { IssueWithUsersDto } from './issue-with-users.dto.js';
|
||||||
|
|
||||||
|
export class CalendarIssuesDto {
|
||||||
|
list: IssueWithUsersDto[];
|
||||||
|
/** В диапазоне задач больше, чем вернули — клиенту стоит сузить фильтр */
|
||||||
|
truncated: boolean;
|
||||||
|
}
|
||||||
@@ -20,6 +20,7 @@ describe('CreateIssueUseCase', () => {
|
|||||||
findById: vi.fn(),
|
findById: vi.fn(),
|
||||||
findByCKey: vi.fn(),
|
findByCKey: vi.fn(),
|
||||||
findAll: vi.fn(),
|
findAll: vi.fn(),
|
||||||
|
findByDueDateRange: vi.fn(),
|
||||||
countByStatus: vi.fn(),
|
countByStatus: vi.fn(),
|
||||||
create: vi.fn(),
|
create: vi.fn(),
|
||||||
update: vi.fn(),
|
update: vi.fn(),
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ describe('DeleteIssueUseCase', () => {
|
|||||||
findById: vi.fn(),
|
findById: vi.fn(),
|
||||||
findByCKey: vi.fn(),
|
findByCKey: vi.fn(),
|
||||||
findAll: vi.fn(),
|
findAll: vi.fn(),
|
||||||
|
findByDueDateRange: vi.fn(),
|
||||||
countByStatus: vi.fn(),
|
countByStatus: vi.fn(),
|
||||||
create: vi.fn(),
|
create: vi.fn(),
|
||||||
update: vi.fn(),
|
update: vi.fn(),
|
||||||
|
|||||||
+1
@@ -33,6 +33,7 @@ describe('GetIssueByCkeyUseCase', () => {
|
|||||||
findById: vi.fn(),
|
findById: vi.fn(),
|
||||||
findByCKey: vi.fn(),
|
findByCKey: vi.fn(),
|
||||||
findAll: vi.fn(),
|
findAll: vi.fn(),
|
||||||
|
findByDueDateRange: vi.fn(),
|
||||||
countByStatus: vi.fn(),
|
countByStatus: vi.fn(),
|
||||||
create: vi.fn(),
|
create: vi.fn(),
|
||||||
update: vi.fn(),
|
update: vi.fn(),
|
||||||
|
|||||||
+1
@@ -11,6 +11,7 @@ describe('GetIssueStatsUseCase', () => {
|
|||||||
findById: vi.fn(),
|
findById: vi.fn(),
|
||||||
findByCKey: vi.fn(),
|
findByCKey: vi.fn(),
|
||||||
findAll: vi.fn(),
|
findAll: vi.fn(),
|
||||||
|
findByDueDateRange: vi.fn(),
|
||||||
countByStatus: vi.fn(),
|
countByStatus: vi.fn(),
|
||||||
create: vi.fn(),
|
create: vi.fn(),
|
||||||
update: vi.fn(),
|
update: vi.fn(),
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ describe('GetIssueUseCase', () => {
|
|||||||
findById: vi.fn(),
|
findById: vi.fn(),
|
||||||
findByCKey: vi.fn(),
|
findByCKey: vi.fn(),
|
||||||
findAll: vi.fn(),
|
findAll: vi.fn(),
|
||||||
|
findByDueDateRange: vi.fn(),
|
||||||
countByStatus: vi.fn(),
|
countByStatus: vi.fn(),
|
||||||
create: vi.fn(),
|
create: vi.fn(),
|
||||||
update: vi.fn(),
|
update: vi.fn(),
|
||||||
|
|||||||
+10
@@ -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,
|
||||||
|
) {}
|
||||||
|
}
|
||||||
+88
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
+42
@@ -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<CalendarIssuesDto> {
|
||||||
|
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 };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,5 +7,6 @@ export class ListIssuesQuery {
|
|||||||
public readonly status?: string[],
|
public readonly status?: string[],
|
||||||
public readonly text?: string,
|
public readonly text?: string,
|
||||||
public readonly assigneeId?: string,
|
public readonly assigneeId?: string,
|
||||||
|
public readonly hasDueDate?: boolean,
|
||||||
) {}
|
) {}
|
||||||
}
|
}
|
||||||
@@ -32,6 +32,7 @@ describe('ListIssuesUseCase', () => {
|
|||||||
findById: vi.fn(),
|
findById: vi.fn(),
|
||||||
findByCKey: vi.fn(),
|
findByCKey: vi.fn(),
|
||||||
findAll: vi.fn(),
|
findAll: vi.fn(),
|
||||||
|
findByDueDateRange: vi.fn(),
|
||||||
countByStatus: vi.fn(),
|
countByStatus: vi.fn(),
|
||||||
create: vi.fn(),
|
create: vi.fn(),
|
||||||
update: vi.fn(),
|
update: vi.fn(),
|
||||||
@@ -61,6 +62,20 @@ describe('ListIssuesUseCase', () => {
|
|||||||
expect(result.meta).toEqual({ totalCount: 2, page: 1, totalPages: 1 });
|
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 () => {
|
it('returns an empty array when there are no issues', async () => {
|
||||||
vi.mocked(repository.findAll).mockResolvedValue({
|
vi.mocked(repository.findAll).mockResolvedValue({
|
||||||
list: [],
|
list: [],
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ export class ListIssuesUseCase {
|
|||||||
status: query.status,
|
status: query.status,
|
||||||
text: query.text,
|
text: query.text,
|
||||||
assigneeId: query.assigneeId,
|
assigneeId: query.assigneeId,
|
||||||
|
hasDueDate: query.hasDueDate,
|
||||||
});
|
});
|
||||||
|
|
||||||
return { list: await this.issueUserExpander.expandAll(list), meta };
|
return { list: await this.issueUserExpander.expandAll(list), meta };
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ describe('UpdateIssueUseCase', () => {
|
|||||||
findById: vi.fn(),
|
findById: vi.fn(),
|
||||||
findByCKey: vi.fn(),
|
findByCKey: vi.fn(),
|
||||||
findAll: vi.fn(),
|
findAll: vi.fn(),
|
||||||
|
findByDueDateRange: vi.fn(),
|
||||||
countByStatus: vi.fn(),
|
countByStatus: vi.fn(),
|
||||||
create: vi.fn(),
|
create: vi.fn(),
|
||||||
update: vi.fn(),
|
update: vi.fn(),
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
export class InvalidCalendarRangeException extends Error {
|
||||||
|
constructor(message: string) {
|
||||||
|
super(message);
|
||||||
|
this.name = 'InvalidCalendarRangeException';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -16,7 +16,18 @@ export interface IIssueRepository {
|
|||||||
text?: string;
|
text?: string;
|
||||||
assigneeId?: string;
|
assigneeId?: string;
|
||||||
projectId?: string;
|
projectId?: string;
|
||||||
|
hasDueDate?: boolean;
|
||||||
}): Promise<{ list: Issue[]; meta: PaginationMeta }>;
|
}): 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<Issue[]>;
|
||||||
countByStatus(userId: string, projectId?: string): Promise<Array<{ status: string; count: number }>>;
|
countByStatus(userId: string, projectId?: string): Promise<Array<{ status: string; count: number }>>;
|
||||||
/** Сохраняет задачу и записи о её создании в историю (автор — reporter) */
|
/** Сохраняет задачу и записи о её создании в историю (автор — reporter) */
|
||||||
create(issue: Issue): Promise<Issue>;
|
create(issue: Issue): Promise<Issue>;
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
+38
-4
@@ -18,6 +18,21 @@ function ownedByUser(userId: string) {
|
|||||||
return [{ assigneeId: userId }, { reporterId: userId }];
|
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 */
|
/** Строки истории одного сохранения задачи — с общим changeId */
|
||||||
function toChangeRows(
|
function toChangeRows(
|
||||||
issueId: number,
|
issueId: number,
|
||||||
@@ -57,6 +72,7 @@ export class IssuePrismaRepository implements IIssueRepository {
|
|||||||
text?: string;
|
text?: string;
|
||||||
assigneeId?: string;
|
assigneeId?: string;
|
||||||
projectId?: string;
|
projectId?: string;
|
||||||
|
hasDueDate?: boolean;
|
||||||
}): Promise<{ list: Issue[]; meta: PaginationMeta }> {
|
}): Promise<{ list: Issue[]; meta: PaginationMeta }> {
|
||||||
const page = Math.max(1, params?.page);
|
const page = Math.max(1, params?.page);
|
||||||
const limit = Math.max(1, params?.limit);
|
const limit = Math.max(1, params?.limit);
|
||||||
@@ -64,10 +80,8 @@ export class IssuePrismaRepository implements IIssueRepository {
|
|||||||
const queryArgs: IssueFindManyArgs = {
|
const queryArgs: IssueFindManyArgs = {
|
||||||
where: {
|
where: {
|
||||||
AND: [
|
AND: [
|
||||||
{ OR: ownedByUser(params.userId) },
|
...commonFilters(params),
|
||||||
...(params.status?.length ? [{ status: { in: params.status } }] : []),
|
...(params.hasDueDate !== undefined ? [{ dueDate: params.hasDueDate ? { not: null } : null }] : []),
|
||||||
...(params.assigneeId ? [{ assigneeId: params.assigneeId }] : []),
|
|
||||||
...(params.projectId ? [{ projectId: params.projectId }] : []),
|
|
||||||
...(params.text
|
...(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<Issue[]> {
|
||||||
|
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<Array<{ status: string; count: number }>> {
|
async countByStatus(userId: string, projectId?: string): Promise<Array<{ status: string; count: number }>> {
|
||||||
const groups = await this.prisma.issue.groupBy({
|
const groups = await this.prisma.issue.groupBy({
|
||||||
by: ['status'],
|
by: ['status'],
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import { GetIssueByCkeyUseCase } from './application/use-cases/get-issue-by-ckey
|
|||||||
import { IssueEventsService } from './application/services/issue-events.service.js';
|
import { IssueEventsService } from './application/services/issue-events.service.js';
|
||||||
import { IssueUserExpanderService } from './application/services/issue-user-expander.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 { 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({
|
@Module({
|
||||||
imports: [PrismaModule, ProjectModule, UserModule],
|
imports: [PrismaModule, ProjectModule, UserModule],
|
||||||
@@ -27,6 +28,7 @@ import { GetIssueStatsUseCase } from './application/use-cases/get-issue-stats/ge
|
|||||||
UpdateIssueUseCase,
|
UpdateIssueUseCase,
|
||||||
DeleteIssueUseCase,
|
DeleteIssueUseCase,
|
||||||
GetIssueStatsUseCase,
|
GetIssueStatsUseCase,
|
||||||
|
ListCalendarIssuesUseCase,
|
||||||
IssueResolver,
|
IssueResolver,
|
||||||
IssueEventsService,
|
IssueEventsService,
|
||||||
IssueUserExpanderService,
|
IssueUserExpanderService,
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import {
|
import {
|
||||||
|
BadRequestException,
|
||||||
Body,
|
Body,
|
||||||
Controller,
|
Controller,
|
||||||
Delete,
|
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 { 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 { IssueEventsService } from '../../application/services/issue-events.service.js';
|
||||||
import { GetIssueStatsUseCase } from '../../application/use-cases/get-issue-stats/get-issue-stats.use-case.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')
|
@Controller('issues')
|
||||||
export class IssueController {
|
export class IssueController {
|
||||||
@@ -48,6 +53,7 @@ export class IssueController {
|
|||||||
private readonly updateIssueUseCase: UpdateIssueUseCase,
|
private readonly updateIssueUseCase: UpdateIssueUseCase,
|
||||||
private readonly deleteIssueUseCase: DeleteIssueUseCase,
|
private readonly deleteIssueUseCase: DeleteIssueUseCase,
|
||||||
private readonly getIssueStatsUseCase: GetIssueStatsUseCase,
|
private readonly getIssueStatsUseCase: GetIssueStatsUseCase,
|
||||||
|
private readonly listCalendarIssuesUseCase: ListCalendarIssuesUseCase,
|
||||||
private readonly issueEventsService: IssueEventsService,
|
private readonly issueEventsService: IssueEventsService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@@ -80,6 +86,24 @@ export class IssueController {
|
|||||||
query.status,
|
query.status,
|
||||||
query.text,
|
query.text,
|
||||||
query.assigneeId,
|
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) {
|
if (error instanceof IssueNotFoundException || error instanceof IssueProjectNotFoundException) {
|
||||||
throw new NotFoundException(error.message);
|
throw new NotFoundException(error.message);
|
||||||
}
|
}
|
||||||
|
if (error instanceof InvalidCalendarRangeException) {
|
||||||
|
throw new BadRequestException(error.message);
|
||||||
|
}
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<string, unknown>) => {
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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[];
|
||||||
|
}
|
||||||
@@ -49,3 +49,28 @@ describe('FilterIssueDto.status', () => {
|
|||||||
expect(errors.map((el) => el.property)).toEqual(['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']);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,7 +1,5 @@
|
|||||||
import { Transform } from 'class-transformer';
|
import { BooleanDecorator, IntDecorator, StringDecorator } from '../../../../common/decorators/validators.js';
|
||||||
import { ArrayNotEmpty, IsArray, IsIn, IsOptional } from 'class-validator';
|
import { IssueStatusListDecorator } from './issue-status-list.decorator.js';
|
||||||
import { IntDecorator, StringDecorator } from '../../../../common/decorators/validators.js';
|
|
||||||
import { ISSUE_STATUSES } from '../../domain/value-objects/issue-status.vo.js';
|
|
||||||
|
|
||||||
export class FilterIssueDto {
|
export class FilterIssueDto {
|
||||||
@IntDecorator({ isOptional: true })
|
@IntDecorator({ isOptional: true })
|
||||||
@@ -16,22 +14,13 @@ export class FilterIssueDto {
|
|||||||
@StringDecorator({ isOptional: true })
|
@StringDecorator({ isOptional: true })
|
||||||
assigneeId?: string;
|
assigneeId?: string;
|
||||||
|
|
||||||
/** Comma-separated (`?status=OPEN,IN_PROGRESS`) or repeated (`?status=OPEN&status=IN_PROGRESS`) */
|
@IssueStatusListDecorator()
|
||||||
@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[];
|
status?: string[];
|
||||||
|
|
||||||
@StringDecorator({ isOptional: true })
|
@StringDecorator({ isOptional: true })
|
||||||
projectId?: string;
|
projectId?: string;
|
||||||
|
|
||||||
|
/** false — только задачи без срока (панель «без срока» рядом с календарём), true — только со сроком */
|
||||||
|
@BooleanDecorator({ isOptional: true })
|
||||||
|
hasDueDate?: boolean;
|
||||||
}
|
}
|
||||||
@@ -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 },
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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 { Args, Int, Mutation, Query, Resolver } from '@nestjs/graphql';
|
||||||
import { Session, type UserSession } from '@thallesp/nestjs-better-auth';
|
import { Session, type UserSession } from '@thallesp/nestjs-better-auth';
|
||||||
import { CreateIssueUseCase } from '../../application/use-cases/create-issue/create-issue.use-case.js';
|
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 { IssuePageType } from '../graphql/issue-page.type.js';
|
||||||
import { CreateIssueInput } from '../graphql/create-issue.input.js';
|
import { CreateIssueInput } from '../graphql/create-issue.input.js';
|
||||||
import { UpdateIssueInput } from '../graphql/update-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';
|
import { PaginationMeta } from '../../../../common/interfaces/pagination-meta.interface.js';
|
||||||
|
|
||||||
@Resolver(() => IssueType)
|
@Resolver(() => IssueType)
|
||||||
@@ -32,6 +37,7 @@ export class IssueResolver {
|
|||||||
private readonly listIssuesUseCase: ListIssuesUseCase,
|
private readonly listIssuesUseCase: ListIssuesUseCase,
|
||||||
private readonly updateIssueUseCase: UpdateIssueUseCase,
|
private readonly updateIssueUseCase: UpdateIssueUseCase,
|
||||||
private readonly deleteIssueUseCase: DeleteIssueUseCase,
|
private readonly deleteIssueUseCase: DeleteIssueUseCase,
|
||||||
|
private readonly listCalendarIssuesUseCase: ListCalendarIssuesUseCase,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@Query(() => IssueDetailType, { name: 'issue' })
|
@Query(() => IssueDetailType, { name: 'issue' })
|
||||||
@@ -50,8 +56,37 @@ export class IssueResolver {
|
|||||||
@Args('page', { type: () => Int, defaultValue: 0 }) page: number,
|
@Args('page', { type: () => Int, defaultValue: 0 }) page: number,
|
||||||
@Session() session: UserSession,
|
@Session() session: UserSession,
|
||||||
@Args('projectId', { type: () => String, nullable: true }) projectId?: string,
|
@Args('projectId', { type: () => String, nullable: true }) projectId?: string,
|
||||||
|
@Args('hasDueDate', { type: () => Boolean, nullable: true }) hasDueDate?: boolean,
|
||||||
): Promise<{ list: IssueWithUsersDto[]; meta: PaginationMeta }> {
|
): 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<CalendarIssuesDto> {
|
||||||
|
return this.handleErrors(() =>
|
||||||
|
this.listCalendarIssuesUseCase.execute(
|
||||||
|
new ListCalendarIssuesQuery(session.user.id, from, to, projectId, status, assigneeId),
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Mutation(() => IssueType, { name: 'createIssue' })
|
@Mutation(() => IssueType, { name: 'createIssue' })
|
||||||
@@ -108,6 +143,9 @@ export class IssueResolver {
|
|||||||
if (error instanceof IssueNotFoundException || error instanceof IssueProjectNotFoundException) {
|
if (error instanceof IssueNotFoundException || error instanceof IssueProjectNotFoundException) {
|
||||||
throw new NotFoundException(error.message);
|
throw new NotFoundException(error.message);
|
||||||
}
|
}
|
||||||
|
if (error instanceof InvalidCalendarRangeException) {
|
||||||
|
throw new BadRequestException(error.message);
|
||||||
|
}
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in new issue
Block a user