feat: comments

* create comment
* reply to comment thread
* edit comment
* delete comment
* resolve comment
This commit is contained in:
Philipinho
2023-11-09 16:52:34 +00:00
parent dea2cad89c
commit 4cb7a56f65
49 changed files with 1486 additions and 87 deletions

View File

@ -0,0 +1,75 @@
import {
Controller,
Post,
Body,
HttpCode,
HttpStatus,
Req,
UseGuards,
} from '@nestjs/common';
import { CommentService } from './comment.service';
import { CreateCommentDto } from './dto/create-comment.dto';
import { UpdateCommentDto } from './dto/update-comment.dto';
import { FastifyRequest } from 'fastify';
import { JwtGuard } from '../auth/guards/JwtGuard';
import { CommentsInput, SingleCommentInput } from './dto/comments.input';
import { ResolveCommentDto } from './dto/resolve-comment.dto';
import { WorkspaceService } from '../workspace/services/workspace.service';
@UseGuards(JwtGuard)
@Controller('comments')
export class CommentController {
constructor(
private readonly commentService: CommentService,
private readonly workspaceService: WorkspaceService,
) {}
@HttpCode(HttpStatus.CREATED)
@Post('create')
async create(
@Req() req: FastifyRequest,
@Body() createCommentDto: CreateCommentDto,
) {
const jwtPayload = req['user'];
const userId = jwtPayload.sub;
const workspaceId = (
await this.workspaceService.getUserCurrentWorkspace(jwtPayload.sub)
).id;
return this.commentService.create(userId, workspaceId, createCommentDto);
}
@HttpCode(HttpStatus.OK)
@Post()
findPageComments(@Body() input: CommentsInput) {
return this.commentService.findByPageId(input.pageId);
}
@HttpCode(HttpStatus.OK)
@Post('view')
findOne(@Body() input: SingleCommentInput) {
return this.commentService.findWithCreator(input.id);
}
@HttpCode(HttpStatus.OK)
@Post('update')
update(@Body() updateCommentDto: UpdateCommentDto) {
return this.commentService.update(updateCommentDto.id, updateCommentDto);
}
@HttpCode(HttpStatus.OK)
@Post('resolve')
resolve(
@Req() req: FastifyRequest,
@Body() resolveCommentDto: ResolveCommentDto,
) {
const userId = req['user'].sub;
return this.commentService.resolveComment(userId, resolveCommentDto);
}
@HttpCode(HttpStatus.OK)
@Post('delete')
remove(@Body() input: SingleCommentInput) {
return this.commentService.remove(input.id);
}
}

View File

@ -0,0 +1,22 @@
import { Module } from '@nestjs/common';
import { CommentService } from './comment.service';
import { CommentController } from './comment.controller';
import { CommentRepository } from './repositories/comment.repository';
import { TypeOrmModule } from '@nestjs/typeorm';
import { AuthModule } from '../auth/auth.module';
import { WorkspaceModule } from '../workspace/workspace.module';
import { Comment } from './entities/comment.entity';
import { PageModule } from '../page/page.module';
@Module({
imports: [
TypeOrmModule.forFeature([Comment]),
AuthModule,
WorkspaceModule,
PageModule,
],
controllers: [CommentController],
providers: [CommentService, CommentRepository],
exports: [CommentService, CommentRepository],
})
export class CommentModule {}

View File

@ -0,0 +1,18 @@
import { Test, TestingModule } from '@nestjs/testing';
import { CommentService } from './comment.service';
describe('CommentService', () => {
let service: CommentService;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [CommentService],
}).compile();
service = module.get<CommentService>(CommentService);
});
it('should be defined', () => {
expect(service).toBeDefined();
});
});

View File

