From be03de1a0b0c9ab67ae93e1046cd62df5b60a9bd Mon Sep 17 00:00:00 2001 From: Zafar7645 Date: Sat, 14 Mar 2026 23:09:19 +0530 Subject: [PATCH 1/7] feat(tasks): implement task and board column CRUD APIs - Define BoardColumn and Task entities strictly mapping to the SQL schema - Create BoardColumnsModule with full CRUD and project ownership validation - Create TasksModule with full CRUD, column validation, and auto-ordering - Protect all new endpoints using JwtAuthGuard - Implement ParseIntPipe for all ID parameters to match SERIAL database columns --- apps/backend/src/app.module.ts | 6 +- .../board-columns.controller.spec.ts | 20 ++++ .../board-columns/board-columns.controller.ts | 68 ++++++++++++ .../src/board-columns/board-columns.module.ts | 13 +++ .../board-columns.service.spec.ts | 18 ++++ .../board-columns/board-columns.service.ts | 91 ++++++++++++++++ .../dto/create-board-column.dto.ts | 15 +++ .../dto/update-board-column.dto.ts | 4 + .../entities/board-column.entity.ts | 42 ++++++++ .../src/projects/entities/project.entity.ts | 5 + apps/backend/src/tasks/dto/create-task.dto.ts | 19 ++++ apps/backend/src/tasks/dto/update-task.dto.ts | 4 + .../backend/src/tasks/entities/task.entity.ts | 40 +++++++ .../src/tasks/tasks.controller.spec.ts | 20 ++++ apps/backend/src/tasks/tasks.controller.ts | 57 ++++++++++ apps/backend/src/tasks/tasks.module.ts | 14 +++ apps/backend/src/tasks/tasks.service.spec.ts | 18 ++++ apps/backend/src/tasks/tasks.service.ts | 100 ++++++++++++++++++ 18 files changed, 553 insertions(+), 1 deletion(-) create mode 100644 apps/backend/src/board-columns/board-columns.controller.spec.ts create mode 100644 apps/backend/src/board-columns/board-columns.controller.ts create mode 100644 apps/backend/src/board-columns/board-columns.module.ts create mode 100644 apps/backend/src/board-columns/board-columns.service.spec.ts create mode 100644 apps/backend/src/board-columns/board-columns.service.ts create mode 100644 apps/backend/src/board-columns/dto/create-board-column.dto.ts create mode 100644 apps/backend/src/board-columns/dto/update-board-column.dto.ts create mode 100644 apps/backend/src/board-columns/entities/board-column.entity.ts create mode 100644 apps/backend/src/tasks/dto/create-task.dto.ts create mode 100644 apps/backend/src/tasks/dto/update-task.dto.ts create mode 100644 apps/backend/src/tasks/entities/task.entity.ts create mode 100644 apps/backend/src/tasks/tasks.controller.spec.ts create mode 100644 apps/backend/src/tasks/tasks.controller.ts create mode 100644 apps/backend/src/tasks/tasks.module.ts create mode 100644 apps/backend/src/tasks/tasks.service.spec.ts create mode 100644 apps/backend/src/tasks/tasks.service.ts 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/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); + } +} From 6f2131b7a42fe19568cccec6fb8e22d269bad4d7 Mon Sep 17 00:00:00 2001 From: Zafar7645 Date: Sat, 14 Mar 2026 23:11:00 +0530 Subject: [PATCH 2/7] chore(backend): refactor type of query param id with ParseIntPipe --- apps/backend/src/projects/projects.controller.ts | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) 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); } } From 727f9007e1dc657d716a91d1286b3bae9ecf00b5 Mon Sep 17 00:00:00 2001 From: Zafar Shaikh Date: Fri, 27 Mar 2026 16:59:13 +0530 Subject: [PATCH 3/7] feat(tasks): apply all suggestions from automated code review --- .../board-columns/board-columns.controller.ts | 7 ++- .../board-columns/board-columns.service.ts | 37 +++++++---- .../dto/create-board-column.dto.ts | 6 +- .../src/projects/entities/project.entity.ts | 2 +- apps/backend/src/tasks/dto/create-task.dto.ts | 6 +- .../backend/src/tasks/entities/task.entity.ts | 2 +- apps/backend/src/tasks/tasks.controller.ts | 15 +++-- apps/backend/src/tasks/tasks.service.ts | 61 +++++++++++++------ 8 files changed, 91 insertions(+), 45 deletions(-) diff --git a/apps/backend/src/board-columns/board-columns.controller.ts b/apps/backend/src/board-columns/board-columns.controller.ts index f1662cd..61d96ae 100644 --- a/apps/backend/src/board-columns/board-columns.controller.ts +++ b/apps/backend/src/board-columns/board-columns.controller.ts @@ -41,8 +41,11 @@ export class BoardColumnsController { } @Get(':id') - findOne(@Param('id', ParseIntPipe) id: number) { - return this.boardColumnsService.findOne(id); + findOne( + @Param('id', ParseIntPipe) id: number, + @Request() req: { user: { userId: number; email: string } }, + ) { + return this.boardColumnsService.findOne(id, req.user.userId); } @Patch(':id') diff --git a/apps/backend/src/board-columns/board-columns.service.ts b/apps/backend/src/board-columns/board-columns.service.ts index 6568b74..8cdcf18 100644 --- a/apps/backend/src/board-columns/board-columns.service.ts +++ b/apps/backend/src/board-columns/board-columns.service.ts @@ -53,17 +53,30 @@ export class BoardColumnsService { 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; - } + return await this.columnsRepository.manager.transaction( + async (transactionalManager) => { + await transactionalManager + .createQueryBuilder(Project, 'project') + .where('project.id = :id', { id: createDto.projectId }) + .setLock('pessimistic_write') + .getOne(); + + let order = createDto.order; + if (order === undefined) { + const lastColumn = await transactionalManager.findOne(BoardColumn, { + 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); + const column = transactionalManager.create(BoardColumn, { + ...createDto, + order, + }); + return await transactionalManager.save(column); + }, + ); } async findAll(projectId: number, userId: number) { @@ -74,8 +87,8 @@ export class BoardColumnsService { }); } - findOne(id: number) { - return `This action returns a #${id} boardColumn`; + async findOne(id: number, userId: number) { + return await this.verifyColumnAccess(id, userId); } async update(id: number, updateDto: UpdateBoardColumnDto, userId: number) { 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 index 4a1c858..acd3563 100644 --- a/apps/backend/src/board-columns/dto/create-board-column.dto.ts +++ b/apps/backend/src/board-columns/dto/create-board-column.dto.ts @@ -1,15 +1,15 @@ -import { IsNotEmpty, IsNumber, IsString, IsOptional } from 'class-validator'; +import { IsNotEmpty, IsString, IsOptional, IsInt } from 'class-validator'; export class CreateBoardColumnDto { @IsString() @IsNotEmpty() name: string; - @IsNumber() + @IsInt() @IsOptional() order?: number; - @IsNumber() + @IsInt() @IsNotEmpty() projectId: number; } diff --git a/apps/backend/src/projects/entities/project.entity.ts b/apps/backend/src/projects/entities/project.entity.ts index b271de9..fc28399 100644 --- a/apps/backend/src/projects/entities/project.entity.ts +++ b/apps/backend/src/projects/entities/project.entity.ts @@ -20,7 +20,7 @@ export class Project { name: string; @Column({ nullable: true }) - description: string; + description: string | null; @Column({ name: 'user_id' }) userId: number; diff --git a/apps/backend/src/tasks/dto/create-task.dto.ts b/apps/backend/src/tasks/dto/create-task.dto.ts index 1987922..7a8ecb6 100644 --- a/apps/backend/src/tasks/dto/create-task.dto.ts +++ b/apps/backend/src/tasks/dto/create-task.dto.ts @@ -1,4 +1,4 @@ -import { IsNotEmpty, IsOptional, IsString, IsNumber } from 'class-validator'; +import { IsNotEmpty, IsOptional, IsString, IsInt } from 'class-validator'; export class CreateTaskDto { @IsString() @@ -9,11 +9,11 @@ export class CreateTaskDto { @IsOptional() description?: string; - @IsNumber() + @IsInt() @IsOptional() order?: number; - @IsNumber() + @IsInt() @IsNotEmpty() columnId: number; } diff --git a/apps/backend/src/tasks/entities/task.entity.ts b/apps/backend/src/tasks/entities/task.entity.ts index 790b74a..ba65aa2 100644 --- a/apps/backend/src/tasks/entities/task.entity.ts +++ b/apps/backend/src/tasks/entities/task.entity.ts @@ -18,7 +18,7 @@ export class Task { title: string; @Column({ nullable: true }) - description: string; + description: string | null; @Column() order: number; diff --git a/apps/backend/src/tasks/tasks.controller.ts b/apps/backend/src/tasks/tasks.controller.ts index a80d157..845eeb6 100644 --- a/apps/backend/src/tasks/tasks.controller.ts +++ b/apps/backend/src/tasks/tasks.controller.ts @@ -9,6 +9,7 @@ import { Request, UseGuards, ParseIntPipe, + Query, } from '@nestjs/common'; import { TasksService } from '@/tasks/tasks.service'; import { CreateTaskDto } from '@/tasks/dto/create-task.dto'; @@ -29,13 +30,19 @@ export class TasksController { } @Get() - findAll() { - return this.tasksService.findAll(); + findAll( + @Query('columnId', ParseIntPipe) columnId: number, + @Request() req: { user: { userId: number; email: string } }, + ) { + return this.tasksService.findAll(columnId, req.user.userId); } @Get(':id') - findOne(@Param('id', ParseIntPipe) id: number) { - return this.tasksService.findOne(id); + findOne( + @Param('id', ParseIntPipe) id: number, + @Request() req: { user: { userId: number; email: string } }, + ) { + return this.tasksService.findOne(id, req.user.userId); } @Patch(':id') diff --git a/apps/backend/src/tasks/tasks.service.ts b/apps/backend/src/tasks/tasks.service.ts index 5e80b74..8816971 100644 --- a/apps/backend/src/tasks/tasks.service.ts +++ b/apps/backend/src/tasks/tasks.service.ts @@ -57,29 +57,52 @@ export class TasksService { 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); + return await this.columnsRepository.manager.transaction( + async (transactionalManager) => { + await transactionalManager + .createQueryBuilder(BoardColumn, 'column') + .where('column.id = :id', { id: createTaskDto.columnId }) + .setLock('pessimistic_write') + .getOne(); + + let order = createTaskDto.order; + if (order === undefined) { + const lastTask = await transactionalManager.findOne(Task, { + where: { columnId: createTaskDto.columnId }, + order: { order: 'DESC' }, + }); + order = lastTask ? lastTask.order + 1 : 0; + } + + const task = transactionalManager.create(Task, { + ...createTaskDto, + order, + }); + + return await transactionalManager.save(task); + }, + ); } - findAll() { - return `This action returns all tasks`; + async findAll(columnId: number, userId: number) { + await this.verifyColumnAccess(columnId, userId); + return await this.tasksRepository.find({ + where: { + column: { + project: { + userId: userId, + }, + }, + }, + order: { + columnId: 'ASC', + order: 'ASC', + }, + }); } - findOne(id: number) { - return `This action returns a #${id} task`; + async findOne(id: number, userId: number) { + return await this.verifyTaskAccess(id, userId); } async update(id: number, updateTaskDto: UpdateTaskDto, userId: number) { From ef5c294285b05f3926aa8c2f7f4bdeb89d996674 Mon Sep 17 00:00:00 2001 From: Zafar Shaikh Date: Fri, 27 Mar 2026 18:57:03 +0530 Subject: [PATCH 4/7] feat(tasks): apply all suggestions from second round of automated review --- .../board-columns/board-columns.service.ts | 16 ++++++++-- .../dto/update-board-column.dto.ts | 6 ++-- apps/backend/src/tasks/tasks.service.ts | 29 +++++++++++++------ 3 files changed, 37 insertions(+), 14 deletions(-) diff --git a/apps/backend/src/board-columns/board-columns.service.ts b/apps/backend/src/board-columns/board-columns.service.ts index 8cdcf18..d92af93 100644 --- a/apps/backend/src/board-columns/board-columns.service.ts +++ b/apps/backend/src/board-columns/board-columns.service.ts @@ -51,16 +51,23 @@ export class BoardColumnsService { } async create(createDto: CreateBoardColumnDto, userId: number) { - await this.verifyProjectAccess(createDto.projectId, userId); - return await this.columnsRepository.manager.transaction( async (transactionalManager) => { - await transactionalManager + const project = await transactionalManager .createQueryBuilder(Project, 'project') .where('project.id = :id', { id: createDto.projectId }) .setLock('pessimistic_write') .getOne(); + 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', + ); + } + let order = createDto.order; if (order === undefined) { const lastColumn = await transactionalManager.findOne(BoardColumn, { @@ -93,6 +100,9 @@ export class BoardColumnsService { async update(id: number, updateDto: UpdateBoardColumnDto, userId: number) { const column = await this.verifyColumnAccess(id, userId); + if ('projectId' in updateDto) { + delete updateDto.projectId; + } const updatedColumn = this.columnsRepository.merge(column, updateDto); return await this.columnsRepository.save(updatedColumn); } 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 index 73af7a9..76ec337 100644 --- a/apps/backend/src/board-columns/dto/update-board-column.dto.ts +++ b/apps/backend/src/board-columns/dto/update-board-column.dto.ts @@ -1,4 +1,6 @@ -import { PartialType } from '@nestjs/mapped-types'; +import { OmitType, PartialType } from '@nestjs/mapped-types'; import { CreateBoardColumnDto } from '@/board-columns/dto/create-board-column.dto'; -export class UpdateBoardColumnDto extends PartialType(CreateBoardColumnDto) {} +export class UpdateBoardColumnDto extends PartialType( + OmitType(CreateBoardColumnDto, ['projectId'] as const), +) {} diff --git a/apps/backend/src/tasks/tasks.service.ts b/apps/backend/src/tasks/tasks.service.ts index 8816971..cf32003 100644 --- a/apps/backend/src/tasks/tasks.service.ts +++ b/apps/backend/src/tasks/tasks.service.ts @@ -55,16 +55,24 @@ export class TasksService { } async create(createTaskDto: CreateTaskDto, userId: number) { - await this.verifyColumnAccess(createTaskDto.columnId, userId); - return await this.columnsRepository.manager.transaction( async (transactionalManager) => { - await transactionalManager + const column = await transactionalManager .createQueryBuilder(BoardColumn, 'column') + .leftJoinAndSelect('column.project', 'project') .where('column.id = :id', { id: createTaskDto.columnId }) .setLock('pessimistic_write') .getOne(); + 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', + ); + } + let order = createTaskDto.order; if (order === undefined) { const lastTask = await transactionalManager.findOne(Task, { @@ -88,14 +96,9 @@ export class TasksService { await this.verifyColumnAccess(columnId, userId); return await this.tasksRepository.find({ where: { - column: { - project: { - userId: userId, - }, - }, + columnId: columnId, }, order: { - columnId: 'ASC', order: 'ASC', }, }); @@ -110,6 +113,14 @@ export class TasksService { if (updateTaskDto.columnId && updateTaskDto.columnId !== task.columnId) { await this.verifyColumnAccess(updateTaskDto.columnId, userId); + + if (updateTaskDto.order === undefined) { + const lastTask = await this.tasksRepository.findOne({ + where: { columnId: updateTaskDto.columnId }, + order: { order: 'DESC' }, + }); + updateTaskDto.order = lastTask ? lastTask.order + 1 : 0; + } } const updatedTask = this.tasksRepository.merge(task, updateTaskDto); From e588e9d5b2349aeb1f244a2c33a2d52108dae28d Mon Sep 17 00:00:00 2001 From: Zafar Shaikh Date: Fri, 27 Mar 2026 19:36:22 +0530 Subject: [PATCH 5/7] feat(tasks): apply all suggestions from third round of automated review --- apps/backend/src/tasks/tasks.service.ts | 52 ++++++++++++++++++++----- 1 file changed, 42 insertions(+), 10 deletions(-) diff --git a/apps/backend/src/tasks/tasks.service.ts b/apps/backend/src/tasks/tasks.service.ts index cf32003..e9dc1ef 100644 --- a/apps/backend/src/tasks/tasks.service.ts +++ b/apps/backend/src/tasks/tasks.service.ts @@ -109,20 +109,52 @@ export class TasksService { } async update(id: number, updateTaskDto: UpdateTaskDto, userId: number) { + // Quick initial check to ensure the user owns the task before doing anything else const task = await this.verifyTaskAccess(id, userId); - if (updateTaskDto.columnId && updateTaskDto.columnId !== task.columnId) { - await this.verifyColumnAccess(updateTaskDto.columnId, userId); - - if (updateTaskDto.order === undefined) { - const lastTask = await this.tasksRepository.findOne({ - where: { columnId: updateTaskDto.columnId }, - order: { order: 'DESC' }, - }); - updateTaskDto.order = lastTask ? lastTask.order + 1 : 0; - } + // If the task is being moved to a DIFFERENT column... + if ( + updateTaskDto.columnId !== undefined && + updateTaskDto.columnId !== task.columnId + ) { + return await this.tasksRepository.manager.transaction( + async (transactionalManager) => { + // 1. Fetch, join, and lock the TARGET column + const targetColumn = await transactionalManager + .createQueryBuilder(BoardColumn, 'column') + .leftJoinAndSelect('column.project', 'project') + .where('column.id = :id', { id: updateTaskDto.columnId }) + .setLock('pessimistic_write') + .getOne(); + + // 2. Validate target column access (Inside the transaction lock) + if (!targetColumn) { + throw new NotFoundException('Target column not found'); + } + if (targetColumn.project.userId !== userId) { + throw new ForbiddenException( + 'You do not have permission to move tasks to this board', + ); + } + + // 3. Calculate the new order safely + if (updateTaskDto.order === undefined) { + const lastTask = await transactionalManager.findOne(Task, { + where: { columnId: updateTaskDto.columnId }, + order: { order: 'DESC' }, + }); + updateTaskDto.order = lastTask ? lastTask.order + 1 : 0; + } + + // 4. Merge and save the task inside the transaction scope + const taskRepo = transactionalManager.getRepository(Task); + const updatedTask = taskRepo.merge(task, updateTaskDto); + return await taskRepo.save(updatedTask); + }, + ); } + // Standard update (Title changes, description changes, etc. within the same column) const updatedTask = this.tasksRepository.merge(task, updateTaskDto); return await this.tasksRepository.save(updatedTask); } From 964dd7d71a9625e9bb74113c1639ba01389695ae Mon Sep 17 00:00:00 2001 From: Zafar Shaikh Date: Fri, 27 Mar 2026 19:46:21 +0530 Subject: [PATCH 6/7] feat(tasks): apply all suggestions from fourth round of automated review --- apps/backend/src/tasks/tasks.service.ts | 36 ++++++++++++++++++------- 1 file changed, 26 insertions(+), 10 deletions(-) diff --git a/apps/backend/src/tasks/tasks.service.ts b/apps/backend/src/tasks/tasks.service.ts index e9dc1ef..54ac945 100644 --- a/apps/backend/src/tasks/tasks.service.ts +++ b/apps/backend/src/tasks/tasks.service.ts @@ -109,17 +109,28 @@ export class TasksService { } async update(id: number, updateTaskDto: UpdateTaskDto, userId: number) { - // Quick initial check to ensure the user owns the task before doing anything else + // 1. Initial auth check (fast fail if user doesn't own the task to begin with) const task = await this.verifyTaskAccess(id, userId); - // If the task is being moved to a DIFFERENT column... + // 2. Check if the task is being moved to a DIFFERENT column if ( updateTaskDto.columnId !== undefined && updateTaskDto.columnId !== task.columnId ) { return await this.tasksRepository.manager.transaction( async (transactionalManager) => { - // 1. Fetch, join, and lock the TARGET column + // a. Re-read and lock the SOURCE TASK to prevent lost updates + const lockedTask = await transactionalManager + .createQueryBuilder(Task, 'task') + .where('task.id = :id', { id }) + .setLock('pessimistic_write') + .getOne(); + + if (!lockedTask) { + throw new NotFoundException('Task no longer exists'); + } + + // b. Fetch, join, and lock the TARGET COLUMN const targetColumn = await transactionalManager .createQueryBuilder(BoardColumn, 'column') .leftJoinAndSelect('column.project', 'project') @@ -127,7 +138,7 @@ export class TasksService { .setLock('pessimistic_write') .getOne(); - // 2. Validate target column access (Inside the transaction lock) + // c. Validate target column access (Inside the transaction lock) if (!targetColumn) { throw new NotFoundException('Target column not found'); } @@ -137,24 +148,29 @@ export class TasksService { ); } - // 3. Calculate the new order safely - if (updateTaskDto.order === undefined) { + // d. Calculate the new order safely + let newOrder = updateTaskDto.order; + if (newOrder === undefined) { const lastTask = await transactionalManager.findOne(Task, { where: { columnId: updateTaskDto.columnId }, order: { order: 'DESC' }, }); - updateTaskDto.order = lastTask ? lastTask.order + 1 : 0; + newOrder = lastTask ? lastTask.order + 1 : 0; } - // 4. Merge and save the task inside the transaction scope + // e. Merge and save into the FRESH locked task, not the stale outer one const taskRepo = transactionalManager.getRepository(Task); - const updatedTask = taskRepo.merge(task, updateTaskDto); + const updatedTask = taskRepo.merge(lockedTask, { + ...updateTaskDto, + order: newOrder, + }); + return await taskRepo.save(updatedTask); }, ); } - // Standard update (Title changes, description changes, etc. within the same column) + // 3. Standard update (Title changes, description changes, etc. within the same column) const updatedTask = this.tasksRepository.merge(task, updateTaskDto); return await this.tasksRepository.save(updatedTask); } From bd941e8838bc1c6c2395c8647351007facde8c80 Mon Sep 17 00:00:00 2001 From: Zafar Shaikh Date: Fri, 27 Mar 2026 20:02:36 +0530 Subject: [PATCH 7/7] feat(tasks): apply all suggestions from fifth round of automated review --- apps/backend/src/tasks/tasks.service.ts | 28 +++++++++---------------- 1 file changed, 10 insertions(+), 18 deletions(-) diff --git a/apps/backend/src/tasks/tasks.service.ts b/apps/backend/src/tasks/tasks.service.ts index 54ac945..16abff6 100644 --- a/apps/backend/src/tasks/tasks.service.ts +++ b/apps/backend/src/tasks/tasks.service.ts @@ -109,28 +109,29 @@ export class TasksService { } async update(id: number, updateTaskDto: UpdateTaskDto, userId: number) { - // 1. Initial auth check (fast fail if user doesn't own the task to begin with) const task = await this.verifyTaskAccess(id, userId); - // 2. Check if the task is being moved to a DIFFERENT column if ( updateTaskDto.columnId !== undefined && updateTaskDto.columnId !== task.columnId ) { return await this.tasksRepository.manager.transaction( async (transactionalManager) => { - // a. Re-read and lock the SOURCE TASK to prevent lost updates const lockedTask = await transactionalManager .createQueryBuilder(Task, 'task') + .innerJoinAndSelect('task.column', 'column') + .innerJoinAndSelect('column.project', 'project') .where('task.id = :id', { id }) .setLock('pessimistic_write') .getOne(); - if (!lockedTask) { - throw new NotFoundException('Task no longer exists'); + if (!lockedTask) throw new NotFoundException('Task not found'); + if (lockedTask.column.project.userId !== userId) { + throw new ForbiddenException( + 'You do not have permission to modify this task', + ); } - // b. Fetch, join, and lock the TARGET COLUMN const targetColumn = await transactionalManager .createQueryBuilder(BoardColumn, 'column') .leftJoinAndSelect('column.project', 'project') @@ -138,7 +139,6 @@ export class TasksService { .setLock('pessimistic_write') .getOne(); - // c. Validate target column access (Inside the transaction lock) if (!targetColumn) { throw new NotFoundException('Target column not found'); } @@ -148,29 +148,21 @@ export class TasksService { ); } - // d. Calculate the new order safely - let newOrder = updateTaskDto.order; - if (newOrder === undefined) { + if (updateTaskDto.order === undefined) { const lastTask = await transactionalManager.findOne(Task, { where: { columnId: updateTaskDto.columnId }, order: { order: 'DESC' }, }); - newOrder = lastTask ? lastTask.order + 1 : 0; + updateTaskDto.order = lastTask ? lastTask.order + 1 : 0; } - // e. Merge and save into the FRESH locked task, not the stale outer one const taskRepo = transactionalManager.getRepository(Task); - const updatedTask = taskRepo.merge(lockedTask, { - ...updateTaskDto, - order: newOrder, - }); - + const updatedTask = taskRepo.merge(lockedTask, updateTaskDto); return await taskRepo.save(updatedTask); }, ); } - // 3. Standard update (Title changes, description changes, etc. within the same column) const updatedTask = this.tasksRepository.merge(task, updateTaskDto); return await this.tasksRepository.save(updatedTask); }