1 parent
4b6ecfbf5d
commit
5cfc8986d7
22 files changed
+145
-44
No files matched your search
+1
-1
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"singleQuote": true,
|
||||
"trailingComma": "all",
|
||||
"printWidth": 140
|
||||
"printWidth": 120
|
||||
}
|
||||
@@ -23,8 +23,14 @@ function isPaginatedResult<T>(value: unknown): value is PaginatedResult<T> {
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class TransformResponseInterceptor<T> implements NestInterceptor<T, T | ResponseEnvelope<T> | PaginatedResponseEnvelope<T>> {
|
||||
intercept(context: ExecutionContext, next: CallHandler<T>): Observable<T | ResponseEnvelope<T> | PaginatedResponseEnvelope<T>> {
|
||||
export class TransformResponseInterceptor<T> implements NestInterceptor<
|
||||
T,
|
||||
T | ResponseEnvelope<T> | PaginatedResponseEnvelope<T>
|
||||
> {
|
||||
intercept(
|
||||
context: ExecutionContext,
|
||||
next: CallHandler<T>,
|
||||
): Observable<T | ResponseEnvelope<T> | PaginatedResponseEnvelope<T>> {
|
||||
if (context.getType() !== 'http') {
|
||||
return next.handle();
|
||||
}
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { COMMENT_REPOSITORY, type ICommentRepository } from '../../domain/repositories/comment.repository.interface.js';
|
||||
import { ISSUE_REPOSITORY, type IIssueRepository } from '../../../issue/domain/repositories/issue.repository.interface.js';
|
||||
import {
|
||||
ISSUE_REPOSITORY,
|
||||
type IIssueRepository,
|
||||
} from '../../../issue/domain/repositories/issue.repository.interface.js';
|
||||
import { Comment } from '../../domain/entities/comment.entity.js';
|
||||
import { CommentNotFoundException } from '../../domain/exceptions/comment-not-found.exception.js';
|
||||
|
||||
|
||||
+3
-1
@@ -49,7 +49,9 @@ describe('CreateCommentUseCase', () => {
|
||||
it('throws IssueNotFoundException when the issue is not visible to the user', async () => {
|
||||
vi.mocked(issueRepository.findById).mockResolvedValue(null);
|
||||
|
||||
await expect(useCase.execute(new CreateCommentCommand(1, 'user-2', 'Hello'))).rejects.toThrow(IssueNotFoundException);
|
||||
await expect(useCase.execute(new CreateCommentCommand(1, 'user-2', 'Hello'))).rejects.toThrow(
|
||||
IssueNotFoundException,
|
||||
);
|
||||
expect(commentRepository.create).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,12 @@
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { COMMENT_REPOSITORY, type ICommentRepository } from '../../../domain/repositories/comment.repository.interface.js';
|
||||
import { ISSUE_REPOSITORY, type IIssueRepository } from '../../../../issue/domain/repositories/issue.repository.interface.js';
|
||||
import {
|
||||
COMMENT_REPOSITORY,
|
||||
type ICommentRepository,
|
||||
} from '../../../domain/repositories/comment.repository.interface.js';
|
||||
import {
|
||||
ISSUE_REPOSITORY,
|
||||
type IIssueRepository,
|
||||
} from '../../../../issue/domain/repositories/issue.repository.interface.js';
|
||||
import { IssueNotFoundException } from '../../../../issue/domain/exceptions/issue-not-found.exception.js';
|
||||
import { Comment } from '../../../domain/entities/comment.entity.js';
|
||||
import { CreateCommentCommand } from './create-comment.command.js';
|
||||
|
||||
+13
-3
@@ -8,7 +8,13 @@ import { CommentEventsService, type CommentEvent } from '../../services/comment-
|
||||
import { CommentAccessService } from '../../services/comment-access.service.js';
|
||||
import { DeleteCommentUseCase } from './delete-comment.use-case.js';
|
||||
import { DeleteCommentCommand } from './delete-comment.command.js';
|
||||
import { buildComment, buildIssue, mockAuthorExpander, mockCommentRepository, mockIssueRepository } from '../test-helpers.js';
|
||||
import {
|
||||
buildComment,
|
||||
buildIssue,
|
||||
mockAuthorExpander,
|
||||
mockCommentRepository,
|
||||
mockIssueRepository,
|
||||
} from '../test-helpers.js';
|
||||
|
||||
describe('DeleteCommentUseCase', () => {
|
||||
let commentRepository: ICommentRepository;
|
||||
@@ -46,13 +52,17 @@ describe('DeleteCommentUseCase', () => {
|
||||
it('throws CommentForbiddenException for a non-author', async () => {
|
||||
vi.mocked(commentRepository.findById).mockResolvedValue(buildComment());
|
||||
|
||||
await expect(useCase.execute(new DeleteCommentCommand('comment-1', 'user-2'))).rejects.toThrow(CommentForbiddenException);
|
||||
await expect(useCase.execute(new DeleteCommentCommand('comment-1', 'user-2'))).rejects.toThrow(
|
||||
CommentForbiddenException,
|
||||
);
|
||||
expect(commentRepository.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('throws CommentNotFoundException when the comment does not exist', async () => {
|
||||
vi.mocked(commentRepository.findById).mockResolvedValue(null);
|
||||
|
||||
await expect(useCase.execute(new DeleteCommentCommand('missing', 'user-1'))).rejects.toThrow(CommentNotFoundException);
|
||||
await expect(useCase.execute(new DeleteCommentCommand('missing', 'user-1'))).rejects.toThrow(
|
||||
CommentNotFoundException,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,8 @@
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { COMMENT_REPOSITORY, type ICommentRepository } from '../../../domain/repositories/comment.repository.interface.js';
|
||||
import {
|
||||
COMMENT_REPOSITORY,
|
||||
type ICommentRepository,
|
||||
} from '../../../domain/repositories/comment.repository.interface.js';
|
||||
import { DeleteCommentCommand } from './delete-comment.command.js';
|
||||
import { CommentAuthorExpanderService } from '../../services/comment-author-expander.service.js';
|
||||
import { CommentEventsService } from '../../services/comment-events.service.js';
|
||||
|
||||
+7
-1
@@ -4,7 +4,13 @@ import type { IIssueRepository } from '../../../../issue/domain/repositories/iss
|
||||
import { IssueNotFoundException } from '../../../../issue/domain/exceptions/issue-not-found.exception.js';
|
||||
import { ListCommentsUseCase } from './list-comments.use-case.js';
|
||||
import { ListCommentsQuery } from './list-comments.query.js';
|
||||
import { buildComment, buildIssue, mockAuthorExpander, mockCommentRepository, mockIssueRepository } from '../test-helpers.js';
|
||||
import {
|
||||
buildComment,
|
||||
buildIssue,
|
||||
mockAuthorExpander,
|
||||
mockCommentRepository,
|
||||
mockIssueRepository,
|
||||
} from '../test-helpers.js';
|
||||
|
||||
describe('ListCommentsUseCase', () => {
|
||||
let commentRepository: ICommentRepository;
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { COMMENT_REPOSITORY, type ICommentRepository } from '../../../domain/repositories/comment.repository.interface.js';
|
||||
import { ISSUE_REPOSITORY, type IIssueRepository } from '../../../../issue/domain/repositories/issue.repository.interface.js';
|
||||
import {
|
||||
COMMENT_REPOSITORY,
|
||||
type ICommentRepository,
|
||||
} from '../../../domain/repositories/comment.repository.interface.js';
|
||||
import {
|
||||
ISSUE_REPOSITORY,
|
||||
type IIssueRepository,
|
||||
} from '../../../../issue/domain/repositories/issue.repository.interface.js';
|
||||
import { IssueNotFoundException } from '../../../../issue/domain/exceptions/issue-not-found.exception.js';
|
||||
import { ListCommentsQuery } from './list-comments.query.js';
|
||||
import { CommentDto } from '../../dto/comment.dto.js';
|
||||
|
||||
@@ -6,7 +6,9 @@ import { Issue } from '../../../issue/domain/entities/issue.entity.js';
|
||||
import { CommentAuthorExpanderService } from '../services/comment-author-expander.service.js';
|
||||
import { CommentApplicationMapper } from '../mappers/comment-application.mapper.js';
|
||||
|
||||
export const buildComment = (overrides: Partial<{ id: string; issueId: number; authorId: string; deletedAt: Date | null }> = {}) =>
|
||||
export const buildComment = (
|
||||
overrides: Partial<{ id: string; issueId: number; authorId: string; deletedAt: Date | null }> = {},
|
||||
) =>
|
||||
Comment.reconstitute({
|
||||
id: overrides.id ?? 'comment-1',
|
||||
content: 'Original',
|
||||
|
||||
+16
-4
@@ -7,7 +7,13 @@ import { CommentEventsService } from '../../services/comment-events.service.js';
|
||||
import { CommentAccessService } from '../../services/comment-access.service.js';
|
||||
import { UpdateCommentUseCase } from './update-comment.use-case.js';
|
||||
import { UpdateCommentCommand } from './update-comment.command.js';
|
||||
import { buildComment, buildIssue, mockAuthorExpander, mockCommentRepository, mockIssueRepository } from '../test-helpers.js';
|
||||
import {
|
||||
buildComment,
|
||||
buildIssue,
|
||||
mockAuthorExpander,
|
||||
mockCommentRepository,
|
||||
mockIssueRepository,
|
||||
} from '../test-helpers.js';
|
||||
|
||||
describe('UpdateCommentUseCase', () => {
|
||||
let commentRepository: ICommentRepository;
|
||||
@@ -54,20 +60,26 @@ describe('UpdateCommentUseCase', () => {
|
||||
it('throws CommentForbiddenException for a non-author', async () => {
|
||||
vi.mocked(commentRepository.findById).mockResolvedValue(buildComment());
|
||||
|
||||
await expect(useCase.execute(new UpdateCommentCommand('comment-1', 'user-2', 'Changed'))).rejects.toThrow(CommentForbiddenException);
|
||||
await expect(useCase.execute(new UpdateCommentCommand('comment-1', 'user-2', 'Changed'))).rejects.toThrow(
|
||||
CommentForbiddenException,
|
||||
);
|
||||
expect(commentRepository.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('throws CommentNotFoundException for a deleted comment', async () => {
|
||||
vi.mocked(commentRepository.findById).mockResolvedValue(buildComment({ deletedAt: new Date() }));
|
||||
|
||||
await expect(useCase.execute(new UpdateCommentCommand('comment-1', 'user-1', 'Changed'))).rejects.toThrow(CommentNotFoundException);
|
||||
await expect(useCase.execute(new UpdateCommentCommand('comment-1', 'user-1', 'Changed'))).rejects.toThrow(
|
||||
CommentNotFoundException,
|
||||
);
|
||||
});
|
||||
|
||||
it('throws CommentNotFoundException when the issue is not visible to the user', async () => {
|
||||
vi.mocked(commentRepository.findById).mockResolvedValue(buildComment());
|
||||
vi.mocked(issueRepository.findById).mockResolvedValue(null);
|
||||
|
||||
await expect(useCase.execute(new UpdateCommentCommand('comment-1', 'user-1', 'Changed'))).rejects.toThrow(CommentNotFoundException);
|
||||
await expect(useCase.execute(new UpdateCommentCommand('comment-1', 'user-1', 'Changed'))).rejects.toThrow(
|
||||
CommentNotFoundException,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,8 @@
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { COMMENT_REPOSITORY, type ICommentRepository } from '../../../domain/repositories/comment.repository.interface.js';
|
||||
import {
|
||||
COMMENT_REPOSITORY,
|
||||
type ICommentRepository,
|
||||
} from '../../../domain/repositories/comment.repository.interface.js';
|
||||
import { UpdateCommentCommand } from './update-comment.command.js';
|
||||
import { CommentDto } from '../../dto/comment.dto.js';
|
||||
import { CommentAuthorExpanderService } from '../../services/comment-author-expander.service.js';
|
||||
|
||||
+2
-1
@@ -7,7 +7,8 @@ import { WatchIssueCommentsUseCase } from './watch-issue-comments.use-case.js';
|
||||
import { WatchIssueCommentsQuery } from './watch-issue-comments.query.js';
|
||||
import { buildComment, buildIssue, mockIssueRepository } from '../test-helpers.js';
|
||||
|
||||
const dtoForIssue = (issueId: number) => CommentApplicationMapper.toDto(buildComment({ id: `comment-of-${issueId}`, issueId }), new Map());
|
||||
const dtoForIssue = (issueId: number) =>
|
||||
CommentApplicationMapper.toDto(buildComment({ id: `comment-of-${issueId}`, issueId }), new Map());
|
||||
|
||||
describe('WatchIssueCommentsUseCase', () => {
|
||||
let issueRepository: IIssueRepository;
|
||||
|
||||
+4
-1
@@ -1,6 +1,9 @@
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { filter, type Observable } from 'rxjs';
|
||||
import { ISSUE_REPOSITORY, type IIssueRepository } from '../../../../issue/domain/repositories/issue.repository.interface.js';
|
||||
import {
|
||||
ISSUE_REPOSITORY,
|
||||
type IIssueRepository,
|
||||
} from '../../../../issue/domain/repositories/issue.repository.interface.js';
|
||||
import { IssueNotFoundException } from '../../../../issue/domain/exceptions/issue-not-found.exception.js';
|
||||
import { WatchIssueCommentsQuery } from './watch-issue-comments.query.js';
|
||||
import { type CommentEvent, CommentEventsService } from '../../services/comment-events.service.js';
|
||||
|
||||
@@ -10,7 +10,11 @@ export interface ICommentRepository {
|
||||
* Комментарии задачи от старых к новым, включая удалённые.
|
||||
* Курсорная пагинация: cursor — id последнего полученного комментария.
|
||||
*/
|
||||
findByIssue(params: { issueId: number; limit: number; cursor?: string }): Promise<{ list: Comment[]; nextCursor: string | null }>;
|
||||
findByIssue(params: {
|
||||
issueId: number;
|
||||
limit: number;
|
||||
cursor?: string;
|
||||
}): Promise<{ list: Comment[]; nextCursor: string | null }>;
|
||||
create(comment: Comment): Promise<Comment>;
|
||||
update(comment: Comment): Promise<void>;
|
||||
}
|
||||
+5
-1
@@ -17,7 +17,11 @@ export class CommentPrismaRepository implements ICommentRepository {
|
||||
return record ? CommentPrismaMapper.toDomain(record) : null;
|
||||
}
|
||||
|
||||
async findByIssue(params: { issueId: number; limit: number; cursor?: string }): Promise<{ list: Comment[]; nextCursor: string | null }> {
|
||||
async findByIssue(params: {
|
||||
issueId: number;
|
||||
limit: number;
|
||||
cursor?: string;
|
||||
}): Promise<{ list: Comment[]; nextCursor: string | null }> {
|
||||
const limit = Math.max(1, params.limit);
|
||||
|
||||
// Берём на одну запись больше, чтобы понять, есть ли следующая страница.
|
||||
|
||||
@@ -47,15 +47,27 @@ export class CommentController {
|
||||
) {}
|
||||
|
||||
@Get('issues/:issueId/comments')
|
||||
list(@Param('issueId', ParseIntPipe) issueId: number, @Query() query: ListCommentsDto, @Session() session: UserSession) {
|
||||
list(
|
||||
@Param('issueId', ParseIntPipe) issueId: number,
|
||||
@Query() query: ListCommentsDto,
|
||||
@Session() session: UserSession,
|
||||
) {
|
||||
return this.handleErrors(() =>
|
||||
this.listCommentsUseCase.execute(new ListCommentsQuery(issueId, session.user.id, Number(query.limit), query.cursor)),
|
||||
this.listCommentsUseCase.execute(
|
||||
new ListCommentsQuery(issueId, session.user.id, Number(query.limit), query.cursor),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@Post('issues/:issueId/comments')
|
||||
create(@Param('issueId', ParseIntPipe) issueId: number, @Body() dto: CreateCommentRequestDto, @Session() session: UserSession) {
|
||||
return this.handleErrors(() => this.createCommentUseCase.execute(new CreateCommentCommand(issueId, session.user.id, dto.content)));
|
||||
create(
|
||||
@Param('issueId', ParseIntPipe) issueId: number,
|
||||
@Body() dto: CreateCommentRequestDto,
|
||||
@Session() session: UserSession,
|
||||
) {
|
||||
return this.handleErrors(() =>
|
||||
this.createCommentUseCase.execute(new CreateCommentCommand(issueId, session.user.id, dto.content)),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -66,16 +78,23 @@ export class CommentController {
|
||||
*/
|
||||
@Sse('issues/:issueId/comments/events')
|
||||
@UseGuards(IssueVisibleGuard)
|
||||
events(@Param('issueId', ParseIntPipe) issueId: number, @Session() session: UserSession): Promise<Observable<MessageEvent>> {
|
||||
events(
|
||||
@Param('issueId', ParseIntPipe) issueId: number,
|
||||
@Session() session: UserSession,
|
||||
): Promise<Observable<MessageEvent>> {
|
||||
return this.handleErrors(async () => {
|
||||
const events$ = await this.watchIssueCommentsUseCase.execute(new WatchIssueCommentsQuery(issueId, session.user.id));
|
||||
const events$ = await this.watchIssueCommentsUseCase.execute(
|
||||
new WatchIssueCommentsQuery(issueId, session.user.id),
|
||||
);
|
||||
return events$.pipe(map((event) => ({ type: event.type, data: event.comment })));
|
||||
});
|
||||
}
|
||||
|
||||
@Patch('comments/:id')
|
||||
update(@Param('id') id: string, @Body() dto: UpdateCommentRequestDto, @Session() session: UserSession) {
|
||||
return this.handleErrors(() => this.updateCommentUseCase.execute(new UpdateCommentCommand(id, session.user.id, dto.content)));
|
||||
return this.handleErrors(() =>
|
||||
this.updateCommentUseCase.execute(new UpdateCommentCommand(id, session.user.id, dto.content)),
|
||||
);
|
||||
}
|
||||
|
||||
@Delete('comments/:id')
|
||||
|
||||
@@ -24,7 +24,9 @@ export class IssueVisibleGuard implements CanActivate {
|
||||
constructor(private readonly commentAccess: CommentAccessService) {}
|
||||
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
const request = context.switchToHttp().getRequest<{ params: Record<string, string | undefined>; session?: UserSession }>();
|
||||
const request = context
|
||||
.switchToHttp()
|
||||
.getRequest<{ params: Record<string, string | undefined>; session?: UserSession }>();
|
||||
|
||||
const userId = request.session?.user.id;
|
||||
if (!userId) {
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
import { Inject, Injectable, Logger } from '@nestjs/common';
|
||||
import { OnEvent } from '@nestjs/event-emitter';
|
||||
import { NOTIFY_EVENT, type NotifyEventPayload } from '../../domain/events/notify.event.js';
|
||||
import {
|
||||
NOTIFICATION_CHANNEL_SENDERS,
|
||||
type NotificationChannelSender,
|
||||
} from '../../domain/channel-sender.interface.js';
|
||||
import { NOTIFICATION_CHANNEL_SENDERS, type NotificationChannelSender } from '../../domain/channel-sender.interface.js';
|
||||
import {
|
||||
NOTIFICATION_PREFERENCE_REPOSITORY,
|
||||
type INotificationPreferenceRepository,
|
||||
@@ -39,7 +36,9 @@ export class NotificationDispatcherService {
|
||||
/** Каналы из payload, отфильтрованные по подпискам; без явных подписок — все каналы включены по умолчанию */
|
||||
private async resolveChannels(payload: NotifyEventPayload): Promise<NotificationChannelValue[]> {
|
||||
const userPreferences = await this.preferences.findByUserId(payload.userId);
|
||||
const disabledChannels = new Set(userPreferences.filter((preference) => !preference.enabled).map((preference) => preference.channel));
|
||||
const disabledChannels = new Set(
|
||||
userPreferences.filter((preference) => !preference.enabled).map((preference) => preference.channel),
|
||||
);
|
||||
|
||||
const candidateChannels = payload.channels ?? this.senders.map((sender) => sender.channel);
|
||||
return candidateChannels.filter((channel) => !disabledChannels.has(channel));
|
||||
|
||||
@@ -9,7 +9,10 @@ import {
|
||||
NOTIFICATION_PREFERENCE_REPOSITORY,
|
||||
type INotificationPreferenceRepository,
|
||||
} from '../../domain/repositories/notification-preference.repository.interface.js';
|
||||
import { NOTIFICATION_CHANNELS, type NotificationChannelValue } from '../../domain/value-objects/notification-channel.vo.js';
|
||||
import {
|
||||
NOTIFICATION_CHANNELS,
|
||||
type NotificationChannelValue,
|
||||
} from '../../domain/value-objects/notification-channel.vo.js';
|
||||
|
||||
export interface ResolvedNotificationPreference {
|
||||
channel: NotificationChannelValue;
|
||||
|
||||
@@ -1,4 +1,15 @@
|
||||
import { BadRequestException, Body, Controller, Delete, Get, HttpCode, HttpStatus, Param, Put, Post } from '@nestjs/common';
|
||||
import {
|
||||
BadRequestException,
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
Param,
|
||||
Put,
|
||||
Post,
|
||||
} from '@nestjs/common';
|
||||
import { Session, type UserSession } from '@thallesp/nestjs-better-auth';
|
||||
import { NotificationsService } from '../../application/services/notifications.service.js';
|
||||
import { RegisterDeviceTokenDto } from '../dto/register-device-token.dto.js';
|
||||
|
||||
@@ -7,11 +7,7 @@ export const PROJECT_REPOSITORY = Symbol('PROJECT_REPOSITORY');
|
||||
export interface IProjectRepository {
|
||||
/** Проект виден владельцу и участникам проекта (ProjectMember) */
|
||||
findById(id: string, userId: string): Promise<Project | null>;
|
||||
findAll(params: {
|
||||
limit: number;
|
||||
page: number;
|
||||
userId: string;
|
||||
}): Promise<{ list: Project[]; meta: PaginationMeta }>;
|
||||
findAll(params: { limit: number; page: number; userId: string }): Promise<{ list: Project[]; meta: PaginationMeta }>;
|
||||
create(project: Project): Promise<Project>;
|
||||
update(project: Project): Promise<void>;
|
||||
delete(id: string): Promise<void>;
|
||||
|
||||
Reference in new issue
Block a user