@ -0,0 +1,122 @@
import { BadRequestException, Injectable } from '@nestjs/common';
import { CreateCommentDto } from './dto/create-comment.dto';
import { UpdateCommentDto } from './dto/update-comment.dto';
import { plainToInstance } from 'class-transformer';
import { Comment } from './entities/comment.entity';
import { CommentRepository } from './repositories/comment.repository';
import { ResolveCommentDto } from './dto/resolve-comment.dto';
import { PageService } from '../page/services/page.service';
@Injectable()
export class CommentService {
constructor(
private commentRepository: CommentRepository,
private pageService: PageService,
) {}
async findWithCreator(commentId: string) {
return await this.commentRepository.findOne({
where: { id: commentId },
relations: ['creator'],
});
}
async create(
userId: string,
workspaceId: string,
createCommentDto: CreateCommentDto,
) {
const comment = plainToInstance(Comment, createCommentDto);
comment.creatorId = userId;
comment.workspaceId = workspaceId;
comment.content = JSON.parse(createCommentDto.content);
if (createCommentDto.selection) {
comment.selection = createCommentDto.selection.substring(0, 250);
}
const page = await this.pageService.findWithBasic(createCommentDto.pageId);
if (!page) {
throw new BadRequestException('Page not found');
}
if (createCommentDto.parentCommentId) {
const parentComment = await this.commentRepository.findOne({
where: { id: createCommentDto.parentCommentId },
select: ['id', 'parentCommentId'],
});
if (!parentComment) {
throw new BadRequestException('Parent comment not found');
}
if (parentComment.parentCommentId !== null) {
throw new BadRequestException('You cannot reply to a reply');
}
}
const savedComment = await this.commentRepository.save(comment);
return this.findWithCreator(savedComment.id);
}
async findByPageId(pageId: string, offset = 0, limit = 100) {
const comments = this.commentRepository.find({
where: {
pageId: pageId,
},
order: {
createdAt: 'asc',
},
take: limit,
skip: offset,
relations: ['creator'],
});
return comments;
}
async update(
commentId: string,
updateCommentDto: UpdateCommentDto,
): Promise<Comment> {
updateCommentDto.content = JSON.parse(updateCommentDto.content);
const result = await this.commentRepository.update(commentId, {
...updateCommentDto,
editedAt: new Date(),
});
if (result.affected === 0) {
throw new BadRequestException(`Comment not found`);
}
return this.findWithCreator(commentId);
}
async resolveComment(
userId: string,
resolveCommentDto: ResolveCommentDto,
): Promise<Comment> {
const resolvedAt = resolveCommentDto.resolved ? new Date() : null;
const resolvedById = resolveCommentDto.resolved ? userId : null;
const result = await this.commentRepository.update(
resolveCommentDto.commentId,
{
resolvedAt,
resolvedById,
},
);
if (result.affected === 0) {
throw new BadRequestException(`Comment not found`);
}
return this.findWithCreator(resolveCommentDto.commentId);
}
async remove(id: string): Promise<void> {
const result = await this.commentRepository.delete(id);
if (result.affected === 0) {
throw new BadRequestException(`Comment with ID ${id} not found.`);
}
}
}

View File

@ -0,0 +1,11 @@
import { IsUUID } from 'class-validator';
export class CommentsInput {
@IsUUID()
pageId: string;
}
export class SingleCommentInput {
@IsUUID()
id: string;
}

View File

@ -0,0 +1,21 @@
import { IsJSON, IsOptional, IsString, IsUUID } from 'class-validator';
export class CreateCommentDto {
@IsOptional()
@IsUUID()
id?: string;
@IsUUID()
pageId: string;
@IsJSON()
content: any;
@IsOptional()
@IsString()
selection: string;
@IsOptional()
@IsUUID()
parentCommentId: string;
}

View File

@ -0,0 +1,9 @@
import { IsBoolean, IsUUID } from 'class-validator';
export class ResolveCommentDto {
@IsUUID()
commentId: string;
@IsBoolean()
resolved: boolean;
}

View File

@ -0,0 +1,9 @@
import { IsJSON, IsUUID } from 'class-validator';
export class UpdateCommentDto {
@IsUUID()
id: string;
@IsJSON()
content: any;
}

View File

@ -0,0 +1,82 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
ManyToOne,
JoinColumn,
CreateDateColumn,
OneToMany,
DeleteDateColumn,
} from 'typeorm';
import { User } from '../../user/entities/user.entity';
import { Page } from '../../page/entities/page.entity';
import { Workspace } from '../../workspace/entities/workspace.entity';
@Entity('comments')
export class Comment {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ type: 'jsonb', nullable: true })
content: any;
@Column({ type: 'varchar', length: 255, nullable: true })
selection: string;
@Column({ type: 'varchar', length: 55, nullable: true })
type: string;
@Column()
creatorId: string;
@ManyToOne(() => User, (user) => user.comments)
@JoinColumn({ name: 'creatorId' })
creator: User;
@Column()
pageId: string;
@ManyToOne(() => Page, (page) => page.comments, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'pageId' })
page: Page;
@Column({ type: 'uuid', nullable: true })
parentCommentId: string;
@ManyToOne(() => Comment, (comment) => comment.replies, {
onDelete: 'CASCADE',
})
@JoinColumn({ name: 'parentCommentId' })
parentComment: Comment;
@OneToMany(() => Comment, (comment) => comment.parentComment)
replies: Comment[];
@Column({ nullable: true })
resolvedById: string;
@ManyToOne(() => User)
@JoinColumn({ name: 'resolvedById' })
resolvedBy: User;
@Column({ type: 'timestamp', nullable: true })
resolvedAt: Date;
@Column()
workspaceId: string;
@ManyToOne(() => Workspace, (workspace) => workspace.comments, {
onDelete: 'CASCADE',
})
@JoinColumn({ name: 'workspaceId' })
workspace: Workspace;
@CreateDateColumn()
createdAt: Date;
@Column({ type: 'timestamp', nullable: true })
editedAt: Date;
@DeleteDateColumn({ nullable: true })
deletedAt: Date;
}

