diff --git a/apps/backend/src/app.module.ts b/apps/backend/src/app.module.ts index 0ff4254..15de779 100644 --- a/apps/backend/src/app.module.ts +++ b/apps/backend/src/app.module.ts @@ -5,7 +5,9 @@ import { TypeOrmModule } from '@nestjs/typeorm'; import { ConfigModule, ConfigService } from '@nestjs/config'; import { UsersModule } from '@/users/users.module'; import { AuthModule } from '@/auth/auth.module'; -import { ProjectsModule } from './projects/projects.module'; +import { ProjectsModule } from '@/projects/projects.module'; +import { TasksModule } from '@/tasks/tasks.module'; +import { BoardColumnsModule } from '@/board-columns/board-columns.module'; @Module({ imports: [ @@ -29,6 +31,8 @@ import { ProjectsModule } from './projects/projects.module'; AuthModule, UsersModule, ProjectsModule, + TasksModule, + BoardColumnsModule, ], controllers: [AppController], providers: [AppService], diff --git a/apps/backend/src/board-columns/board-columns.controller.spec.ts b/apps/backend/src/board-columns/board-columns.controller.spec.ts new file mode 100644 index 0000000..3ea5e06 --- /dev/null +++ b/apps/backend/src/board-columns/board-columns.controller.spec.ts @@ -0,0 +1,20 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { BoardColumnsController } from '@/board-columns/board-columns.controller'; +import { BoardColumnsService } from '@/board-columns/board-columns.service'; + +describe('BoardColumnsController', () => { + let controller: BoardColumnsController; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + controllers: [BoardColumnsController], + providers: [BoardColumnsService], + }).compile(); + + controller = module.get(BoardColumnsController); + }); + + it('should be defined', () => { + expect(controller).toBeDefined(); + }); +}); diff --git a/apps/backend/src/board-columns/board-columns.controller.ts b/apps/backend/src/board-columns/board-columns.controller.ts new file mode 100644 index 0000000..f1662cd --- /dev/null +++ b/apps/backend/src/board-columns/board-columns.controller.ts @@ -0,0 +1,68 @@ +import { + Controller, + Get, + Post, + Body, + Patch, + Param, + Delete, + UseGuards, + Request, + Query, + ParseIntPipe, +} from '@nestjs/common'; +import { BoardColumnsService } from '@/board-columns/board-columns.service'; +import { CreateBoardColumnDto } from '@/board-columns/dto/create-board-column.dto'; +import { UpdateBoardColumnDto } from '@/board-columns/dto/update-board-column.dto'; +import { JwtAuthGuard } from '@/auth/guards/jwt-auth.guard'; + +@UseGuards(JwtAuthGuard) +@Controller('board-columns') +export class BoardColumnsController { + constructor(private readonly boardColumnsService: BoardColumnsService) {} + + @Post() + create( + @Body() createBoardColumnDto: CreateBoardColumnDto, + @Request() req: { user: { userId: number; email: string } }, + ) { + return this.boardColumnsService.create( + createBoardColumnDto, + req.user.userId, + ); + } + + @Get() + findAll( + @Query('projectId', ParseIntPipe) projectId: number, + @Request() req: { user: { userId: number; email: string } }, + ) { + return this.boardColumnsService.findAll(projectId, req.user.userId); + } + + @Get(':id') + findOne(@Param('id', ParseIntPipe) id: number) { + return this.boardColumnsService.findOne(id); + } + + @Patch(':id') + update( + @Param('id', ParseIntPipe) id: number, + @Body() updateBoardColumnDto: UpdateBoardColumnDto, + @Request() req: { user: { userId: number; email: string } }, + ) { + return this.boardColumnsService.update( + id, + updateBoardColumnDto, + req.user.userId, + ); + } + + @Delete(':id') + remove( + @Param('id', ParseIntPipe) id: number, + @Request() req: { user: { userId: number; email: string } }, + ) { + return this.boardColumnsService.remove(id, req.user.userId); + } +} diff --git a/apps/backend/src/board-columns/board-columns.module.ts b/apps/backend/src/board-columns/board-columns.module.ts new file mode 100644 index 0000000..9ab99c3 --- /dev/null +++ b/apps/backend/src/board-columns/board-columns.module.ts @@ -0,0 +1,13 @@ +import { Module } from '@nestjs/common'; +import { BoardColumnsService } from '@/board-columns/board-columns.service'; +import { BoardColumnsController } from '@/board-columns/board-columns.controller'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { BoardColumn } from '@/board-columns/entities/board-column.entity'; +import { Project } from '@/projects/entities/project.entity'; + +@Module({ + imports: [TypeOrmModule.forFeature([BoardColumn, Project])], + controllers: [BoardColumnsController], + providers: [BoardColumnsService], +}) +export class BoardColumnsModule {} diff --git a/apps/backend/src/board-columns/board-columns.service.spec.ts b/apps/backend/src/board-columns/board-columns.service.spec.ts new file mode 100644 index 0000000..db63f02 --- /dev/null +++ b/apps/backend/src/board-columns/board-columns.service.spec.ts @@ -0,0 +1,18 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { BoardColumnsService } from '@/board-columns/board-columns.service'; + +describe('BoardColumnsService', () => { + let service: BoardColumnsService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [BoardColumnsService], + }).compile(); + + service = module.get(BoardColumnsService); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); +}); diff --git a/apps/backend/src/board-columns/board-columns.service.ts b/apps/backend/src/board-columns/board-columns.service.ts new file mode 100644 index 0000000..6568b74 --- /dev/null +++ b/apps/backend/src/board-columns/board-columns.service.ts @@ -0,0 +1,91 @@ +import { + ForbiddenException, + Injectable, + NotFoundException, +} from '@nestjs/common'; +import { CreateBoardColumnDto } from '@/board-columns/dto/create-board-column.dto'; +import { UpdateBoardColumnDto } from '@/board-columns/dto/update-board-column.dto'; +import { Project } from '@/projects/entities/project.entity'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { BoardColumn } from '@/board-columns/entities/board-column.entity'; + +@Injectable() +export class BoardColumnsService { + constructor( + @InjectRepository(BoardColumn) + private columnsRepository: Repository, + @InjectRepository(Project) private projectsRepository: Repository, + ) {} + + private async verifyProjectAccess( + projectId: number, + userId: number, + ): Promise { + const project = await this.projectsRepository.findOne({ + where: { id: projectId }, + }); + if (!project) throw new NotFoundException('Project not found'); + if (project.userId !== userId) { + throw new ForbiddenException( + 'You do not have permission to modify this project board', + ); + } + } + + private async verifyColumnAccess( + columnId: number, + userId: number, + ): Promise { + const column = await this.columnsRepository.findOne({ + where: { id: columnId }, + relations: ['project'], + }); + if (!column) throw new NotFoundException('Column not found'); + if (column.project.userId !== userId) { + throw new ForbiddenException( + 'You do not have permission to modify this column', + ); + } + return column; + } + + async create(createDto: CreateBoardColumnDto, userId: number) { + await this.verifyProjectAccess(createDto.projectId, userId); + + let order = createDto.order; + if (order === undefined) { + const lastColumn = await this.columnsRepository.findOne({ + where: { projectId: createDto.projectId }, + order: { order: 'DESC' }, + }); + order = lastColumn ? lastColumn.order + 1 : 0; + } + + const column = this.columnsRepository.create({ ...createDto, order }); + return await this.columnsRepository.save(column); + } + + async findAll(projectId: number, userId: number) { + await this.verifyProjectAccess(projectId, userId); + return await this.columnsRepository.find({ + where: { projectId }, + order: { order: 'ASC' }, + }); + } + + findOne(id: number) { + return `This action returns a #${id} boardColumn`; + } + + async update(id: number, updateDto: UpdateBoardColumnDto, userId: number) { + const column = await this.verifyColumnAccess(id, userId); + const updatedColumn = this.columnsRepository.merge(column, updateDto); + return await this.columnsRepository.save(updatedColumn); + } + + async remove(id: number, userId: number) { + const column = await this.verifyColumnAccess(id, userId); + return await this.columnsRepository.remove(column); + } +} diff --git a/apps/backend/src/board-columns/dto/create-board-column.dto.ts b/apps/backend/src/board-columns/dto/create-board-column.dto.ts new file mode 100644 index 0000000..4a1c858 --- /dev/null +++ b/apps/backend/src/board-columns/dto/create-board-column.dto.ts @@ -0,0 +1,15 @@ +import { IsNotEmpty, IsNumber, IsString, IsOptional } from 'class-validator'; + +export class CreateBoardColumnDto { + @IsString() + @IsNotEmpty() + name: string; + + @IsNumber() + @IsOptional() + order?: number; + + @IsNumber() + @IsNotEmpty() + projectId: number; +} diff --git a/apps/backend/src/board-columns/dto/update-board-column.dto.ts b/apps/backend/src/board-columns/dto/update-board-column.dto.ts new file mode 100644 index 0000000..73af7a9 --- /dev/null +++ b/apps/backend/src/board-columns/dto/update-board-column.dto.ts @@ -0,0 +1,4 @@ +import { PartialType } from '@nestjs/mapped-types'; +import { CreateBoardColumnDto } from '@/board-columns/dto/create-board-column.dto'; + +export class UpdateBoardColumnDto extends PartialType(CreateBoardColumnDto) {} diff --git a/apps/backend/src/board-columns/entities/board-column.entity.ts b/apps/backend/src/board-columns/entities/board-column.entity.ts new file mode 100644 index 0000000..697f612 --- /dev/null +++ b/apps/backend/src/board-columns/entities/board-column.entity.ts @@ -0,0 +1,42 @@ +import { Project } from '@/projects/entities/project.entity'; +import { Task } from '@/tasks/entities/task.entity'; +import { + Column, + CreateDateColumn, + UpdateDateColumn, + Entity, + ManyToOne, + JoinColumn, + OneToMany, + PrimaryGeneratedColumn, +} from 'typeorm'; + +@Entity({ name: 'board_columns' }) +export class BoardColumn { + @PrimaryGeneratedColumn() + id: number; + + @Column() + name: string; + + @Column() + order: number; + + @Column({ name: 'project_id' }) + projectId: number; + + @CreateDateColumn({ name: 'created_at' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at' }) + updatedAt: Date; + + @ManyToOne(() => Project, (project) => project.boardColumns, { + onDelete: 'CASCADE', + }) + @JoinColumn({ name: 'project_id' }) + project: Project; + + @OneToMany(() => Task, (task) => task.column) + tasks: Task[]; +} diff --git a/apps/backend/src/projects/entities/project.entity.ts b/apps/backend/src/projects/entities/project.entity.ts index 643b006..b271de9 100644 --- a/apps/backend/src/projects/entities/project.entity.ts +++ b/apps/backend/src/projects/entities/project.entity.ts @@ -1,3 +1,4 @@ +import { BoardColumn } from '@/board-columns/entities/board-column.entity'; import { User } from '@/users/user.entity'; import { Column, @@ -5,6 +6,7 @@ import { Entity, JoinColumn, ManyToOne, + OneToMany, PrimaryGeneratedColumn, UpdateDateColumn, } from 'typeorm'; @@ -32,4 +34,7 @@ export class Project { @UpdateDateColumn({ name: 'updated_at' }) updatedAt: Date; + + @OneToMany(() => BoardColumn, (boardColumn) => boardColumn.project) + boardColumns: BoardColumn[]; } diff --git a/apps/backend/src/projects/projects.controller.ts b/apps/backend/src/projects/projects.controller.ts index 082c6f7..fc17a44 100644 --- a/apps/backend/src/projects/projects.controller.ts +++ b/apps/backend/src/projects/projects.controller.ts @@ -8,6 +8,7 @@ import { Delete, UseGuards, Request, + ParseIntPipe, } from '@nestjs/common'; import { ProjectsService } from '@/projects/projects.service'; import { CreateProjectDto } from '@/projects/dto/create-project.dto'; @@ -34,26 +35,26 @@ export class ProjectsController { @Get(':id') findOne( - @Param('id') id: string, + @Param('id', ParseIntPipe) id: number, @Request() req: { user: { userId: number; email: string } }, ) { - return this.projectsService.findOne(+id, req.user.userId); + return this.projectsService.findOne(id, req.user.userId); } @Patch(':id') update( - @Param('id') id: string, + @Param('id', ParseIntPipe) id: number, @Body() updateProjectDto: UpdateProjectDto, @Request() req: { user: { userId: number; email: string } }, ) { - return this.projectsService.update(+id, updateProjectDto, req.user.userId); + return this.projectsService.update(id, updateProjectDto, req.user.userId); } @Delete(':id') remove( - @Param('id') id: string, + @Param('id', ParseIntPipe) id: number, @Request() req: { user: { userId: number; email: string } }, ) { - return this.projectsService.remove(+id, req.user.userId); + return this.projectsService.remove(id, req.user.userId); } } diff --git a/apps/backend/src/tasks/dto/create-task.dto.ts b/apps/backend/src/tasks/dto/create-task.dto.ts new file mode 100644 index 0000000..1987922 --- /dev/null +++ b/apps/backend/src/tasks/dto/create-task.dto.ts @@ -0,0 +1,19 @@ +import { IsNotEmpty, IsOptional, IsString, IsNumber } from 'class-validator'; + +export class CreateTaskDto { + @IsString() + @IsNotEmpty() + title: string; + + @IsString() + @IsOptional() + description?: string; + + @IsNumber() + @IsOptional() + order?: number; + + @IsNumber() + @IsNotEmpty() + columnId: number; +} diff --git a/apps/backend/src/tasks/dto/update-task.dto.ts b/apps/backend/src/tasks/dto/update-task.dto.ts new file mode 100644 index 0000000..58ae29d --- /dev/null +++ b/apps/backend/src/tasks/dto/update-task.dto.ts @@ -0,0 +1,4 @@ +import { PartialType } from '@nestjs/mapped-types'; +import { CreateTaskDto } from '@/tasks/dto/create-task.dto'; + +export class UpdateTaskDto extends PartialType(CreateTaskDto) {} diff --git a/apps/backend/src/tasks/entities/task.entity.ts b/apps/backend/src/tasks/entities/task.entity.ts new file mode 100644 index 0000000..790b74a --- /dev/null +++ b/apps/backend/src/tasks/entities/task.entity.ts @@ -0,0 +1,40 @@ +import { BoardColumn } from '@/board-columns/entities/board-column.entity'; +import { + Column, + CreateDateColumn, + UpdateDateColumn, + Entity, + JoinColumn, + ManyToOne, + PrimaryGeneratedColumn, +} from 'typeorm'; + +@Entity({ name: 'tasks' }) +export class Task { + @PrimaryGeneratedColumn() + id: number; + + @Column() + title: string; + + @Column({ nullable: true }) + description: string; + + @Column() + order: number; + + @Column({ name: 'column_id' }) + columnId: number; + + @CreateDateColumn({ name: 'created_at' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at' }) + updatedAt: Date; + + @ManyToOne(() => BoardColumn, (column) => column.tasks, { + onDelete: 'CASCADE', + }) + @JoinColumn({ name: 'column_id' }) + column: BoardColumn; +} diff --git a/apps/backend/src/tasks/tasks.controller.spec.ts b/apps/backend/src/tasks/tasks.controller.spec.ts new file mode 100644 index 0000000..ce8efed --- /dev/null +++ b/apps/backend/src/tasks/tasks.controller.spec.ts @@ -0,0 +1,20 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { TasksController } from '@/tasks/tasks.controller'; +import { TasksService } from '@/tasks/tasks.service'; + +describe('TasksController', () => { + let controller: TasksController; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + controllers: [TasksController], + providers: [TasksService], + }).compile(); + + controller = module.get(TasksController); + }); + + it('should be defined', () => { + expect(controller).toBeDefined(); + }); +}); diff --git a/apps/backend/src/tasks/tasks.controller.ts b/apps/backend/src/tasks/tasks.controller.ts new file mode 100644 index 0000000..a80d157 --- /dev/null +++ b/apps/backend/src/tasks/tasks.controller.ts @@ -0,0 +1,57 @@ +import { + Controller, + Get, + Post, + Body, + Patch, + Param, + Delete, + Request, + UseGuards, + ParseIntPipe, +} from '@nestjs/common'; +import { TasksService } from '@/tasks/tasks.service'; +import { CreateTaskDto } from '@/tasks/dto/create-task.dto'; +import { UpdateTaskDto } from '@/tasks/dto/update-task.dto'; +import { JwtAuthGuard } from '@/auth/guards/jwt-auth.guard'; + +@UseGuards(JwtAuthGuard) +@Controller('tasks') +export class TasksController { + constructor(private readonly tasksService: TasksService) {} + + @Post() + create( + @Body() createTaskDto: CreateTaskDto, + @Request() req: { user: { userId: number; email: string } }, + ) { + return this.tasksService.create(createTaskDto, req.user.userId); + } + + @Get() + findAll() { + return this.tasksService.findAll(); + } + + @Get(':id') + findOne(@Param('id', ParseIntPipe) id: number) { + return this.tasksService.findOne(id); + } + + @Patch(':id') + update( + @Param('id', ParseIntPipe) id: number, + @Body() updateTaskDto: UpdateTaskDto, + @Request() req: { user: { userId: number; email: string } }, + ) { + return this.tasksService.update(id, updateTaskDto, req.user.userId); + } + + @Delete(':id') + remove( + @Param('id', ParseIntPipe) id: number, + @Request() req: { user: { userId: number; email: string } }, + ) { + return this.tasksService.remove(id, req.user.userId); + } +} diff --git a/apps/backend/src/tasks/tasks.module.ts b/apps/backend/src/tasks/tasks.module.ts new file mode 100644 index 0000000..6838c0c --- /dev/null +++ b/apps/backend/src/tasks/tasks.module.ts @@ -0,0 +1,14 @@ +import { Module } from '@nestjs/common'; +import { TasksService } from '@/tasks/tasks.service'; +import { TasksController } from '@/tasks/tasks.controller'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { Task } from '@/tasks/entities/task.entity'; +import { BoardColumn } from '@/board-columns/entities/board-column.entity'; +import { Project } from '@/projects/entities/project.entity'; + +@Module({ + imports: [TypeOrmModule.forFeature([Task, BoardColumn, Project])], + controllers: [TasksController], + providers: [TasksService], +}) +export class TasksModule {} diff --git a/apps/backend/src/tasks/tasks.service.spec.ts b/apps/backend/src/tasks/tasks.service.spec.ts new file mode 100644 index 0000000..1eb13de --- /dev/null +++ b/apps/backend/src/tasks/tasks.service.spec.ts @@ -0,0 +1,18 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { TasksService } from '@/tasks/tasks.service'; + +describe('TasksService', () => { + let service: TasksService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [TasksService], + }).compile(); + + service = module.get(TasksService); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); +}); diff --git a/apps/backend/src/tasks/tasks.service.ts b/apps/backend/src/tasks/tasks.service.ts new file mode 100644 index 0000000..5e80b74 --- /dev/null +++ b/apps/backend/src/tasks/tasks.service.ts @@ -0,0 +1,100 @@ +import { + Injectable, + NotFoundException, + ForbiddenException, +} from '@nestjs/common'; +import { CreateTaskDto } from '@/tasks/dto/create-task.dto'; +import { UpdateTaskDto } from '@/tasks/dto/update-task.dto'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { Task } from '@/tasks/entities/task.entity'; +import { BoardColumn } from '@/board-columns/entities/board-column.entity'; + +@Injectable() +export class TasksService { + constructor( + @InjectRepository(Task) private tasksRepository: Repository, + @InjectRepository(BoardColumn) + private columnsRepository: Repository, + ) {} + + private async verifyColumnAccess( + columnId: number, + userId: number, + ): Promise { + const column = await this.columnsRepository.findOne({ + where: { id: columnId }, + relations: ['project'], + }); + + if (!column) throw new NotFoundException('Column not found'); + if (column.project.userId !== userId) { + throw new ForbiddenException( + 'You do not have permission to add tasks to this board', + ); + } + } + + private async verifyTaskAccess( + taskId: number, + userId: number, + ): Promise { + const task = await this.tasksRepository.findOne({ + where: { id: taskId }, + relations: ['column', 'column.project'], + }); + + if (!task) throw new NotFoundException('Task not found'); + if (task.column.project.userId !== userId) { + throw new ForbiddenException( + 'You do not have permission to modify this task', + ); + } + + return task; + } + + async create(createTaskDto: CreateTaskDto, userId: number) { + await this.verifyColumnAccess(createTaskDto.columnId, userId); + + let order = createTaskDto.order; + if (order === undefined) { + const lastTask = await this.tasksRepository.findOne({ + where: { columnId: createTaskDto.columnId }, + order: { order: 'DESC' }, + }); + order = lastTask ? lastTask.order + 1 : 0; + } + + const task = this.tasksRepository.create({ + ...createTaskDto, + order, + }); + + return await this.tasksRepository.save(task); + } + + findAll() { + return `This action returns all tasks`; + } + + findOne(id: number) { + return `This action returns a #${id} task`; + } + + async update(id: number, updateTaskDto: UpdateTaskDto, userId: number) { + const task = await this.verifyTaskAccess(id, userId); + + if (updateTaskDto.columnId && updateTaskDto.columnId !== task.columnId) { + await this.verifyColumnAccess(updateTaskDto.columnId, userId); + } + + const updatedTask = this.tasksRepository.merge(task, updateTaskDto); + return await this.tasksRepository.save(updatedTask); + } + + async remove(id: number, userId: number) { + const task = await this.verifyTaskAccess(id, userId); + return await this.tasksRepository.remove(task); + } +}