diff --git a/client/src/lib/time-ago.ts b/client/src/lib/time-ago.ts
new file mode 100644
index 00000000..152e35c6
--- /dev/null
+++ b/client/src/lib/time-ago.ts
@@ -0,0 +1,5 @@
+import { formatDistanceStrict } from 'date-fns';
+
+export function timeAgo(date: Date){
+ return formatDistanceStrict(new Date(date), new Date(), { addSuffix: true })
+}
diff --git a/client/src/main.tsx b/client/src/main.tsx
index fb623536..b774b4aa 100644
--- a/client/src/main.tsx
+++ b/client/src/main.tsx
@@ -9,16 +9,19 @@ import { MantineProvider } from '@mantine/core';
import { TanstackProvider } from '@/components/providers/tanstack-provider';
import CustomToaster from '@/components/ui/custom-toaster';
import { BrowserRouter } from 'react-router-dom';
+import { ModalsProvider } from '@mantine/modals';
const root = ReactDOM.createRoot(document.getElementById('root') as HTMLElement);
root.render(
+
+
,
);
diff --git a/client/src/pages/page/page.tsx b/client/src/pages/page/page.tsx
index 9ab0d895..ca21417d 100644
--- a/client/src/pages/page/page.tsx
+++ b/client/src/pages/page/page.tsx
@@ -1,7 +1,7 @@
import { useParams } from 'react-router-dom';
import React, { useEffect } from 'react';
import { useAtom } from 'jotai/index';
-import usePage from '@/features/page/hooks/usePage';
+import usePage from '@/features/page/hooks/use-page';
import Editor from '@/features/editor/editor';
import { pageAtom } from '@/features/page/atoms/page-atom';
diff --git a/server/src/core/comment/comment.controller.ts b/server/src/core/comment/comment.controller.ts
new file mode 100644
index 00000000..826870cc
--- /dev/null
+++ b/server/src/core/comment/comment.controller.ts
@@ -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);
+ }
+}
diff --git a/server/src/core/comment/comment.module.ts b/server/src/core/comment/comment.module.ts
new file mode 100644
index 00000000..f7220579
--- /dev/null
+++ b/server/src/core/comment/comment.module.ts
@@ -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 {}
diff --git a/server/src/core/comment/comment.service.spec.ts b/server/src/core/comment/comment.service.spec.ts
new file mode 100644
index 00000000..0f57aec2
--- /dev/null
+++ b/server/src/core/comment/comment.service.spec.ts
@@ -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);
+ });
+
+ it('should be defined', () => {
+ expect(service).toBeDefined();
+ });
+});
diff --git a/server/src/core/comment/comment.service.ts b/server/src/core/comment/comment.service.ts
new file mode 100644
index 00000000..23d50095
--- /dev/null
+++ b/server/src/core/comment/comment.service.ts
@@ -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 {
+ 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 {
+ 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 {
+ const result = await this.commentRepository.delete(id);
+ if (result.affected === 0) {
+ throw new BadRequestException(`Comment with ID ${id} not found.`);
+ }
+ }
+}
diff --git a/server/src/core/comment/dto/comments.input.ts b/server/src/core/comment/dto/comments.input.ts
new file mode 100644
index 00000000..6aadebbb
--- /dev/null
+++ b/server/src/core/comment/dto/comments.input.ts
@@ -0,0 +1,11 @@
+import { IsUUID } from 'class-validator';
+
+export class CommentsInput {
+ @IsUUID()
+ pageId: string;
+}
+
+export class SingleCommentInput {
+ @IsUUID()
+ id: string;
+}
diff --git a/server/src/core/comment/dto/create-comment.dto.ts b/server/src/core/comment/dto/create-comment.dto.ts
new file mode 100644
index 00000000..8377e32f
--- /dev/null
+++ b/server/src/core/comment/dto/create-comment.dto.ts
@@ -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;
+}
diff --git a/server/src/core/comment/dto/resolve-comment.dto.ts b/server/src/core/comment/dto/resolve-comment.dto.ts
new file mode 100644
index 00000000..73bc9cd2
--- /dev/null
+++ b/server/src/core/comment/dto/resolve-comment.dto.ts
@@ -0,0 +1,9 @@
+import { IsBoolean, IsUUID } from 'class-validator';
+
+export class ResolveCommentDto {
+ @IsUUID()
+ commentId: string;
+
+ @IsBoolean()
+ resolved: boolean;
+}
diff --git a/server/src/core/comment/dto/update-comment.dto.ts b/server/src/core/comment/dto/update-comment.dto.ts
new file mode 100644
index 00000000..bfd109ee
--- /dev/null
+++ b/server/src/core/comment/dto/update-comment.dto.ts
@@ -0,0 +1,9 @@
+import { IsJSON, IsUUID } from 'class-validator';
+
+export class UpdateCommentDto {
+ @IsUUID()
+ id: string;
+
+ @IsJSON()
+ content: any;
+}
diff --git a/server/src/core/comment/entities/comment.entity.ts b/server/src/core/comment/entities/comment.entity.ts
new file mode 100644
index 00000000..8069705e
--- /dev/null
+++ b/server/src/core/comment/entities/comment.entity.ts
@@ -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;
+}
diff --git a/server/src/core/comment/repositories/comment.repository.ts b/server/src/core/comment/repositories/comment.repository.ts
new file mode 100644
index 00000000..8a829dd1
--- /dev/null
+++ b/server/src/core/comment/repositories/comment.repository.ts
@@ -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 {
+ constructor(private dataSource: DataSource) {
+ super(Comment, dataSource.createEntityManager());
+ }
+
+ async findById(commentId: string) {
+ return this.findOneBy({ id: commentId });
+ }
+}
diff --git a/server/src/core/core.module.ts b/server/src/core/core.module.ts
index e823c818..fbc56834 100644
--- a/server/src/core/core.module.ts
+++ b/server/src/core/core.module.ts
@@ -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 {}
diff --git a/server/src/core/page/entities/page.entity.ts b/server/src/core/page/entities/page.entity.ts
index 5ea9956b..236f044d 100644
--- a/server/src/core/page/entities/page.entity.ts
+++ b/server/src/core/page/entities/page.entity.ts
@@ -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[];
}
diff --git a/server/src/core/page/repositories/page.repository.ts b/server/src/core/page/repositories/page.repository.ts
index fa99cf53..574f48ac 100644
--- a/server/src/core/page/repositories/page.repository.ts
+++ b/server/src/core/page/repositories/page.repository.ts
@@ -33,7 +33,6 @@ export class PageRepository extends Repository {
'page.createdAt',
'page.updatedAt',
'page.deletedAt',
- 'page.children',
])
.getOne();
}
diff --git a/server/src/core/page/services/page.service.ts b/server/src/core/page/services/page.service.ts
index 9deb01b2..2678f58a 100644
--- a/server/src/core/page/services/page.service.ts
+++ b/server/src/core/page/services/page.service.ts
@@ -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);
}
diff --git a/server/src/core/user/entities/user.entity.ts b/server/src/core/user/entities/user.entity.ts
index 6c400497..549fbe2c 100644
--- a/server/src/core/user/entities/user.entity.ts
+++ b/server/src/core/user/entities/user.entity.ts
@@ -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;
diff --git a/server/src/core/workspace/entities/workspace.entity.ts b/server/src/core/workspace/entities/workspace.entity.ts
index 10d8def3..f49b94bd 100644
--- a/server/src/core/workspace/entities/workspace.entity.ts
+++ b/server/src/core/workspace/entities/workspace.entity.ts
@@ -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[];
}