mirror of
https://github.com/docmost/docmost.git
synced 2025-11-13 15:32:36 +10:00
feat: comments
* create comment * reply to comment thread * edit comment * delete comment * resolve comment
This commit is contained in:
75
server/src/core/comment/comment.controller.ts
Normal file
75
server/src/core/comment/comment.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
22
server/src/core/comment/comment.module.ts
Normal file
22
server/src/core/comment/comment.module.ts
Normal 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 {}
|
||||
18
server/src/core/comment/comment.service.spec.ts
Normal file
18
server/src/core/comment/comment.service.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
122
server/src/core/comment/comment.service.ts
Normal file
122
server/src/core/comment/comment.service.ts
Normal 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.`);
|
||||
}
|
||||
}
|
||||
}
|
||||
11
server/src/core/comment/dto/comments.input.ts
Normal file
11
server/src/core/comment/dto/comments.input.ts
Normal file
@ -0,0 +1,11 @@
|
||||
import { IsUUID } from 'class-validator';
|
||||
|
||||
export class CommentsInput {
|
||||
@IsUUID()
|
||||
pageId: string;
|
||||
}
|
||||
|
||||
export class SingleCommentInput {
|
||||
@IsUUID()
|
||||
id: string;
|
||||
}
|
||||
21
server/src/core/comment/dto/create-comment.dto.ts
Normal file
21
server/src/core/comment/dto/create-comment.dto.ts
Normal 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;
|
||||
}
|
||||
9
server/src/core/comment/dto/resolve-comment.dto.ts
Normal file
9
server/src/core/comment/dto/resolve-comment.dto.ts
Normal file
@ -0,0 +1,9 @@
|
||||
import { IsBoolean, IsUUID } from 'class-validator';
|
||||
|
||||
export class ResolveCommentDto {
|
||||
@IsUUID()
|
||||
commentId: string;
|
||||
|
||||
@IsBoolean()
|
||||
resolved: boolean;
|
||||
}
|
||||
9
server/src/core/comment/dto/update-comment.dto.ts
Normal file
9
server/src/core/comment/dto/update-comment.dto.ts
Normal file
@ -0,0 +1,9 @@
|
||||
import { IsJSON, IsUUID } from 'class-validator';
|
||||
|
||||
export class UpdateCommentDto {
|
||||
@IsUUID()
|
||||
id: string;
|
||||
|
||||
@IsJSON()
|
||||
content: any;
|
||||
}
|
||||
82
server/src/core/comment/entities/comment.entity.ts
Normal file
82
server/src/core/comment/entities/comment.entity.ts
Normal 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;
|
||||
}
|
||||
14
server/src/core/comment/repositories/comment.repository.ts
Normal file
14
server/src/core/comment/repositories/comment.repository.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
@ -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 {}
|
||||
|
||||
@ -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[];
|
||||
}
|
||||
|
||||
@ -33,7 +33,6 @@ export class PageRepository extends Repository<Page> {
|
||||
'page.createdAt',
|
||||
'page.updatedAt',
|
||||
'page.deletedAt',
|
||||
'page.children',
|
||||
])
|
||||
.getOne();
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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[];
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user