View File

@ -0,0 +1,14 @@
import { DataSource, Repository } from 'typeorm';
import { Injectable } from '@nestjs/common';
import { Comment } from '../entities/comment.entity';
@Injectable()
export class CommentRepository extends Repository<Comment> {
constructor(private dataSource: DataSource) {
super(Comment, dataSource.createEntityManager());
}
async findById(commentId: string) {
return this.findOneBy({ id: commentId });
}
}

View File

@ -6,6 +6,7 @@ import { PageModule } from './page/page.module';
import { StorageModule } from './storage/storage.module';
import { AttachmentModule } from './attachment/attachment.module';
import { EnvironmentModule } from '../environment/environment.module';
import { CommentModule } from './comment/comment.module';
@Module({
imports: [
@ -17,6 +18,7 @@ import { EnvironmentModule } from '../environment/environment.module';
imports: [EnvironmentModule],
}),
AttachmentModule,
CommentModule,
],
})
export class CoreModule {}

View File

@ -11,6 +11,7 @@ import {
} from 'typeorm';
import { User } from '../../user/entities/user.entity';
import { Workspace } from '../../workspace/entities/workspace.entity';
import { Comment } from '../../comment/entities/comment.entity';
@Entity('pages')
export class Page {
@ -68,7 +69,7 @@ export class Page {
status: string;
@Column({ type: 'date', nullable: true })
publishedAt: string;
publishedAt: Date;
@CreateDateColumn()
createdAt: Date;
@ -85,4 +86,7 @@ export class Page {
@OneToMany(() => Page, (page) => page.parentPage, { onDelete: 'CASCADE' })
childPages: Page[];
@OneToMany(() => Comment, (comment) => comment.page)
comments: Comment[];
}

View File

@ -33,7 +33,6 @@ export class PageRepository extends Repository<Page> {
'page.createdAt',
'page.updatedAt',
'page.deletedAt',
'page.children',
])
.getOne();
}

View File

@ -24,6 +24,13 @@ export class PageService {
private pageOrderingService: PageOrderingService,
) {}
async findWithBasic(pageId: string) {
return this.pageRepository.findOne({
where: { id: pageId },
select: ['id', 'title'],
});
}
async findById(pageId: string) {
return this.pageRepository.findById(pageId);
}

View File

@ -3,8 +3,6 @@ import {
Column,
CreateDateColumn,
Entity,
JoinTable,
ManyToMany,
OneToMany,
PrimaryGeneratedColumn,
UpdateDateColumn,
@ -13,6 +11,7 @@ import * as bcrypt from 'bcrypt';
import { Workspace } from '../../workspace/entities/workspace.entity';
import { WorkspaceUser } from '../../workspace/entities/workspace-user.entity';
import { Page } from '../../page/entities/page.entity';
import { Comment } from '../../comment/entities/comment.entity';
@Entity('users')
export class User {
@ -64,6 +63,9 @@ export class User {
@OneToMany(() => Page, (page) => page.creator)
createdPages: Page[];
@OneToMany(() => Comment, (comment) => comment.creator)
comments: Comment[];
toJSON() {
delete this.password;
return this;

View File

@ -12,6 +12,7 @@ import { User } from '../../user/entities/user.entity';
import { WorkspaceUser } from './workspace-user.entity';
import { Page } from '../../page/entities/page.entity';
import { WorkspaceInvitation } from './workspace-invitation.entity';
import { Comment } from '../../comment/entities/comment.entity';
@Entity('workspaces')
export class Workspace {
@ -66,4 +67,7 @@ export class Workspace {
@OneToMany(() => Page, (page) => page.workspace)
pages: Page[];
@OneToMany(() => Comment, (comment) => comment.workspace)
comments: Comment[];
}