From c325bdfee56a7db2d27acdcf04d9710f69b8abfe Mon Sep 17 00:00:00 2001 From: Maxim Date: Sat, 2 May 2026 23:17:37 +0300 Subject: [PATCH 01/12] feat(boards): implement initial boards module structure --- src/app.module.ts | 2 ++ src/boards/application/boards.facade.ts | 6 ++++++ .../application/controller/boards/controller.ts | 7 +++++++ .../application/controller/boards/swagger.ts | 0 src/boards/application/controller/index.ts | 1 + src/boards/application/dtos/boards.dto.ts | 1 + src/boards/application/dtos/index.ts | 1 + src/boards/application/mappers/boards.mapper.ts | 1 + src/boards/application/mappers/index.ts | 1 + src/boards/application/use-cases/index.ts | 2 ++ src/boards/boards.module.ts | 15 +++++++++++++++ src/boards/domain/entities/boards.domain.ts | 1 + src/boards/domain/entities/index.ts | 1 + src/boards/domain/policy/board-access.policy.ts | 4 ++++ src/boards/domain/policy/index.ts | 5 +++++ .../repository/boards.repository.interface.ts | 1 + src/boards/domain/repository/index.ts | 1 + src/boards/index.ts | 1 + .../persistence/models/boards.model.ts | 1 + .../infrastructure/persistence/models/index.ts | 1 + .../persistence/repositories/boards.repository.ts | 5 +++++ .../persistence/repositories/index.ts | 1 + 22 files changed, 59 insertions(+) create mode 100644 src/boards/application/boards.facade.ts create mode 100644 src/boards/application/controller/boards/controller.ts create mode 100644 src/boards/application/controller/boards/swagger.ts create mode 100644 src/boards/application/controller/index.ts create mode 100644 src/boards/application/dtos/boards.dto.ts create mode 100644 src/boards/application/dtos/index.ts create mode 100644 src/boards/application/mappers/boards.mapper.ts create mode 100644 src/boards/application/mappers/index.ts create mode 100644 src/boards/application/use-cases/index.ts create mode 100644 src/boards/boards.module.ts create mode 100644 src/boards/domain/entities/boards.domain.ts create mode 100644 src/boards/domain/entities/index.ts create mode 100644 src/boards/domain/policy/board-access.policy.ts create mode 100644 src/boards/domain/policy/index.ts create mode 100644 src/boards/domain/repository/boards.repository.interface.ts create mode 100644 src/boards/domain/repository/index.ts create mode 100644 src/boards/index.ts create mode 100644 src/boards/infrastructure/persistence/models/boards.model.ts create mode 100644 src/boards/infrastructure/persistence/models/index.ts create mode 100644 src/boards/infrastructure/persistence/repositories/boards.repository.ts create mode 100644 src/boards/infrastructure/persistence/repositories/index.ts diff --git a/src/app.module.ts b/src/app.module.ts index 24a612d..68a863f 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -16,6 +16,7 @@ import { BullModule } from '@nestjs/bullmq'; import { MailModule } from '@shared/adapters/mail'; import { TeamsModule } from './teams'; import { ProjectsModule } from './projects'; +import { BoardsModule } from './boards'; @Module({ imports: [ @@ -54,6 +55,7 @@ import { ProjectsModule } from './projects'; UserModule, TeamsModule, ProjectsModule, + BoardsModule, BullBoardModule.forRoot({ route: '/queues', adapter: FastifyAdapter, diff --git a/src/boards/application/boards.facade.ts b/src/boards/application/boards.facade.ts new file mode 100644 index 0000000..8903e6f --- /dev/null +++ b/src/boards/application/boards.facade.ts @@ -0,0 +1,6 @@ +import { Injectable } from '@nestjs/common'; + +@Injectable() +export class BoardsFacade { + constructor() {} +} diff --git a/src/boards/application/controller/boards/controller.ts b/src/boards/application/controller/boards/controller.ts new file mode 100644 index 0000000..c0b9853 --- /dev/null +++ b/src/boards/application/controller/boards/controller.ts @@ -0,0 +1,7 @@ +import { ApiBaseController } from '@shared/decorators'; +import { BoardsFacade } from '@core/boards/application/boards.facade'; + +@ApiBaseController('boards', 'Boards', true) +export class BoardsController { + constructor(private readonly facade: BoardsFacade) {} +} diff --git a/src/boards/application/controller/boards/swagger.ts b/src/boards/application/controller/boards/swagger.ts new file mode 100644 index 0000000..e69de29 diff --git a/src/boards/application/controller/index.ts b/src/boards/application/controller/index.ts new file mode 100644 index 0000000..7bffbdf --- /dev/null +++ b/src/boards/application/controller/index.ts @@ -0,0 +1 @@ +export { BoardsController } from './boards/controller'; diff --git a/src/boards/application/dtos/boards.dto.ts b/src/boards/application/dtos/boards.dto.ts new file mode 100644 index 0000000..830b79f --- /dev/null +++ b/src/boards/application/dtos/boards.dto.ts @@ -0,0 +1 @@ +export class BoardsDto {} diff --git a/src/boards/application/dtos/index.ts b/src/boards/application/dtos/index.ts new file mode 100644 index 0000000..851839c --- /dev/null +++ b/src/boards/application/dtos/index.ts @@ -0,0 +1 @@ +export * from './boards.dto'; diff --git a/src/boards/application/mappers/boards.mapper.ts b/src/boards/application/mappers/boards.mapper.ts new file mode 100644 index 0000000..dd130c3 --- /dev/null +++ b/src/boards/application/mappers/boards.mapper.ts @@ -0,0 +1 @@ +export class BoardsMapper {} diff --git a/src/boards/application/mappers/index.ts b/src/boards/application/mappers/index.ts new file mode 100644 index 0000000..c89e9d2 --- /dev/null +++ b/src/boards/application/mappers/index.ts @@ -0,0 +1 @@ +export { BoardsMapper } from './boards.mapper'; diff --git a/src/boards/application/use-cases/index.ts b/src/boards/application/use-cases/index.ts new file mode 100644 index 0000000..db3510f --- /dev/null +++ b/src/boards/application/use-cases/index.ts @@ -0,0 +1,2 @@ +export const BoardUseCases: unknown[] = []; +export const BoardQueries: unknown[] = []; diff --git a/src/boards/boards.module.ts b/src/boards/boards.module.ts new file mode 100644 index 0000000..7f553f0 --- /dev/null +++ b/src/boards/boards.module.ts @@ -0,0 +1,15 @@ +import { Module } from '@nestjs/common'; +import { BoardsRepository } from './infrastructure/persistence/repositories'; +import { BoardsController } from './application/controller'; +import { BoardsFacade } from './application/boards.facade'; + +const REPOSITORY = { + provide: 'IBoardsRepository', + useClass: BoardsRepository, +}; + +@Module({ + controllers: [BoardsController], + providers: [REPOSITORY, BoardsFacade], +}) +export class BoardsModule {} diff --git a/src/boards/domain/entities/boards.domain.ts b/src/boards/domain/entities/boards.domain.ts new file mode 100644 index 0000000..bbf82e7 --- /dev/null +++ b/src/boards/domain/entities/boards.domain.ts @@ -0,0 +1 @@ +export interface Board {} diff --git a/src/boards/domain/entities/index.ts b/src/boards/domain/entities/index.ts new file mode 100644 index 0000000..1da2d74 --- /dev/null +++ b/src/boards/domain/entities/index.ts @@ -0,0 +1 @@ +export * from './boards.domain'; diff --git a/src/boards/domain/policy/board-access.policy.ts b/src/boards/domain/policy/board-access.policy.ts new file mode 100644 index 0000000..0b584ae --- /dev/null +++ b/src/boards/domain/policy/board-access.policy.ts @@ -0,0 +1,4 @@ +import { Injectable } from '@nestjs/common'; + +@Injectable() +export class BoardAccessPolicy {} diff --git a/src/boards/domain/policy/index.ts b/src/boards/domain/policy/index.ts new file mode 100644 index 0000000..9186dac --- /dev/null +++ b/src/boards/domain/policy/index.ts @@ -0,0 +1,5 @@ +import { BoardAccessPolicy } from './board-access.policy'; + +export * from './board-access.policy'; + +export const POLICIES = [BoardAccessPolicy]; diff --git a/src/boards/domain/repository/boards.repository.interface.ts b/src/boards/domain/repository/boards.repository.interface.ts new file mode 100644 index 0000000..a305138 --- /dev/null +++ b/src/boards/domain/repository/boards.repository.interface.ts @@ -0,0 +1 @@ +export interface IBoardsRepository {} diff --git a/src/boards/domain/repository/index.ts b/src/boards/domain/repository/index.ts new file mode 100644 index 0000000..a28a6ac --- /dev/null +++ b/src/boards/domain/repository/index.ts @@ -0,0 +1 @@ +export * from './boards.repository.interface'; diff --git a/src/boards/index.ts b/src/boards/index.ts new file mode 100644 index 0000000..0d0f5fb --- /dev/null +++ b/src/boards/index.ts @@ -0,0 +1 @@ +export { BoardsModule } from './boards.module'; diff --git a/src/boards/infrastructure/persistence/models/boards.model.ts b/src/boards/infrastructure/persistence/models/boards.model.ts new file mode 100644 index 0000000..a80cca8 --- /dev/null +++ b/src/boards/infrastructure/persistence/models/boards.model.ts @@ -0,0 +1 @@ +export class BoardsModel {} diff --git a/src/boards/infrastructure/persistence/models/index.ts b/src/boards/infrastructure/persistence/models/index.ts new file mode 100644 index 0000000..bc6e8e7 --- /dev/null +++ b/src/boards/infrastructure/persistence/models/index.ts @@ -0,0 +1 @@ +export * from './boards.model'; diff --git a/src/boards/infrastructure/persistence/repositories/boards.repository.ts b/src/boards/infrastructure/persistence/repositories/boards.repository.ts new file mode 100644 index 0000000..78315ee --- /dev/null +++ b/src/boards/infrastructure/persistence/repositories/boards.repository.ts @@ -0,0 +1,5 @@ +import { Injectable } from '@nestjs/common'; +import { IBoardsRepository } from '../../../domain/repository'; + +@Injectable() +export class BoardsRepository implements IBoardsRepository {} diff --git a/src/boards/infrastructure/persistence/repositories/index.ts b/src/boards/infrastructure/persistence/repositories/index.ts new file mode 100644 index 0000000..4b4bb14 --- /dev/null +++ b/src/boards/infrastructure/persistence/repositories/index.ts @@ -0,0 +1 @@ +export { BoardsRepository } from './boards.repository'; From 8fe4b93f0ac51511ef6e40cc0903490f44309fe6 Mon Sep 17 00:00:00 2001 From: Maxim Date: Mon, 4 May 2026 20:57:32 +0300 Subject: [PATCH 02/12] feat(boards): add module skeleton with controller, facade and use-cases --- src/boards/application/boards.facade.ts | 35 +++++++- .../controller/boards/controller.ts | 47 ++++++++++- .../use-cases/create-board.use-case.ts | 14 ++++ .../use-cases/delete-board.use-case.ts | 14 ++++ .../application/use-cases/get-board.query.ts | 14 ++++ .../application/use-cases/get-boards.query.ts | 14 ++++ src/boards/application/use-cases/index.ts | 16 +++- .../use-cases/update-board.use-case.ts | 14 ++++ src/boards/boards.module.ts | 3 +- src/boards/domain/entities/boards.domain.ts | 12 ++- .../repository/boards.repository.interface.ts | 10 ++- .../persistence/models/boards.model.ts | 35 +++++++- .../repositories/boards.repository.ts | 81 ++++++++++++++++++- 13 files changed, 299 insertions(+), 10 deletions(-) create mode 100644 src/boards/application/use-cases/create-board.use-case.ts create mode 100644 src/boards/application/use-cases/delete-board.use-case.ts create mode 100644 src/boards/application/use-cases/get-board.query.ts create mode 100644 src/boards/application/use-cases/get-boards.query.ts create mode 100644 src/boards/application/use-cases/update-board.use-case.ts diff --git a/src/boards/application/boards.facade.ts b/src/boards/application/boards.facade.ts index 8903e6f..a25a3ed 100644 --- a/src/boards/application/boards.facade.ts +++ b/src/boards/application/boards.facade.ts @@ -1,6 +1,39 @@ import { Injectable } from '@nestjs/common'; +import { + CreateBoardUseCase, + DeleteBoardUseCase, + GetBoardQuery, + GetBoardsQuery, + UpdateBoardUseCase, +} from './use-cases'; @Injectable() export class BoardsFacade { - constructor() {} + constructor( + private readonly createBoardUC: CreateBoardUseCase, + private readonly updateBoardUC: UpdateBoardUseCase, + private readonly deleteBoardUC: DeleteBoardUseCase, + private readonly getBoardQ: GetBoardQuery, + private readonly getBoardsQ: GetBoardsQuery, + ) {} + + public async create(projectId: string, userId: string, dto: any) { + return this.createBoardUC.execute(projectId, userId, dto); + } + + public async update(id: string, projectId: string, userId: string, dto: any) { + return this.updateBoardUC.execute(id, projectId, userId, dto); + } + + public async delete(id: string, projectId: string, userId: string) { + return this.deleteBoardUC.execute(id, projectId, userId); + } + + public async getOne(id: string, projectId: string, userId: string) { + return this.getBoardQ.execute(id, projectId, userId); + } + + public async getAll(projectId: string, userId: string) { + return this.getBoardsQ.execute(projectId, userId); + } } diff --git a/src/boards/application/controller/boards/controller.ts b/src/boards/application/controller/boards/controller.ts index c0b9853..901b680 100644 --- a/src/boards/application/controller/boards/controller.ts +++ b/src/boards/application/controller/boards/controller.ts @@ -1,7 +1,50 @@ -import { ApiBaseController } from '@shared/decorators'; +import { ApiBaseController, GetUserId } from '@shared/decorators'; import { BoardsFacade } from '@core/boards/application/boards.facade'; +import { Body, Delete, Get, Param, Patch, Post } from '@nestjs/common'; -@ApiBaseController('boards', 'Boards', true) +@ApiBaseController('projects/:projectId/boards', 'Boards', true) export class BoardsController { constructor(private readonly facade: BoardsFacade) {} + + @Get() + async findAll(@Param('projectId') projectId: string, @GetUserId() userId: string) { + return this.facade.getAll(projectId, userId); + } + + @Get(':id') + async findOne( + @Param('id') id: string, + @Param('projectId') projectId: string, + @GetUserId() userId: string, + ) { + return this.facade.getOne(id, projectId, userId); + } + + @Post() + async create( + @Param('projectId') projectId: string, + @GetUserId() userId: string, + @Body() dto: any, + ) { + return this.facade.create(projectId, userId, dto); + } + + @Patch(':id') + async update( + @Param('id') id: string, + @Param('projectId') projectId: string, + @GetUserId() userId: string, + @Body() dto: any, + ) { + return this.facade.update(id, projectId, userId, dto); + } + + @Delete(':id') + async remove( + @Param('id') id: string, + @Param('projectId') projectId: string, + @GetUserId() userId: string, + ) { + return this.facade.delete(id, projectId, userId); + } } diff --git a/src/boards/application/use-cases/create-board.use-case.ts b/src/boards/application/use-cases/create-board.use-case.ts new file mode 100644 index 0000000..bf88b28 --- /dev/null +++ b/src/boards/application/use-cases/create-board.use-case.ts @@ -0,0 +1,14 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { IBoardsRepository } from '@core/boards/domain/repository'; + +@Injectable() +export class CreateBoardUseCase { + constructor( + @Inject('IBoardsRepository') + private readonly boardsRepo: IBoardsRepository, + ) {} + + public async execute(_projectId: string, _userId: string, dto: any) { + return this.boardsRepo.create(dto); + } +} diff --git a/src/boards/application/use-cases/delete-board.use-case.ts b/src/boards/application/use-cases/delete-board.use-case.ts new file mode 100644 index 0000000..823a502 --- /dev/null +++ b/src/boards/application/use-cases/delete-board.use-case.ts @@ -0,0 +1,14 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { IBoardsRepository } from '@core/boards/domain/repository'; + +@Injectable() +export class DeleteBoardUseCase { + constructor( + @Inject('IBoardsRepository') + private readonly boardsRepo: IBoardsRepository, + ) {} + + public async execute(id: string, _projectId: string, _userId: string) { + return this.boardsRepo.remove(id); + } +} diff --git a/src/boards/application/use-cases/get-board.query.ts b/src/boards/application/use-cases/get-board.query.ts new file mode 100644 index 0000000..6678062 --- /dev/null +++ b/src/boards/application/use-cases/get-board.query.ts @@ -0,0 +1,14 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { IBoardsRepository } from '@core/boards/domain/repository'; + +@Injectable() +export class GetBoardQuery { + constructor( + @Inject('IBoardsRepository') + private readonly boardsRepo: IBoardsRepository, + ) {} + + public async execute(id: string, _projectId: string, _userId: string) { + return await this.boardsRepo.findById(id); + } +} diff --git a/src/boards/application/use-cases/get-boards.query.ts b/src/boards/application/use-cases/get-boards.query.ts new file mode 100644 index 0000000..94509a1 --- /dev/null +++ b/src/boards/application/use-cases/get-boards.query.ts @@ -0,0 +1,14 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { IBoardsRepository } from '@core/boards/domain/repository'; + +@Injectable() +export class GetBoardsQuery { + constructor( + @Inject('IBoardsRepository') + private readonly boardsRepo: IBoardsRepository, + ) {} + + public async execute(projectId: string, _userId: string) { + return this.boardsRepo.findAll(projectId); + } +} diff --git a/src/boards/application/use-cases/index.ts b/src/boards/application/use-cases/index.ts index db3510f..ccf3d2a 100644 --- a/src/boards/application/use-cases/index.ts +++ b/src/boards/application/use-cases/index.ts @@ -1,2 +1,14 @@ -export const BoardUseCases: unknown[] = []; -export const BoardQueries: unknown[] = []; +import { CreateBoardUseCase } from './create-board.use-case'; +import { UpdateBoardUseCase } from './update-board.use-case'; +import { DeleteBoardUseCase } from './delete-board.use-case'; +import { GetBoardQuery } from './get-board.query'; +import { GetBoardsQuery } from './get-boards.query'; + +export * from './create-board.use-case'; +export * from './update-board.use-case'; +export * from './delete-board.use-case'; +export * from './get-board.query'; +export * from './get-boards.query'; + +export const BoardUseCases = [CreateBoardUseCase, UpdateBoardUseCase, DeleteBoardUseCase]; +export const BoardQueries = [GetBoardQuery, GetBoardsQuery]; diff --git a/src/boards/application/use-cases/update-board.use-case.ts b/src/boards/application/use-cases/update-board.use-case.ts new file mode 100644 index 0000000..eff48f9 --- /dev/null +++ b/src/boards/application/use-cases/update-board.use-case.ts @@ -0,0 +1,14 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { IBoardsRepository } from '@core/boards/domain/repository'; + +@Injectable() +export class UpdateBoardUseCase { + constructor( + @Inject('IBoardsRepository') + private readonly boardsRepo: IBoardsRepository, + ) {} + + public async execute(id: string, _projectId: string, _userId: string, dto: any) { + return await this.boardsRepo.update(id, dto); + } +} diff --git a/src/boards/boards.module.ts b/src/boards/boards.module.ts index 7f553f0..7e4343a 100644 --- a/src/boards/boards.module.ts +++ b/src/boards/boards.module.ts @@ -2,6 +2,7 @@ import { Module } from '@nestjs/common'; import { BoardsRepository } from './infrastructure/persistence/repositories'; import { BoardsController } from './application/controller'; import { BoardsFacade } from './application/boards.facade'; +import { BoardQueries, BoardUseCases } from './application/use-cases'; const REPOSITORY = { provide: 'IBoardsRepository', @@ -10,6 +11,6 @@ const REPOSITORY = { @Module({ controllers: [BoardsController], - providers: [REPOSITORY, BoardsFacade], + providers: [REPOSITORY, BoardsFacade, ...BoardUseCases, ...BoardQueries], }) export class BoardsModule {} diff --git a/src/boards/domain/entities/boards.domain.ts b/src/boards/domain/entities/boards.domain.ts index bbf82e7..d281818 100644 --- a/src/boards/domain/entities/boards.domain.ts +++ b/src/boards/domain/entities/boards.domain.ts @@ -1 +1,11 @@ -export interface Board {} +import type { InferInsertModel, InferSelectModel } from 'drizzle-orm'; +import { boards } from '@core/boards/infrastructure/persistence/models/boards.model'; + +export enum BoardType { + Kanban = 'kanban', + Calendar = 'calendar', + Matrix = 'matrix', +} + +export type Board = InferSelectModel; +export type NewBoard = InferInsertModel; diff --git a/src/boards/domain/repository/boards.repository.interface.ts b/src/boards/domain/repository/boards.repository.interface.ts index a305138..b9b2c75 100644 --- a/src/boards/domain/repository/boards.repository.interface.ts +++ b/src/boards/domain/repository/boards.repository.interface.ts @@ -1 +1,9 @@ -export interface IBoardsRepository {} +import { Board, NewBoard } from '@core/boards/domain/entities'; + +export interface IBoardsRepository { + findAll(projectId: string): Promise; + findById(id: string): Promise; + create(data: NewBoard): Promise; + update(id: string, data: Partial): Promise; + remove(id: string): Promise; +} diff --git a/src/boards/infrastructure/persistence/models/boards.model.ts b/src/boards/infrastructure/persistence/models/boards.model.ts index a80cca8..ddf87be 100644 --- a/src/boards/infrastructure/persistence/models/boards.model.ts +++ b/src/boards/infrastructure/persistence/models/boards.model.ts @@ -1 +1,34 @@ -export class BoardsModel {} +import { text, varchar, timestamp, jsonb } from 'drizzle-orm/pg-core'; +import { baseSchema, projects, users } from '@shared/entities'; +import { createId } from '@paralleldrive/cuid2'; + +type KanbanSettings = { + mock: boolean; +}; + +type CalendarSettings = { + mock: boolean; +}; + +type MatrixSettings = { + mock: boolean; +}; + +export const boardTypeEnum = baseSchema.enum('board_type', ['kanban', 'calendar', 'matrix']); + +export const boards = baseSchema.table('boards', { + id: text('id') + .primaryKey() + .$defaultFn(() => createId()), + name: varchar('name', { length: 100 }).notNull(), + type: boardTypeEnum('type').default('kanban').notNull(), + projectId: text('project_id') + .references(() => projects.id, { onDelete: 'cascade' }) + .notNull(), + settings: jsonb('settings') + .$type() + .notNull(), + ownerId: text('owner_id').references(() => users.id, { onDelete: 'set null' }), + createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(), + updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(), +}); diff --git a/src/boards/infrastructure/persistence/repositories/boards.repository.ts b/src/boards/infrastructure/persistence/repositories/boards.repository.ts index 78315ee..c651bf5 100644 --- a/src/boards/infrastructure/persistence/repositories/boards.repository.ts +++ b/src/boards/infrastructure/persistence/repositories/boards.repository.ts @@ -1,5 +1,84 @@ import { Injectable } from '@nestjs/common'; import { IBoardsRepository } from '../../../domain/repository'; +import { Board, NewBoard } from '@core/boards/domain/entities'; @Injectable() -export class BoardsRepository implements IBoardsRepository {} +export class BoardsRepository implements IBoardsRepository { + async findAll(_projectId: string): Promise { + return [ + { + id: '1', + name: 'mockBoard1', + projectId: 'projectId-1', + settings: { + mock: true, + }, + ownerId: 'userId-1', + createdAt: new Date(), + updatedAt: new Date(), + type: 'kanban', + }, + { + id: '2', + name: 'mockBoard2', + projectId: 'projectId-2', + settings: { + mock: true, + }, + ownerId: 'userId-1', + createdAt: new Date(), + updatedAt: new Date(), + type: 'kanban', + }, + ]; + } + + async findById(_id: string): Promise { + return { + id: '1', + name: 'mockBoard1', + projectId: 'projectId-1', + settings: { + mock: true, + }, + ownerId: 'userId-1', + createdAt: new Date(), + updatedAt: new Date(), + type: 'kanban', + }; + } + + async create(_data: NewBoard): Promise { + return { + id: '1', + name: 'mockBoard1', + projectId: 'projectId-1', + settings: { + mock: true, + }, + ownerId: 'userId-1', + createdAt: new Date(), + updatedAt: new Date(), + type: 'kanban', + }; + } + + async update(_id: string, _data: Partial): Promise { + return { + id: '1', + name: 'mockBoard1', + projectId: 'projectId-1', + settings: { + mock: true, + }, + ownerId: 'userId-1', + createdAt: new Date(), + updatedAt: new Date(), + type: 'kanban', + }; + } + + async remove(_id: string): Promise { + return true; + } +} From ff06db07fcff94be052dfe21aee4f9488c83d720 Mon Sep 17 00:00:00 2001 From: Maxim Date: Wed, 6 May 2026 16:52:16 +0300 Subject: [PATCH 03/12] feat(boards): implement board factory, repository, and controller for board management --- src/boards/application/boards.facade.ts | 25 +- .../controller/boards/controller.ts | 19 +- src/boards/application/dtos/boards.dto.ts | 24 +- .../use-cases/create-board.use-case.ts | 13 +- .../use-cases/delete-board.use-case.ts | 2 +- .../application/use-cases/get-board.query.ts | 7 +- .../application/use-cases/get-boards.query.ts | 3 +- .../use-cases/update-board.use-case.ts | 9 +- src/boards/domain/entities/boards.domain.ts | 24 +- src/boards/domain/factories/board.factory.ts | 80 +++++++ .../repository/boards.repository.interface.ts | 20 +- .../persistence/models/boards.model.ts | 57 +++-- .../repositories/boards.repository.ts | 217 ++++++++++++------ src/shared/entities/index.ts | 1 + 14 files changed, 385 insertions(+), 116 deletions(-) create mode 100644 src/boards/domain/factories/board.factory.ts diff --git a/src/boards/application/boards.facade.ts b/src/boards/application/boards.facade.ts index a25a3ed..e99aad9 100644 --- a/src/boards/application/boards.facade.ts +++ b/src/boards/application/boards.facade.ts @@ -6,6 +6,8 @@ import { GetBoardsQuery, UpdateBoardUseCase, } from './use-cases'; +import { CreateBoardDto, UpdateBoardDto } from './dtos'; +import type { BoardWithRelations } from '@core/boards/domain/entities'; @Injectable() export class BoardsFacade { @@ -17,23 +19,36 @@ export class BoardsFacade { private readonly getBoardsQ: GetBoardsQuery, ) {} - public async create(projectId: string, userId: string, dto: any) { + public async create( + projectId: string, + userId: string, + dto: CreateBoardDto, + ): Promise { return this.createBoardUC.execute(projectId, userId, dto); } - public async update(id: string, projectId: string, userId: string, dto: any) { + public async update( + id: string, + projectId: string, + userId: string, + dto: UpdateBoardDto, + ): Promise { return this.updateBoardUC.execute(id, projectId, userId, dto); } - public async delete(id: string, projectId: string, userId: string) { + public async delete(id: string, projectId: string, userId: string): Promise { return this.deleteBoardUC.execute(id, projectId, userId); } - public async getOne(id: string, projectId: string, userId: string) { + public async getOne( + id: string, + projectId: string, + userId: string, + ): Promise { return this.getBoardQ.execute(id, projectId, userId); } - public async getAll(projectId: string, userId: string) { + public async getAll(projectId: string, userId: string): Promise { return this.getBoardsQ.execute(projectId, userId); } } diff --git a/src/boards/application/controller/boards/controller.ts b/src/boards/application/controller/boards/controller.ts index 901b680..2267109 100644 --- a/src/boards/application/controller/boards/controller.ts +++ b/src/boards/application/controller/boards/controller.ts @@ -1,13 +1,18 @@ import { ApiBaseController, GetUserId } from '@shared/decorators'; import { BoardsFacade } from '@core/boards/application/boards.facade'; import { Body, Delete, Get, Param, Patch, Post } from '@nestjs/common'; +import { CreateBoardDto, UpdateBoardDto } from '@core/boards/application/dtos'; +import type { BoardWithRelations } from '@core/boards/domain/entities'; @ApiBaseController('projects/:projectId/boards', 'Boards', true) export class BoardsController { constructor(private readonly facade: BoardsFacade) {} @Get() - async findAll(@Param('projectId') projectId: string, @GetUserId() userId: string) { + async findAll( + @Param('projectId') projectId: string, + @GetUserId() userId: string, + ): Promise { return this.facade.getAll(projectId, userId); } @@ -16,7 +21,7 @@ export class BoardsController { @Param('id') id: string, @Param('projectId') projectId: string, @GetUserId() userId: string, - ) { + ): Promise { return this.facade.getOne(id, projectId, userId); } @@ -24,8 +29,8 @@ export class BoardsController { async create( @Param('projectId') projectId: string, @GetUserId() userId: string, - @Body() dto: any, - ) { + @Body() dto: CreateBoardDto, + ): Promise { return this.facade.create(projectId, userId, dto); } @@ -34,8 +39,8 @@ export class BoardsController { @Param('id') id: string, @Param('projectId') projectId: string, @GetUserId() userId: string, - @Body() dto: any, - ) { + @Body() dto: UpdateBoardDto, + ): Promise { return this.facade.update(id, projectId, userId, dto); } @@ -44,7 +49,7 @@ export class BoardsController { @Param('id') id: string, @Param('projectId') projectId: string, @GetUserId() userId: string, - ) { + ): Promise { return this.facade.delete(id, projectId, userId); } } diff --git a/src/boards/application/dtos/boards.dto.ts b/src/boards/application/dtos/boards.dto.ts index 830b79f..52a053f 100644 --- a/src/boards/application/dtos/boards.dto.ts +++ b/src/boards/application/dtos/boards.dto.ts @@ -1 +1,23 @@ -export class BoardsDto {} +import { z } from 'zod/v4'; +import { createZodDto } from 'nestjs-zod'; + +export const CreateBoardSchema = z.object({ + name: z + .string() + .min(1, 'Название доски не может быть пустым') + .max(100, 'Название доски не должно превышать 100 символов'), + position: z.number().finite().optional(), + settings: z.record(z.string(), z.unknown()).optional(), +}); + +export class CreateBoardDto extends createZodDto(CreateBoardSchema) {} + +export const UpdateBoardSchema = CreateBoardSchema.partial().refine( + (data) => Object.keys(data).length > 0, + { + error: 'Необходимо передать хотя бы одно поле для обновления', + abort: true, + }, +); + +export class UpdateBoardDto extends createZodDto(UpdateBoardSchema) {} diff --git a/src/boards/application/use-cases/create-board.use-case.ts b/src/boards/application/use-cases/create-board.use-case.ts index bf88b28..0c12a2e 100644 --- a/src/boards/application/use-cases/create-board.use-case.ts +++ b/src/boards/application/use-cases/create-board.use-case.ts @@ -1,5 +1,8 @@ import { Inject, Injectable } from '@nestjs/common'; import { IBoardsRepository } from '@core/boards/domain/repository'; +import { CreateBoardDto } from '@core/boards/application/dtos'; +import { BoardFactory } from '@core/boards/domain/factories/board.factory'; +import type { BoardWithRelations } from '@core/boards/domain/entities'; @Injectable() export class CreateBoardUseCase { @@ -8,7 +11,13 @@ export class CreateBoardUseCase { private readonly boardsRepo: IBoardsRepository, ) {} - public async execute(_projectId: string, _userId: string, dto: any) { - return this.boardsRepo.create(dto); + public async execute( + projectId: string, + userId: string, + dto: CreateBoardDto, + ): Promise { + const { board, columns, views } = BoardFactory.createBoard(projectId, userId, dto); + + return this.boardsRepo.create(board, columns, views); } } diff --git a/src/boards/application/use-cases/delete-board.use-case.ts b/src/boards/application/use-cases/delete-board.use-case.ts index 823a502..80ad357 100644 --- a/src/boards/application/use-cases/delete-board.use-case.ts +++ b/src/boards/application/use-cases/delete-board.use-case.ts @@ -8,7 +8,7 @@ export class DeleteBoardUseCase { private readonly boardsRepo: IBoardsRepository, ) {} - public async execute(id: string, _projectId: string, _userId: string) { + public async execute(id: string, _projectId: string, _userId: string): Promise { return this.boardsRepo.remove(id); } } diff --git a/src/boards/application/use-cases/get-board.query.ts b/src/boards/application/use-cases/get-board.query.ts index 6678062..88cd30f 100644 --- a/src/boards/application/use-cases/get-board.query.ts +++ b/src/boards/application/use-cases/get-board.query.ts @@ -1,5 +1,6 @@ import { Inject, Injectable } from '@nestjs/common'; import { IBoardsRepository } from '@core/boards/domain/repository'; +import type { BoardWithRelations } from '@core/boards/domain/entities'; @Injectable() export class GetBoardQuery { @@ -8,7 +9,11 @@ export class GetBoardQuery { private readonly boardsRepo: IBoardsRepository, ) {} - public async execute(id: string, _projectId: string, _userId: string) { + public async execute( + id: string, + _projectId: string, + _userId: string, + ): Promise { return await this.boardsRepo.findById(id); } } diff --git a/src/boards/application/use-cases/get-boards.query.ts b/src/boards/application/use-cases/get-boards.query.ts index 94509a1..7669e4b 100644 --- a/src/boards/application/use-cases/get-boards.query.ts +++ b/src/boards/application/use-cases/get-boards.query.ts @@ -1,5 +1,6 @@ import { Inject, Injectable } from '@nestjs/common'; import { IBoardsRepository } from '@core/boards/domain/repository'; +import type { BoardWithRelations } from '@core/boards/domain/entities'; @Injectable() export class GetBoardsQuery { @@ -8,7 +9,7 @@ export class GetBoardsQuery { private readonly boardsRepo: IBoardsRepository, ) {} - public async execute(projectId: string, _userId: string) { + public async execute(projectId: string, _userId: string): Promise { return this.boardsRepo.findAll(projectId); } } diff --git a/src/boards/application/use-cases/update-board.use-case.ts b/src/boards/application/use-cases/update-board.use-case.ts index eff48f9..9020f0f 100644 --- a/src/boards/application/use-cases/update-board.use-case.ts +++ b/src/boards/application/use-cases/update-board.use-case.ts @@ -1,5 +1,7 @@ import { Inject, Injectable } from '@nestjs/common'; import { IBoardsRepository } from '@core/boards/domain/repository'; +import { UpdateBoardDto } from '@core/boards/application/dtos'; +import type { BoardWithRelations } from '@core/boards/domain/entities'; @Injectable() export class UpdateBoardUseCase { @@ -8,7 +10,12 @@ export class UpdateBoardUseCase { private readonly boardsRepo: IBoardsRepository, ) {} - public async execute(id: string, _projectId: string, _userId: string, dto: any) { + public async execute( + id: string, + _projectId: string, + _userId: string, + dto: UpdateBoardDto, + ): Promise { return await this.boardsRepo.update(id, dto); } } diff --git a/src/boards/domain/entities/boards.domain.ts b/src/boards/domain/entities/boards.domain.ts index d281818..8c79972 100644 --- a/src/boards/domain/entities/boards.domain.ts +++ b/src/boards/domain/entities/boards.domain.ts @@ -1,11 +1,23 @@ import type { InferInsertModel, InferSelectModel } from 'drizzle-orm'; -import { boards } from '@core/boards/infrastructure/persistence/models/boards.model'; +import { + boards, + boardViews, + boardTypeEnum, + boardColumns, +} from '@core/boards/infrastructure/persistence/models/boards.model'; -export enum BoardType { - Kanban = 'kanban', - Calendar = 'calendar', - Matrix = 'matrix', -} +export type BoardType = (typeof boardTypeEnum.enumValues)[number]; export type Board = InferSelectModel; export type NewBoard = InferInsertModel; + +export type BoardColumn = InferSelectModel; +export type NewBoardColumn = InferInsertModel; + +export type BoardView = InferSelectModel; +export type NewBoardView = InferInsertModel; + +export type BoardWithRelations = Board & { + boardColumns: BoardColumn[]; + boardViews: BoardView[]; +}; diff --git a/src/boards/domain/factories/board.factory.ts b/src/boards/domain/factories/board.factory.ts new file mode 100644 index 0000000..a91e1ad --- /dev/null +++ b/src/boards/domain/factories/board.factory.ts @@ -0,0 +1,80 @@ +import { BoardType, NewBoard, NewBoardColumn, NewBoardView } from '@core/boards/domain/entities'; +import { createId } from '@paralleldrive/cuid2'; +import { CreateBoardDto } from '@core/boards/application/dtos'; + +export class BoardFactory { + static createView(props: { + boardId: string; + type: BoardType; + name?: string; + position: number; + settings?: Record; + }): NewBoardView { + return { + id: createId(), + boardId: props.boardId, + type: props.type, + name: props.name ?? this.getDefaultViewName(props.type), + position: props.position, + settings: props.settings ?? this.getDefaultSettings(props.type), + }; + } + + static createBoard( + projectId: string, + ownerId: string, + dto: CreateBoardDto, + ): { board: NewBoard; columns: NewBoardColumn[]; views: NewBoardView[] } { + const boardId = createId(); + const boardPosition = dto.position ?? Date.now(); + const board: NewBoard = { + id: boardId, + name: dto.name, + projectId, + ownerId, + position: boardPosition, + settings: dto.settings ?? {}, + }; + + const defaultViewTypes: BoardType[] = ['kanban', 'calendar', 'gantt_matrix']; + + const views = defaultViewTypes.map((type, index) => + this.createView({ + boardId, + type, + position: (index + 1) * 1000, + }), + ); + + const columns = [ + { id: createId(), boardId, name: 'К выполнению', position: 1000, color: '#64748b' }, + { id: createId(), boardId, name: 'В работе', position: 2000, color: '#3b82f6' }, + { id: createId(), boardId, name: 'Готово', position: 3000, color: '#22c55e' }, + ]; + + return { board, columns, views }; + } + + private static getDefaultViewName(type: BoardType): string { + const names: Record = { + kanban: 'Доска', + calendar: 'Календарь', + gantt_matrix: 'Гант', + }; + return names[type]; + } + + private static getDefaultSettings(type: BoardType): Record { + switch (type) { + case 'kanban': + return { mock: 'kanban_mock_setting' }; + case 'calendar': + return { mock: 'calendar_mock_setting' }; + case 'gantt_matrix': + return { mock: 'gantt_mock_setting' }; + default: + const exhaustiveCheck: never = type; + return exhaustiveCheck; + } + } +} diff --git a/src/boards/domain/repository/boards.repository.interface.ts b/src/boards/domain/repository/boards.repository.interface.ts index b9b2c75..856ffd5 100644 --- a/src/boards/domain/repository/boards.repository.interface.ts +++ b/src/boards/domain/repository/boards.repository.interface.ts @@ -1,9 +1,19 @@ -import { Board, NewBoard } from '@core/boards/domain/entities'; +import { + Board, + BoardWithRelations, + NewBoard, + NewBoardColumn, + NewBoardView, +} from '@core/boards/domain/entities'; export interface IBoardsRepository { - findAll(projectId: string): Promise; - findById(id: string): Promise; - create(data: NewBoard): Promise; - update(id: string, data: Partial): Promise; + findAll(projectId: string): Promise; + findById(id: string): Promise; + create( + board: NewBoard, + columns: NewBoardColumn[], + views: NewBoardView[], + ): Promise; + update(id: string, data: Partial): Promise; remove(id: string): Promise; } diff --git a/src/boards/infrastructure/persistence/models/boards.model.ts b/src/boards/infrastructure/persistence/models/boards.model.ts index ddf87be..5fb9e9d 100644 --- a/src/boards/infrastructure/persistence/models/boards.model.ts +++ b/src/boards/infrastructure/persistence/models/boards.model.ts @@ -1,34 +1,51 @@ -import { text, varchar, timestamp, jsonb } from 'drizzle-orm/pg-core'; +import { text, varchar, timestamp, jsonb, doublePrecision } from 'drizzle-orm/pg-core'; import { baseSchema, projects, users } from '@shared/entities'; import { createId } from '@paralleldrive/cuid2'; -type KanbanSettings = { - mock: boolean; -}; - -type CalendarSettings = { - mock: boolean; -}; - -type MatrixSettings = { - mock: boolean; -}; - -export const boardTypeEnum = baseSchema.enum('board_type', ['kanban', 'calendar', 'matrix']); +export const boardTypeEnum = baseSchema.enum('board_type', ['kanban', 'calendar', 'gantt_matrix']); export const boards = baseSchema.table('boards', { id: text('id') .primaryKey() .$defaultFn(() => createId()), name: varchar('name', { length: 100 }).notNull(), - type: boardTypeEnum('type').default('kanban').notNull(), projectId: text('project_id') .references(() => projects.id, { onDelete: 'cascade' }) .notNull(), - settings: jsonb('settings') - .$type() - .notNull(), + settings: jsonb('settings').default({}).notNull(), + position: doublePrecision('position').notNull(), ownerId: text('owner_id').references(() => users.id, { onDelete: 'set null' }), - createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(), - updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(), + createdAt: timestamp('created_at').defaultNow().notNull(), + updatedAt: timestamp('updated_at').defaultNow().notNull(), +}); + +export const boardColumns = baseSchema.table('board_columns', { + id: text('id') + .primaryKey() + .$defaultFn(() => createId()), + boardId: text('board_id') + .references(() => boards.id, { onDelete: 'cascade' }) + .notNull(), + name: varchar('name', { length: 50 }).notNull(), + position: doublePrecision('position').notNull(), + + color: varchar('color', { length: 7 }).default('#64748b').notNull(), + + createdAt: timestamp('created_at').defaultNow().notNull(), + updatedAt: timestamp('updated_at').defaultNow().notNull(), +}); + +export const boardViews = baseSchema.table('boards_views', { + id: text('id') + .primaryKey() + .$defaultFn(() => createId()), + boardId: text('board_id') + .references(() => boards.id, { onDelete: 'cascade' }) + .notNull(), + type: boardTypeEnum('type').default('kanban').notNull(), + name: varchar('name', { length: 100 }).notNull(), + settings: jsonb('settings').default({}).notNull(), + position: doublePrecision('position').notNull(), + createdAt: timestamp('created_at').defaultNow().notNull(), + updatedAt: timestamp('updated_at').defaultNow().notNull(), }); diff --git a/src/boards/infrastructure/persistence/repositories/boards.repository.ts b/src/boards/infrastructure/persistence/repositories/boards.repository.ts index c651bf5..f7d4af2 100644 --- a/src/boards/infrastructure/persistence/repositories/boards.repository.ts +++ b/src/boards/infrastructure/persistence/repositories/boards.repository.ts @@ -1,84 +1,169 @@ -import { Injectable } from '@nestjs/common'; +import { Inject, Injectable } from '@nestjs/common'; import { IBoardsRepository } from '../../../domain/repository'; -import { Board, NewBoard } from '@core/boards/domain/entities'; +import { + Board, + BoardColumn, + BoardView, + BoardWithRelations, + NewBoard, + NewBoardColumn, + NewBoardView, +} from '@core/boards/domain/entities'; +import { DATABASE_SERVICE, DatabaseService } from '@libs/database'; +import * as schema from '../models'; +import { asc, eq, inArray } from 'drizzle-orm'; @Injectable() export class BoardsRepository implements IBoardsRepository { - async findAll(_projectId: string): Promise { - return [ - { - id: '1', - name: 'mockBoard1', - projectId: 'projectId-1', - settings: { - mock: true, - }, - ownerId: 'userId-1', - createdAt: new Date(), - updatedAt: new Date(), - type: 'kanban', - }, - { - id: '2', - name: 'mockBoard2', - projectId: 'projectId-2', - settings: { - mock: true, - }, - ownerId: 'userId-1', - createdAt: new Date(), - updatedAt: new Date(), - type: 'kanban', - }, - ]; + constructor( + @Inject(DATABASE_SERVICE) + private readonly db: DatabaseService, + ) {} + + async findAll(projectId: string): Promise { + const boards = await this.db + .select() + .from(schema.boards) + .where(eq(schema.boards.projectId, projectId)) + .orderBy(asc(schema.boards.position)); + + if (boards.length === 0) { + return []; + } + + const boardIds = boards.map((board) => board.id); + const [columns, views] = await Promise.all([ + this.db + .select() + .from(schema.boardColumns) + .where(inArray(schema.boardColumns.boardId, boardIds)) + .orderBy(asc(schema.boardColumns.position)), + this.db + .select() + .from(schema.boardViews) + .where(inArray(schema.boardViews.boardId, boardIds)) + .orderBy(asc(schema.boardViews.position)), + ]); + + const columnsByBoardId = this.groupByBoardId(columns); + const viewsByBoardId = this.groupByBoardId(views); + + return boards.map((board) => ({ + ...board, + boardColumns: columnsByBoardId.get(board.id) ?? [], + boardViews: viewsByBoardId.get(board.id) ?? [], + })); } - async findById(_id: string): Promise { + async findById(id: string): Promise { + const [board] = await this.db.select().from(schema.boards).where(eq(schema.boards.id, id)); + + if (!board) { + return null; + } + + const [boardColumns, boardViews] = await Promise.all([ + this.db + .select() + .from(schema.boardColumns) + .where(eq(schema.boardColumns.boardId, id)) + .orderBy(asc(schema.boardColumns.position)), + this.db + .select() + .from(schema.boardViews) + .where(eq(schema.boardViews.boardId, id)) + .orderBy(asc(schema.boardViews.position)), + ]); + return { - id: '1', - name: 'mockBoard1', - projectId: 'projectId-1', - settings: { - mock: true, - }, - ownerId: 'userId-1', - createdAt: new Date(), - updatedAt: new Date(), - type: 'kanban', + ...board, + boardColumns, + boardViews, }; } - async create(_data: NewBoard): Promise { - return { - id: '1', - name: 'mockBoard1', - projectId: 'projectId-1', - settings: { - mock: true, - }, - ownerId: 'userId-1', - createdAt: new Date(), - updatedAt: new Date(), - type: 'kanban', - }; + async create( + board: NewBoard, + columns: NewBoardColumn[], + views: NewBoardView[], + ): Promise { + return await this.db.transaction(async (tx) => { + const [newBoard] = await tx + .insert(schema.boards) + .values({ + id: board.id, + name: board.name, + projectId: board.projectId, + ownerId: board.ownerId, + position: board.position, + settings: board.settings, + }) + .returning(); + + const boardViews = await tx.insert(schema.boardViews).values(views).returning(); + + const boardColumns = await tx.insert(schema.boardColumns).values(columns).returning(); + + return { + ...newBoard, + boardViews, + boardColumns, + }; + }); } - async update(_id: string, _data: Partial): Promise { + async update(id: string, data: Partial): Promise { + const [updated] = await this.db + .update(schema.boards) + .set({ ...data, updatedAt: new Date() }) + .where(eq(schema.boards.id, id)) + .returning(); + + if (!updated) { + return null; + } + + const [boardColumns, boardViews] = await Promise.all([ + this.db + .select() + .from(schema.boardColumns) + .where(eq(schema.boardColumns.boardId, id)) + .orderBy(asc(schema.boardColumns.position)), + this.db + .select() + .from(schema.boardViews) + .where(eq(schema.boardViews.boardId, id)) + .orderBy(asc(schema.boardViews.position)), + ]); + return { - id: '1', - name: 'mockBoard1', - projectId: 'projectId-1', - settings: { - mock: true, - }, - ownerId: 'userId-1', - createdAt: new Date(), - updatedAt: new Date(), - type: 'kanban', + ...updated, + boardColumns, + boardViews, }; } - async remove(_id: string): Promise { - return true; + async remove(id: string): Promise { + const result = await this.db + .delete(schema.boards) + .where(eq(schema.boards.id, id)) + .returning({ id: schema.boards.id }); + + return result.length > 0; + } + + private groupByBoardId(items: T[]): Map { + const grouped = new Map(); + + for (const item of items) { + const current = grouped.get(item.boardId); + if (current) { + current.push(item); + } else { + grouped.set(item.boardId, [item]); + } + } + + return grouped; } } diff --git a/src/shared/entities/index.ts b/src/shared/entities/index.ts index b50a6a2..524e3f8 100644 --- a/src/shared/entities/index.ts +++ b/src/shared/entities/index.ts @@ -3,3 +3,4 @@ export * from '../../user/infrastructure/persistence/models'; export * from '../../auth/infrastructure/persistence/models'; export * from '../../teams/infrastructure/persistence/models'; export * from '../../projects/infrastructure/persistence/models'; +export * from '../../boards/infrastructure/persistence/models'; From ee148bc22162aceecbed68460e2c51b0689868d1 Mon Sep 17 00:00:00 2001 From: Maxim Date: Wed, 6 May 2026 19:55:24 +0300 Subject: [PATCH 04/12] feat(boards): add swagger docs and add unique index for board names --- .../controller/boards/controller.ts | 12 +++ .../application/controller/boards/swagger.ts | 79 +++++++++++++++++++ src/boards/application/dtos/boards.dto.ts | 41 ++++++++++ .../persistence/models/boards.model.ts | 36 +++++---- 4 files changed, 153 insertions(+), 15 deletions(-) diff --git a/src/boards/application/controller/boards/controller.ts b/src/boards/application/controller/boards/controller.ts index 2267109..7175d47 100644 --- a/src/boards/application/controller/boards/controller.ts +++ b/src/boards/application/controller/boards/controller.ts @@ -3,12 +3,20 @@ import { BoardsFacade } from '@core/boards/application/boards.facade'; import { Body, Delete, Get, Param, Patch, Post } from '@nestjs/common'; import { CreateBoardDto, UpdateBoardDto } from '@core/boards/application/dtos'; import type { BoardWithRelations } from '@core/boards/domain/entities'; +import { + CreateBoardSwagger, + FindAllBoardsSwagger, + FindOneBoardSwagger, + RemoveBoardSwagger, + UpdateBoardSwagger, +} from './swagger'; @ApiBaseController('projects/:projectId/boards', 'Boards', true) export class BoardsController { constructor(private readonly facade: BoardsFacade) {} @Get() + @FindAllBoardsSwagger() async findAll( @Param('projectId') projectId: string, @GetUserId() userId: string, @@ -17,6 +25,7 @@ export class BoardsController { } @Get(':id') + @FindOneBoardSwagger() async findOne( @Param('id') id: string, @Param('projectId') projectId: string, @@ -26,6 +35,7 @@ export class BoardsController { } @Post() + @CreateBoardSwagger() async create( @Param('projectId') projectId: string, @GetUserId() userId: string, @@ -35,6 +45,7 @@ export class BoardsController { } @Patch(':id') + @UpdateBoardSwagger() async update( @Param('id') id: string, @Param('projectId') projectId: string, @@ -45,6 +56,7 @@ export class BoardsController { } @Delete(':id') + @RemoveBoardSwagger() async remove( @Param('id') id: string, @Param('projectId') projectId: string, diff --git a/src/boards/application/controller/boards/swagger.ts b/src/boards/application/controller/boards/swagger.ts index e69de29..dbfb6e4 100644 --- a/src/boards/application/controller/boards/swagger.ts +++ b/src/boards/application/controller/boards/swagger.ts @@ -0,0 +1,79 @@ +import { applyDecorators } from '@nestjs/common'; +import { ApiBody, ApiOperation, ApiParam, ApiResponse } from '@nestjs/swagger'; +import { ApiForbidden, ApiNotFound, ApiUnauthorized, ApiValidationError } from '@shared/error'; +import { BoardResponse, CreateBoardDto, UpdateBoardDto } from '../../dtos'; + +export const FindAllBoardsSwagger = () => + applyDecorators( + ApiOperation({ summary: 'Получить список досок проекта' }), + ApiParam({ name: 'projectId', description: 'ID проекта', type: 'string' }), + ApiResponse({ + status: 200, + description: 'Список досок получен', + type: [BoardResponse.Output], + }), + ApiUnauthorized(), + ApiForbidden(), + ); + +export const FindOneBoardSwagger = () => + applyDecorators( + ApiOperation({ summary: 'Получить доску по ID' }), + ApiParam({ name: 'projectId', description: 'ID проекта', type: 'string' }), + ApiParam({ name: 'id', description: 'ID доски', type: 'string' }), + ApiResponse({ + status: 200, + description: 'Данные доски получены', + type: BoardResponse.Output, + }), + ApiNotFound('Доска не найдена'), + ApiUnauthorized(), + ApiForbidden(), + ); + +export const CreateBoardSwagger = () => + applyDecorators( + ApiOperation({ summary: 'Создать доску в проекте' }), + ApiParam({ name: 'projectId', description: 'ID проекта', type: 'string' }), + ApiBody({ type: CreateBoardDto.Output }), + ApiResponse({ + status: 201, + description: 'Доска успешно создана', + type: BoardResponse.Output, + }), + ApiValidationError(), + ApiUnauthorized(), + ApiForbidden(), + ); + +export const UpdateBoardSwagger = () => + applyDecorators( + ApiOperation({ summary: 'Обновить доску' }), + ApiParam({ name: 'projectId', description: 'ID проекта', type: 'string' }), + ApiParam({ name: 'id', description: 'ID доски', type: 'string' }), + ApiBody({ type: UpdateBoardDto.Output }), + ApiResponse({ + status: 200, + description: 'Доска обновлена', + type: BoardResponse.Output, + }), + ApiValidationError(), + ApiNotFound('Доска не найдена'), + ApiUnauthorized(), + ApiForbidden(), + ); + +export const RemoveBoardSwagger = () => + applyDecorators( + ApiOperation({ summary: 'Удалить доску' }), + ApiParam({ name: 'projectId', description: 'ID проекта', type: 'string' }), + ApiParam({ name: 'id', description: 'ID доски', type: 'string' }), + ApiResponse({ + status: 200, + description: 'Доска удалена', + type: Boolean, + }), + ApiNotFound('Доска не найдена'), + ApiUnauthorized(), + ApiForbidden(), + ); diff --git a/src/boards/application/dtos/boards.dto.ts b/src/boards/application/dtos/boards.dto.ts index 52a053f..0fa34d7 100644 --- a/src/boards/application/dtos/boards.dto.ts +++ b/src/boards/application/dtos/boards.dto.ts @@ -1,5 +1,6 @@ import { z } from 'zod/v4'; import { createZodDto } from 'nestjs-zod'; +import { boardTypeEnum } from '@core/boards/infrastructure/persistence/models/boards.model'; export const CreateBoardSchema = z.object({ name: z @@ -21,3 +22,43 @@ export const UpdateBoardSchema = CreateBoardSchema.partial().refine( ); export class UpdateBoardDto extends createZodDto(UpdateBoardSchema) {} + +export const BoardColumnResponseSchema = z.object({ + id: z.string().describe('ID колонки'), + boardId: z.string().describe('ID доски'), + name: z.string().describe('Название колонки'), + position: z.number().describe('Позиция колонки'), + color: z.string().describe('Цвет колонки в HEX'), + createdAt: z.string().datetime().describe('Дата создания'), + updatedAt: z.string().datetime().describe('Дата обновления'), +}); + +export class BoardColumnResponse extends createZodDto(BoardColumnResponseSchema) {} + +export const BoardViewResponseSchema = z.object({ + id: z.string().describe('ID представления'), + boardId: z.string().describe('ID доски'), + type: z.enum(boardTypeEnum.enumValues).describe('Тип представления'), + name: z.string().describe('Название представления'), + settings: z.record(z.string(), z.unknown()).describe('Настройки представления'), + position: z.number().describe('Позиция представления'), + createdAt: z.string().datetime().describe('Дата создания'), + updatedAt: z.string().datetime().describe('Дата обновления'), +}); + +export class BoardViewResponse extends createZodDto(BoardViewResponseSchema) {} + +export const BoardResponseSchema = z.object({ + id: z.string().describe('ID доски'), + name: z.string().describe('Название доски'), + projectId: z.string().describe('ID проекта'), + settings: z.record(z.string(), z.unknown()).describe('Настройки доски'), + position: z.number().describe('Позиция доски'), + ownerId: z.string().nullable().describe('ID владельца доски'), + createdAt: z.string().datetime().describe('Дата создания'), + updatedAt: z.string().datetime().describe('Дата обновления'), + boardColumns: z.array(BoardColumnResponseSchema).describe('Колонки доски'), + boardViews: z.array(BoardViewResponseSchema).describe('Представления доски'), +}); + +export class BoardResponse extends createZodDto(BoardResponseSchema) {} diff --git a/src/boards/infrastructure/persistence/models/boards.model.ts b/src/boards/infrastructure/persistence/models/boards.model.ts index 5fb9e9d..a7dc48d 100644 --- a/src/boards/infrastructure/persistence/models/boards.model.ts +++ b/src/boards/infrastructure/persistence/models/boards.model.ts @@ -1,23 +1,29 @@ -import { text, varchar, timestamp, jsonb, doublePrecision } from 'drizzle-orm/pg-core'; +import { text, varchar, timestamp, jsonb, doublePrecision, uniqueIndex } from 'drizzle-orm/pg-core'; import { baseSchema, projects, users } from '@shared/entities'; import { createId } from '@paralleldrive/cuid2'; export const boardTypeEnum = baseSchema.enum('board_type', ['kanban', 'calendar', 'gantt_matrix']); -export const boards = baseSchema.table('boards', { - id: text('id') - .primaryKey() - .$defaultFn(() => createId()), - name: varchar('name', { length: 100 }).notNull(), - projectId: text('project_id') - .references(() => projects.id, { onDelete: 'cascade' }) - .notNull(), - settings: jsonb('settings').default({}).notNull(), - position: doublePrecision('position').notNull(), - ownerId: text('owner_id').references(() => users.id, { onDelete: 'set null' }), - createdAt: timestamp('created_at').defaultNow().notNull(), - updatedAt: timestamp('updated_at').defaultNow().notNull(), -}); +export const boards = baseSchema.table( + 'boards', + { + id: text('id') + .primaryKey() + .$defaultFn(() => createId()), + name: varchar('name', { length: 100 }).notNull(), + projectId: text('project_id') + .references(() => projects.id, { onDelete: 'cascade' }) + .notNull(), + settings: jsonb('settings').default({}).notNull(), + position: doublePrecision('position').notNull(), + ownerId: text('owner_id').references(() => users.id, { onDelete: 'set null' }), + createdAt: timestamp('created_at').defaultNow().notNull(), + updatedAt: timestamp('updated_at').defaultNow().notNull(), + }, + (table) => ({ + projectBoardNameIdx: uniqueIndex('project_board_name_idx').on(table.projectId, table.name), + }), +); export const boardColumns = baseSchema.table('board_columns', { id: text('id') From 10385a252a92ff63fef8176323e2fcefe01b64f6 Mon Sep 17 00:00:00 2001 From: Maxim Date: Wed, 6 May 2026 22:42:36 +0300 Subject: [PATCH 05/12] feat(boards): implement CRUD operations for board columns and swagger --- src/boards/application/boards.facade.ts | 48 ++++++++++- .../controller/columns/controller.ts | 67 +++++++++++++++ .../application/controller/columns/swagger.ts | 84 +++++++++++++++++++ src/boards/application/controller/index.ts | 1 + src/boards/application/dtos/boards.dto.ts | 24 ++++++ .../use-cases/create-board-column.use-case.ts | 20 +++++ .../use-cases/delete-board-column.use-case.ts | 14 ++++ .../use-cases/get-board-column.query.ts | 19 +++++ .../use-cases/get-board-columns.query.ts | 15 ++++ src/boards/application/use-cases/index.ts | 26 +++++- .../use-cases/update-board-column.use-case.ts | 21 +++++ src/boards/boards.module.ts | 4 +- .../repository/boards.repository.interface.ts | 6 ++ .../repositories/boards.repository.ts | 42 ++++++++++ 14 files changed, 385 insertions(+), 6 deletions(-) create mode 100644 src/boards/application/controller/columns/controller.ts create mode 100644 src/boards/application/controller/columns/swagger.ts create mode 100644 src/boards/application/use-cases/create-board-column.use-case.ts create mode 100644 src/boards/application/use-cases/delete-board-column.use-case.ts create mode 100644 src/boards/application/use-cases/get-board-column.query.ts create mode 100644 src/boards/application/use-cases/get-board-columns.query.ts create mode 100644 src/boards/application/use-cases/update-board-column.use-case.ts diff --git a/src/boards/application/boards.facade.ts b/src/boards/application/boards.facade.ts index e99aad9..2d7ee05 100644 --- a/src/boards/application/boards.facade.ts +++ b/src/boards/application/boards.facade.ts @@ -5,9 +5,14 @@ import { GetBoardQuery, GetBoardsQuery, UpdateBoardUseCase, + CreateBoardColumnUseCase, + UpdateBoardColumnUseCase, + DeleteBoardColumnUseCase, + GetBoardColumnsQuery, + GetBoardColumnQuery, } from './use-cases'; -import { CreateBoardDto, UpdateBoardDto } from './dtos'; -import type { BoardWithRelations } from '@core/boards/domain/entities'; +import { CreateBoardDto, CreateBoardColumnDto, UpdateBoardColumnDto, UpdateBoardDto } from './dtos'; +import type { BoardColumn, BoardWithRelations } from '@core/boards/domain/entities'; @Injectable() export class BoardsFacade { @@ -17,6 +22,12 @@ export class BoardsFacade { private readonly deleteBoardUC: DeleteBoardUseCase, private readonly getBoardQ: GetBoardQuery, private readonly getBoardsQ: GetBoardsQuery, + + private readonly createBoardColumnUC: CreateBoardColumnUseCase, + private readonly updateBoardColumnUC: UpdateBoardColumnUseCase, + private readonly deleteBoardColumnUC: DeleteBoardColumnUseCase, + private readonly getBoardColumnsQ: GetBoardColumnsQuery, + private readonly getBoardColumnQ: GetBoardColumnQuery, ) {} public async create( @@ -51,4 +62,37 @@ export class BoardsFacade { public async getAll(projectId: string, userId: string): Promise { return this.getBoardsQ.execute(projectId, userId); } + + public async createColumn( + boardId: string, + userId: string, + dto: CreateBoardColumnDto, + ): Promise { + return this.createBoardColumnUC.execute(boardId, userId, dto); + } + + public async updateColumn( + id: string, + boardId: string, + userId: string, + dto: UpdateBoardColumnDto, + ): Promise { + return this.updateBoardColumnUC.execute(id, boardId, userId, dto); + } + + public async deleteColumn(id: string, boardId: string, userId: string): Promise { + return this.deleteBoardColumnUC.execute(id, boardId, userId); + } + + public async getColumn( + id: string, + boardId: string, + userId: string, + ): Promise { + return this.getBoardColumnQ.execute(id, boardId, userId); + } + + public async getColumns(boardId: string, userId: string): Promise { + return this.getBoardColumnsQ.execute(boardId, userId); + } } diff --git a/src/boards/application/controller/columns/controller.ts b/src/boards/application/controller/columns/controller.ts new file mode 100644 index 0000000..e244594 --- /dev/null +++ b/src/boards/application/controller/columns/controller.ts @@ -0,0 +1,67 @@ +import { ApiBaseController, GetUserId } from '@shared/decorators'; +import { BoardsFacade } from '@core/boards/application/boards.facade'; +import { Body, Delete, Get, Param, Patch, Post } from '@nestjs/common'; +import { CreateBoardColumnDto, UpdateBoardColumnDto } from '@core/boards/application/dtos'; +import type { BoardColumn } from '@core/boards/domain/entities'; +import { + CreateBoardColumnSwagger, + FindAllBoardColumnsSwagger, + FindOneBoardColumnSwagger, + RemoveBoardColumnSwagger, + UpdateBoardColumnSwagger, +} from './swagger'; + +@ApiBaseController('boards/:boardId/columns', 'Board Columns', true) +export class ColumnsController { + constructor(private readonly facade: BoardsFacade) {} + + @Get() + @FindAllBoardColumnsSwagger() + async findAll( + @Param('boardId') boardId: string, + @GetUserId() userId: string, + ): Promise { + return this.facade.getColumns(boardId, userId); + } + + @Get(':id') + @FindOneBoardColumnSwagger() + async findOne( + @Param('id') id: string, + @Param('boardId') boardId: string, + @GetUserId() userId: string, + ): Promise { + return this.facade.getColumn(id, boardId, userId); + } + + @Post() + @CreateBoardColumnSwagger() + async create( + @Param('boardId') boardId: string, + @GetUserId() userId: string, + @Body() dto: CreateBoardColumnDto, + ): Promise { + return this.facade.createColumn(boardId, userId, dto); + } + + @Patch(':id') + @UpdateBoardColumnSwagger() + async update( + @Param('id') id: string, + @Param('boardId') boardId: string, + @GetUserId() userId: string, + @Body() dto: UpdateBoardColumnDto, + ): Promise { + return this.facade.updateColumn(id, boardId, userId, dto); + } + + @Delete(':id') + @RemoveBoardColumnSwagger() + async remove( + @Param('id') id: string, + @Param('boardId') boardId: string, + @GetUserId() userId: string, + ): Promise { + return this.facade.deleteColumn(id, boardId, userId); + } +} diff --git a/src/boards/application/controller/columns/swagger.ts b/src/boards/application/controller/columns/swagger.ts new file mode 100644 index 0000000..65a2f55 --- /dev/null +++ b/src/boards/application/controller/columns/swagger.ts @@ -0,0 +1,84 @@ +import { applyDecorators } from '@nestjs/common'; +import { ApiBody, ApiOperation, ApiParam, ApiResponse } from '@nestjs/swagger'; +import { ApiForbidden, ApiNotFound, ApiUnauthorized, ApiValidationError } from '@shared/error'; +import { BoardColumnResponse, CreateBoardColumnDto, UpdateBoardColumnDto } from '../../dtos'; + +export const FindAllBoardColumnsSwagger = () => + applyDecorators( + ApiOperation({ summary: 'Получить список колонок доски' }), + ApiParam({ name: 'projectId', description: 'ID проекта', type: 'string' }), + ApiParam({ name: 'boardId', description: 'ID доски', type: 'string' }), + ApiResponse({ + status: 200, + description: 'Список колонок получен', + type: [BoardColumnResponse.Output], + }), + ApiUnauthorized(), + ApiForbidden(), + ); + +export const FindOneBoardColumnSwagger = () => + applyDecorators( + ApiOperation({ summary: 'Получить колонку по ID' }), + ApiParam({ name: 'projectId', description: 'ID проекта', type: 'string' }), + ApiParam({ name: 'boardId', description: 'ID доски', type: 'string' }), + ApiParam({ name: 'id', description: 'ID колонки', type: 'string' }), + ApiResponse({ + status: 200, + description: 'Колонка получена', + type: BoardColumnResponse.Output, + }), + ApiNotFound('Колонка не найдена'), + ApiUnauthorized(), + ApiForbidden(), + ); + +export const CreateBoardColumnSwagger = () => + applyDecorators( + ApiOperation({ summary: 'Создать колонку в доске' }), + ApiParam({ name: 'projectId', description: 'ID проекта', type: 'string' }), + ApiParam({ name: 'boardId', description: 'ID доски', type: 'string' }), + ApiBody({ type: CreateBoardColumnDto.Output }), + ApiResponse({ + status: 201, + description: 'Колонка создана', + type: BoardColumnResponse.Output, + }), + ApiValidationError(), + ApiUnauthorized(), + ApiForbidden(), + ); + +export const UpdateBoardColumnSwagger = () => + applyDecorators( + ApiOperation({ summary: 'Обновить колонку' }), + ApiParam({ name: 'projectId', description: 'ID проекта', type: 'string' }), + ApiParam({ name: 'boardId', description: 'ID доски', type: 'string' }), + ApiParam({ name: 'id', description: 'ID колонки', type: 'string' }), + ApiBody({ type: UpdateBoardColumnDto.Output }), + ApiResponse({ + status: 200, + description: 'Колонка обновлена', + type: BoardColumnResponse.Output, + }), + ApiValidationError(), + ApiNotFound('Колонка не найдена'), + ApiUnauthorized(), + ApiForbidden(), + ); + +export const RemoveBoardColumnSwagger = () => + applyDecorators( + ApiOperation({ summary: 'Удалить колонку' }), + ApiParam({ name: 'projectId', description: 'ID проекта', type: 'string' }), + ApiParam({ name: 'boardId', description: 'ID доски', type: 'string' }), + ApiParam({ name: 'id', description: 'ID колонки', type: 'string' }), + ApiResponse({ + status: 200, + description: 'Колонка удалена', + type: Boolean, + }), + ApiNotFound('Колонка не найдена'), + ApiUnauthorized(), + ApiForbidden(), + ); diff --git a/src/boards/application/controller/index.ts b/src/boards/application/controller/index.ts index 7bffbdf..85f3e92 100644 --- a/src/boards/application/controller/index.ts +++ b/src/boards/application/controller/index.ts @@ -1 +1,2 @@ export { BoardsController } from './boards/controller'; +export { ColumnsController } from './columns/controller'; diff --git a/src/boards/application/dtos/boards.dto.ts b/src/boards/application/dtos/boards.dto.ts index 0fa34d7..4e2d767 100644 --- a/src/boards/application/dtos/boards.dto.ts +++ b/src/boards/application/dtos/boards.dto.ts @@ -23,6 +23,30 @@ export const UpdateBoardSchema = CreateBoardSchema.partial().refine( export class UpdateBoardDto extends createZodDto(UpdateBoardSchema) {} +export const CreateBoardColumnSchema = z.object({ + name: z + .string() + .min(1, 'Название колонки не может быть пустым') + .max(50, 'Название колонки не должно превышать 50 символов'), + position: z.number().finite(), + color: z + .string() + .regex(/^#[A-Fa-f0-9]{6}$/, 'Цвет должен быть в формате HEX (например, #FFFFFF)') + .optional(), +}); + +export class CreateBoardColumnDto extends createZodDto(CreateBoardColumnSchema) {} + +export const UpdateBoardColumnSchema = CreateBoardColumnSchema.partial().refine( + (data) => Object.keys(data).length > 0, + { + error: 'Необходимо передать хотя бы одно поле для обновления', + abort: true, + }, +); + +export class UpdateBoardColumnDto extends createZodDto(UpdateBoardColumnSchema) {} + export const BoardColumnResponseSchema = z.object({ id: z.string().describe('ID колонки'), boardId: z.string().describe('ID доски'), diff --git a/src/boards/application/use-cases/create-board-column.use-case.ts b/src/boards/application/use-cases/create-board-column.use-case.ts new file mode 100644 index 0000000..e2ca285 --- /dev/null +++ b/src/boards/application/use-cases/create-board-column.use-case.ts @@ -0,0 +1,20 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { IBoardsRepository } from '@core/boards/domain/repository'; +import { CreateBoardColumnDto } from '@core/boards/application/dtos'; +import type { BoardColumn } from '@core/boards/domain/entities'; + +@Injectable() +export class CreateBoardColumnUseCase { + constructor( + @Inject('IBoardsRepository') + private readonly boardsRepo: IBoardsRepository, + ) {} + + public async execute( + boardId: string, + _userId: string, + dto: CreateBoardColumnDto, + ): Promise { + return this.boardsRepo.createColumn({ boardId, ...dto }); + } +} diff --git a/src/boards/application/use-cases/delete-board-column.use-case.ts b/src/boards/application/use-cases/delete-board-column.use-case.ts new file mode 100644 index 0000000..311905a --- /dev/null +++ b/src/boards/application/use-cases/delete-board-column.use-case.ts @@ -0,0 +1,14 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { IBoardsRepository } from '@core/boards/domain/repository'; + +@Injectable() +export class DeleteBoardColumnUseCase { + constructor( + @Inject('IBoardsRepository') + private readonly boardsRepo: IBoardsRepository, + ) {} + + public async execute(id: string, _boardId: string, _userId: string): Promise { + return this.boardsRepo.removeColumn(id); + } +} diff --git a/src/boards/application/use-cases/get-board-column.query.ts b/src/boards/application/use-cases/get-board-column.query.ts new file mode 100644 index 0000000..8ba8b05 --- /dev/null +++ b/src/boards/application/use-cases/get-board-column.query.ts @@ -0,0 +1,19 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { IBoardsRepository } from '@core/boards/domain/repository'; +import type { BoardColumn } from '@core/boards/domain/entities'; + +@Injectable() +export class GetBoardColumnQuery { + constructor( + @Inject('IBoardsRepository') + private readonly boardsRepo: IBoardsRepository, + ) {} + + public async execute( + id: string, + _boardId: string, + _userId: string, + ): Promise { + return this.boardsRepo.findColumnById(id); + } +} diff --git a/src/boards/application/use-cases/get-board-columns.query.ts b/src/boards/application/use-cases/get-board-columns.query.ts new file mode 100644 index 0000000..2c24da8 --- /dev/null +++ b/src/boards/application/use-cases/get-board-columns.query.ts @@ -0,0 +1,15 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { IBoardsRepository } from '@core/boards/domain/repository'; +import type { BoardColumn } from '@core/boards/domain/entities'; + +@Injectable() +export class GetBoardColumnsQuery { + constructor( + @Inject('IBoardsRepository') + private readonly boardsRepo: IBoardsRepository, + ) {} + + public async execute(boardId: string, _userId: string): Promise { + return this.boardsRepo.findColumns(boardId); + } +} diff --git a/src/boards/application/use-cases/index.ts b/src/boards/application/use-cases/index.ts index ccf3d2a..827f407 100644 --- a/src/boards/application/use-cases/index.ts +++ b/src/boards/application/use-cases/index.ts @@ -3,12 +3,34 @@ import { UpdateBoardUseCase } from './update-board.use-case'; import { DeleteBoardUseCase } from './delete-board.use-case'; import { GetBoardQuery } from './get-board.query'; import { GetBoardsQuery } from './get-boards.query'; +import { CreateBoardColumnUseCase } from './create-board-column.use-case'; +import { UpdateBoardColumnUseCase } from './update-board-column.use-case'; +import { DeleteBoardColumnUseCase } from './delete-board-column.use-case'; +import { GetBoardColumnsQuery } from './get-board-columns.query'; +import { GetBoardColumnQuery } from './get-board-column.query'; export * from './create-board.use-case'; export * from './update-board.use-case'; export * from './delete-board.use-case'; export * from './get-board.query'; export * from './get-boards.query'; +export * from './create-board-column.use-case'; +export * from './update-board-column.use-case'; +export * from './delete-board-column.use-case'; +export * from './get-board-columns.query'; +export * from './get-board-column.query'; -export const BoardUseCases = [CreateBoardUseCase, UpdateBoardUseCase, DeleteBoardUseCase]; -export const BoardQueries = [GetBoardQuery, GetBoardsQuery]; +export const BoardUseCases = [ + CreateBoardUseCase, + UpdateBoardUseCase, + DeleteBoardUseCase, + CreateBoardColumnUseCase, + UpdateBoardColumnUseCase, + DeleteBoardColumnUseCase, +]; +export const BoardQueries = [ + GetBoardQuery, + GetBoardsQuery, + GetBoardColumnsQuery, + GetBoardColumnQuery, +]; diff --git a/src/boards/application/use-cases/update-board-column.use-case.ts b/src/boards/application/use-cases/update-board-column.use-case.ts new file mode 100644 index 0000000..27f0b14 --- /dev/null +++ b/src/boards/application/use-cases/update-board-column.use-case.ts @@ -0,0 +1,21 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { IBoardsRepository } from '@core/boards/domain/repository'; +import { UpdateBoardColumnDto } from '@core/boards/application/dtos'; +import type { BoardColumn } from '@core/boards/domain/entities'; + +@Injectable() +export class UpdateBoardColumnUseCase { + constructor( + @Inject('IBoardsRepository') + private readonly boardsRepo: IBoardsRepository, + ) {} + + public async execute( + id: string, + _boardId: string, + _userId: string, + dto: UpdateBoardColumnDto, + ): Promise { + return this.boardsRepo.updateColumn(id, dto); + } +} diff --git a/src/boards/boards.module.ts b/src/boards/boards.module.ts index 7e4343a..2a7702e 100644 --- a/src/boards/boards.module.ts +++ b/src/boards/boards.module.ts @@ -1,6 +1,6 @@ import { Module } from '@nestjs/common'; import { BoardsRepository } from './infrastructure/persistence/repositories'; -import { BoardsController } from './application/controller'; +import { BoardsController, ColumnsController } from './application/controller'; import { BoardsFacade } from './application/boards.facade'; import { BoardQueries, BoardUseCases } from './application/use-cases'; @@ -10,7 +10,7 @@ const REPOSITORY = { }; @Module({ - controllers: [BoardsController], + controllers: [BoardsController, ColumnsController], providers: [REPOSITORY, BoardsFacade, ...BoardUseCases, ...BoardQueries], }) export class BoardsModule {} diff --git a/src/boards/domain/repository/boards.repository.interface.ts b/src/boards/domain/repository/boards.repository.interface.ts index 856ffd5..fd132cc 100644 --- a/src/boards/domain/repository/boards.repository.interface.ts +++ b/src/boards/domain/repository/boards.repository.interface.ts @@ -1,5 +1,6 @@ import { Board, + BoardColumn, BoardWithRelations, NewBoard, NewBoardColumn, @@ -16,4 +17,9 @@ export interface IBoardsRepository { ): Promise; update(id: string, data: Partial): Promise; remove(id: string): Promise; + findColumns(boardId: string): Promise; + findColumnById(id: string): Promise; + createColumn(column: NewBoardColumn): Promise; + updateColumn(id: string, data: Partial): Promise; + removeColumn(id: string): Promise; } diff --git a/src/boards/infrastructure/persistence/repositories/boards.repository.ts b/src/boards/infrastructure/persistence/repositories/boards.repository.ts index f7d4af2..e7109e9 100644 --- a/src/boards/infrastructure/persistence/repositories/boards.repository.ts +++ b/src/boards/infrastructure/persistence/repositories/boards.repository.ts @@ -152,6 +152,48 @@ export class BoardsRepository implements IBoardsRepository { return result.length > 0; } + async findColumns(boardId: string): Promise { + return this.db + .select() + .from(schema.boardColumns) + .where(eq(schema.boardColumns.boardId, boardId)) + .orderBy(asc(schema.boardColumns.position)); + } + + async findColumnById(id: string): Promise { + const [column] = await this.db + .select() + .from(schema.boardColumns) + .where(eq(schema.boardColumns.id, id)); + + return column ?? null; + } + + async createColumn(column: NewBoardColumn): Promise { + const [created] = await this.db.insert(schema.boardColumns).values(column).returning(); + + return created; + } + + async updateColumn(id: string, data: Partial): Promise { + const [updated] = await this.db + .update(schema.boardColumns) + .set({ ...data, updatedAt: new Date() }) + .where(eq(schema.boardColumns.id, id)) + .returning(); + + return updated ?? null; + } + + async removeColumn(id: string): Promise { + const result = await this.db + .delete(schema.boardColumns) + .where(eq(schema.boardColumns.id, id)) + .returning({ id: schema.boardColumns.id }); + + return result.length > 0; + } + private groupByBoardId(items: T[]): Map { const grouped = new Map(); From 1cbff644218aff8a3460b291f4cc752935965d6d Mon Sep 17 00:00:00 2001 From: Maxim Date: Wed, 6 May 2026 22:45:50 +0300 Subject: [PATCH 06/12] feat(boards): implement CRUD operations for board views and swagger --- src/boards/application/boards.facade.ts | 51 ++++++++++- src/boards/application/controller/index.ts | 1 + .../controller/views/controller.ts | 67 +++++++++++++++ .../application/controller/views/swagger.ts | 84 +++++++++++++++++++ src/boards/application/dtos/boards.dto.ts | 22 +++++ .../use-cases/create-board-view.use-case.ts | 20 +++++ .../use-cases/delete-board-view.use-case.ts | 14 ++++ .../use-cases/get-board-view.query.ts | 15 ++++ .../use-cases/get-board-views.query.ts | 15 ++++ src/boards/application/use-cases/index.ts | 15 ++++ .../use-cases/update-board-view.use-case.ts | 21 +++++ src/boards/boards.module.ts | 4 +- .../repository/boards.repository.interface.ts | 6 ++ .../repositories/boards.repository.ts | 42 ++++++++++ 14 files changed, 373 insertions(+), 4 deletions(-) create mode 100644 src/boards/application/controller/views/controller.ts create mode 100644 src/boards/application/controller/views/swagger.ts create mode 100644 src/boards/application/use-cases/create-board-view.use-case.ts create mode 100644 src/boards/application/use-cases/delete-board-view.use-case.ts create mode 100644 src/boards/application/use-cases/get-board-view.query.ts create mode 100644 src/boards/application/use-cases/get-board-views.query.ts create mode 100644 src/boards/application/use-cases/update-board-view.use-case.ts diff --git a/src/boards/application/boards.facade.ts b/src/boards/application/boards.facade.ts index 2d7ee05..2ea8250 100644 --- a/src/boards/application/boards.facade.ts +++ b/src/boards/application/boards.facade.ts @@ -10,9 +10,21 @@ import { DeleteBoardColumnUseCase, GetBoardColumnsQuery, GetBoardColumnQuery, + CreateBoardViewUseCase, + UpdateBoardViewUseCase, + DeleteBoardViewUseCase, + GetBoardViewsQuery, + GetBoardViewQuery, } from './use-cases'; -import { CreateBoardDto, CreateBoardColumnDto, UpdateBoardColumnDto, UpdateBoardDto } from './dtos'; -import type { BoardColumn, BoardWithRelations } from '@core/boards/domain/entities'; +import { + CreateBoardDto, + CreateBoardColumnDto, + CreateBoardViewDto, + UpdateBoardColumnDto, + UpdateBoardDto, + UpdateBoardViewDto, +} from './dtos'; +import type { BoardColumn, BoardView, BoardWithRelations } from '@core/boards/domain/entities'; @Injectable() export class BoardsFacade { @@ -28,6 +40,12 @@ export class BoardsFacade { private readonly deleteBoardColumnUC: DeleteBoardColumnUseCase, private readonly getBoardColumnsQ: GetBoardColumnsQuery, private readonly getBoardColumnQ: GetBoardColumnQuery, + + private readonly createBoardViewUC: CreateBoardViewUseCase, + private readonly updateBoardViewUC: UpdateBoardViewUseCase, + private readonly deleteBoardViewUC: DeleteBoardViewUseCase, + private readonly getBoardViewsQ: GetBoardViewsQuery, + private readonly getBoardViewQ: GetBoardViewQuery, ) {} public async create( @@ -95,4 +113,33 @@ export class BoardsFacade { public async getColumns(boardId: string, userId: string): Promise { return this.getBoardColumnsQ.execute(boardId, userId); } + + public async createView( + boardId: string, + userId: string, + dto: CreateBoardViewDto, + ): Promise { + return this.createBoardViewUC.execute(boardId, userId, dto); + } + + public async updateView( + id: string, + boardId: string, + userId: string, + dto: UpdateBoardViewDto, + ): Promise { + return this.updateBoardViewUC.execute(id, boardId, userId, dto); + } + + public async deleteView(id: string, boardId: string, userId: string): Promise { + return this.deleteBoardViewUC.execute(id, boardId, userId); + } + + public async getView(id: string, boardId: string, userId: string): Promise { + return this.getBoardViewQ.execute(id, boardId, userId); + } + + public async getViews(boardId: string, userId: string): Promise { + return this.getBoardViewsQ.execute(boardId, userId); + } } diff --git a/src/boards/application/controller/index.ts b/src/boards/application/controller/index.ts index 85f3e92..6e3a48f 100644 --- a/src/boards/application/controller/index.ts +++ b/src/boards/application/controller/index.ts @@ -1,2 +1,3 @@ export { BoardsController } from './boards/controller'; export { ColumnsController } from './columns/controller'; +export { ViewsController } from './views/controller'; diff --git a/src/boards/application/controller/views/controller.ts b/src/boards/application/controller/views/controller.ts new file mode 100644 index 0000000..7c75a31 --- /dev/null +++ b/src/boards/application/controller/views/controller.ts @@ -0,0 +1,67 @@ +import { Body, Delete, Get, Param, Patch, Post } from '@nestjs/common'; +import { ApiBaseController, GetUserId } from '@shared/decorators'; +import { BoardsFacade } from '@core/boards/application/boards.facade'; +import { CreateBoardViewDto, UpdateBoardViewDto } from '@core/boards/application/dtos'; +import type { BoardView } from '@core/boards/domain/entities'; +import { + CreateBoardViewSwagger, + FindAllBoardViewsSwagger, + FindOneBoardViewSwagger, + RemoveBoardViewSwagger, + UpdateBoardViewSwagger, +} from './swagger'; + +@ApiBaseController('boards/:boardId/views', 'Board Views', true) +export class ViewsController { + constructor(private readonly facade: BoardsFacade) {} + + @Get() + @FindAllBoardViewsSwagger() + async findAll( + @Param('boardId') boardId: string, + @GetUserId() userId: string, + ): Promise { + return this.facade.getViews(boardId, userId); + } + + @Get(':id') + @FindOneBoardViewSwagger() + async findOne( + @Param('id') id: string, + @Param('boardId') boardId: string, + @GetUserId() userId: string, + ): Promise { + return this.facade.getView(id, boardId, userId); + } + + @Post() + @CreateBoardViewSwagger() + async create( + @Param('boardId') boardId: string, + @GetUserId() userId: string, + @Body() dto: CreateBoardViewDto, + ): Promise { + return this.facade.createView(boardId, userId, dto); + } + + @Patch(':id') + @UpdateBoardViewSwagger() + async update( + @Param('id') id: string, + @Param('boardId') boardId: string, + @GetUserId() userId: string, + @Body() dto: UpdateBoardViewDto, + ): Promise { + return this.facade.updateView(id, boardId, userId, dto); + } + + @Delete(':id') + @RemoveBoardViewSwagger() + async remove( + @Param('id') id: string, + @Param('boardId') boardId: string, + @GetUserId() userId: string, + ): Promise { + return this.facade.deleteView(id, boardId, userId); + } +} diff --git a/src/boards/application/controller/views/swagger.ts b/src/boards/application/controller/views/swagger.ts new file mode 100644 index 0000000..ef06acf --- /dev/null +++ b/src/boards/application/controller/views/swagger.ts @@ -0,0 +1,84 @@ +import { applyDecorators } from '@nestjs/common'; +import { ApiBody, ApiOperation, ApiParam, ApiResponse } from '@nestjs/swagger'; +import { ApiForbidden, ApiNotFound, ApiUnauthorized, ApiValidationError } from '@shared/error'; +import { BoardViewResponse, CreateBoardViewDto, UpdateBoardViewDto } from '../../dtos'; + +export const FindAllBoardViewsSwagger = () => + applyDecorators( + ApiOperation({ summary: 'Получить список представлений доски' }), + ApiParam({ name: 'projectId', description: 'ID проекта', type: 'string' }), + ApiParam({ name: 'boardId', description: 'ID доски', type: 'string' }), + ApiResponse({ + status: 200, + description: 'Список представлений получен', + type: [BoardViewResponse.Output], + }), + ApiUnauthorized(), + ApiForbidden(), + ); + +export const FindOneBoardViewSwagger = () => + applyDecorators( + ApiOperation({ summary: 'Получить представление по ID' }), + ApiParam({ name: 'projectId', description: 'ID проекта', type: 'string' }), + ApiParam({ name: 'boardId', description: 'ID доски', type: 'string' }), + ApiParam({ name: 'id', description: 'ID представления', type: 'string' }), + ApiResponse({ + status: 200, + description: 'Представление получено', + type: BoardViewResponse.Output, + }), + ApiNotFound('Представление не найдено'), + ApiUnauthorized(), + ApiForbidden(), + ); + +export const CreateBoardViewSwagger = () => + applyDecorators( + ApiOperation({ summary: 'Создать представление в доске' }), + ApiParam({ name: 'projectId', description: 'ID проекта', type: 'string' }), + ApiParam({ name: 'boardId', description: 'ID доски', type: 'string' }), + ApiBody({ type: CreateBoardViewDto.Output }), + ApiResponse({ + status: 201, + description: 'Представление создано', + type: BoardViewResponse.Output, + }), + ApiValidationError(), + ApiUnauthorized(), + ApiForbidden(), + ); + +export const UpdateBoardViewSwagger = () => + applyDecorators( + ApiOperation({ summary: 'Обновить представление' }), + ApiParam({ name: 'projectId', description: 'ID проекта', type: 'string' }), + ApiParam({ name: 'boardId', description: 'ID доски', type: 'string' }), + ApiParam({ name: 'id', description: 'ID представления', type: 'string' }), + ApiBody({ type: UpdateBoardViewDto.Output }), + ApiResponse({ + status: 200, + description: 'Представление обновлено', + type: BoardViewResponse.Output, + }), + ApiValidationError(), + ApiNotFound('Представление не найдено'), + ApiUnauthorized(), + ApiForbidden(), + ); + +export const RemoveBoardViewSwagger = () => + applyDecorators( + ApiOperation({ summary: 'Удалить представление' }), + ApiParam({ name: 'projectId', description: 'ID проекта', type: 'string' }), + ApiParam({ name: 'boardId', description: 'ID доски', type: 'string' }), + ApiParam({ name: 'id', description: 'ID представления', type: 'string' }), + ApiResponse({ + status: 200, + description: 'Представление удалено', + type: Boolean, + }), + ApiNotFound('Представление не найдено'), + ApiUnauthorized(), + ApiForbidden(), + ); diff --git a/src/boards/application/dtos/boards.dto.ts b/src/boards/application/dtos/boards.dto.ts index 4e2d767..4f1b2df 100644 --- a/src/boards/application/dtos/boards.dto.ts +++ b/src/boards/application/dtos/boards.dto.ts @@ -47,6 +47,28 @@ export const UpdateBoardColumnSchema = CreateBoardColumnSchema.partial().refine( export class UpdateBoardColumnDto extends createZodDto(UpdateBoardColumnSchema) {} +export const CreateBoardViewSchema = z.object({ + type: z.enum(boardTypeEnum.enumValues), + name: z + .string() + .min(1, 'Название представления не может быть пустым') + .max(100, 'Название представления не должно превышать 100 символов'), + settings: z.record(z.string(), z.unknown()).optional(), + position: z.number().finite(), +}); + +export class CreateBoardViewDto extends createZodDto(CreateBoardViewSchema) {} + +export const UpdateBoardViewSchema = CreateBoardViewSchema.partial().refine( + (data) => Object.keys(data).length > 0, + { + error: 'Необходимо передать хотя бы одно поле для обновления', + abort: true, + }, +); + +export class UpdateBoardViewDto extends createZodDto(UpdateBoardViewSchema) {} + export const BoardColumnResponseSchema = z.object({ id: z.string().describe('ID колонки'), boardId: z.string().describe('ID доски'), diff --git a/src/boards/application/use-cases/create-board-view.use-case.ts b/src/boards/application/use-cases/create-board-view.use-case.ts new file mode 100644 index 0000000..aa23f4e --- /dev/null +++ b/src/boards/application/use-cases/create-board-view.use-case.ts @@ -0,0 +1,20 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { IBoardsRepository } from '@core/boards/domain/repository'; +import { CreateBoardViewDto } from '@core/boards/application/dtos'; +import type { BoardView } from '@core/boards/domain/entities'; + +@Injectable() +export class CreateBoardViewUseCase { + constructor( + @Inject('IBoardsRepository') + private readonly boardsRepo: IBoardsRepository, + ) {} + + public async execute( + boardId: string, + _userId: string, + dto: CreateBoardViewDto, + ): Promise { + return this.boardsRepo.createView({ boardId, ...dto }); + } +} diff --git a/src/boards/application/use-cases/delete-board-view.use-case.ts b/src/boards/application/use-cases/delete-board-view.use-case.ts new file mode 100644 index 0000000..e3e48fe --- /dev/null +++ b/src/boards/application/use-cases/delete-board-view.use-case.ts @@ -0,0 +1,14 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { IBoardsRepository } from '@core/boards/domain/repository'; + +@Injectable() +export class DeleteBoardViewUseCase { + constructor( + @Inject('IBoardsRepository') + private readonly boardsRepo: IBoardsRepository, + ) {} + + public async execute(id: string, _boardId: string, _userId: string): Promise { + return this.boardsRepo.removeView(id); + } +} diff --git a/src/boards/application/use-cases/get-board-view.query.ts b/src/boards/application/use-cases/get-board-view.query.ts new file mode 100644 index 0000000..669f702 --- /dev/null +++ b/src/boards/application/use-cases/get-board-view.query.ts @@ -0,0 +1,15 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { IBoardsRepository } from '@core/boards/domain/repository'; +import type { BoardView } from '@core/boards/domain/entities'; + +@Injectable() +export class GetBoardViewQuery { + constructor( + @Inject('IBoardsRepository') + private readonly boardsRepo: IBoardsRepository, + ) {} + + public async execute(id: string, _boardId: string, _userId: string): Promise { + return this.boardsRepo.findViewById(id); + } +} diff --git a/src/boards/application/use-cases/get-board-views.query.ts b/src/boards/application/use-cases/get-board-views.query.ts new file mode 100644 index 0000000..af3f906 --- /dev/null +++ b/src/boards/application/use-cases/get-board-views.query.ts @@ -0,0 +1,15 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { IBoardsRepository } from '@core/boards/domain/repository'; +import type { BoardView } from '@core/boards/domain/entities'; + +@Injectable() +export class GetBoardViewsQuery { + constructor( + @Inject('IBoardsRepository') + private readonly boardsRepo: IBoardsRepository, + ) {} + + public async execute(boardId: string, _userId: string): Promise { + return this.boardsRepo.findViews(boardId); + } +} diff --git a/src/boards/application/use-cases/index.ts b/src/boards/application/use-cases/index.ts index 827f407..8cf712f 100644 --- a/src/boards/application/use-cases/index.ts +++ b/src/boards/application/use-cases/index.ts @@ -8,6 +8,11 @@ import { UpdateBoardColumnUseCase } from './update-board-column.use-case'; import { DeleteBoardColumnUseCase } from './delete-board-column.use-case'; import { GetBoardColumnsQuery } from './get-board-columns.query'; import { GetBoardColumnQuery } from './get-board-column.query'; +import { CreateBoardViewUseCase } from './create-board-view.use-case'; +import { UpdateBoardViewUseCase } from './update-board-view.use-case'; +import { DeleteBoardViewUseCase } from './delete-board-view.use-case'; +import { GetBoardViewsQuery } from './get-board-views.query'; +import { GetBoardViewQuery } from './get-board-view.query'; export * from './create-board.use-case'; export * from './update-board.use-case'; @@ -19,6 +24,11 @@ export * from './update-board-column.use-case'; export * from './delete-board-column.use-case'; export * from './get-board-columns.query'; export * from './get-board-column.query'; +export * from './create-board-view.use-case'; +export * from './update-board-view.use-case'; +export * from './delete-board-view.use-case'; +export * from './get-board-views.query'; +export * from './get-board-view.query'; export const BoardUseCases = [ CreateBoardUseCase, @@ -27,10 +37,15 @@ export const BoardUseCases = [ CreateBoardColumnUseCase, UpdateBoardColumnUseCase, DeleteBoardColumnUseCase, + CreateBoardViewUseCase, + UpdateBoardViewUseCase, + DeleteBoardViewUseCase, ]; export const BoardQueries = [ GetBoardQuery, GetBoardsQuery, GetBoardColumnsQuery, GetBoardColumnQuery, + GetBoardViewsQuery, + GetBoardViewQuery, ]; diff --git a/src/boards/application/use-cases/update-board-view.use-case.ts b/src/boards/application/use-cases/update-board-view.use-case.ts new file mode 100644 index 0000000..b40091f --- /dev/null +++ b/src/boards/application/use-cases/update-board-view.use-case.ts @@ -0,0 +1,21 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { IBoardsRepository } from '@core/boards/domain/repository'; +import { UpdateBoardViewDto } from '@core/boards/application/dtos'; +import type { BoardView } from '@core/boards/domain/entities'; + +@Injectable() +export class UpdateBoardViewUseCase { + constructor( + @Inject('IBoardsRepository') + private readonly boardsRepo: IBoardsRepository, + ) {} + + public async execute( + id: string, + _boardId: string, + _userId: string, + dto: UpdateBoardViewDto, + ): Promise { + return this.boardsRepo.updateView(id, dto); + } +} diff --git a/src/boards/boards.module.ts b/src/boards/boards.module.ts index 2a7702e..4eaa918 100644 --- a/src/boards/boards.module.ts +++ b/src/boards/boards.module.ts @@ -1,6 +1,6 @@ import { Module } from '@nestjs/common'; import { BoardsRepository } from './infrastructure/persistence/repositories'; -import { BoardsController, ColumnsController } from './application/controller'; +import { BoardsController, ColumnsController, ViewsController } from './application/controller'; import { BoardsFacade } from './application/boards.facade'; import { BoardQueries, BoardUseCases } from './application/use-cases'; @@ -10,7 +10,7 @@ const REPOSITORY = { }; @Module({ - controllers: [BoardsController, ColumnsController], + controllers: [BoardsController, ColumnsController, ViewsController], providers: [REPOSITORY, BoardsFacade, ...BoardUseCases, ...BoardQueries], }) export class BoardsModule {} diff --git a/src/boards/domain/repository/boards.repository.interface.ts b/src/boards/domain/repository/boards.repository.interface.ts index fd132cc..4c5e7c1 100644 --- a/src/boards/domain/repository/boards.repository.interface.ts +++ b/src/boards/domain/repository/boards.repository.interface.ts @@ -1,6 +1,7 @@ import { Board, BoardColumn, + BoardView, BoardWithRelations, NewBoard, NewBoardColumn, @@ -22,4 +23,9 @@ export interface IBoardsRepository { createColumn(column: NewBoardColumn): Promise; updateColumn(id: string, data: Partial): Promise; removeColumn(id: string): Promise; + findViews(boardId: string): Promise; + findViewById(id: string): Promise; + createView(view: NewBoardView): Promise; + updateView(id: string, data: Partial): Promise; + removeView(id: string): Promise; } diff --git a/src/boards/infrastructure/persistence/repositories/boards.repository.ts b/src/boards/infrastructure/persistence/repositories/boards.repository.ts index e7109e9..3102775 100644 --- a/src/boards/infrastructure/persistence/repositories/boards.repository.ts +++ b/src/boards/infrastructure/persistence/repositories/boards.repository.ts @@ -194,6 +194,48 @@ export class BoardsRepository implements IBoardsRepository { return result.length > 0; } + async findViews(boardId: string): Promise { + return this.db + .select() + .from(schema.boardViews) + .where(eq(schema.boardViews.boardId, boardId)) + .orderBy(asc(schema.boardViews.position)); + } + + async findViewById(id: string): Promise { + const [view] = await this.db + .select() + .from(schema.boardViews) + .where(eq(schema.boardViews.id, id)); + + return view ?? null; + } + + async createView(view: NewBoardView): Promise { + const [created] = await this.db.insert(schema.boardViews).values(view).returning(); + + return created; + } + + async updateView(id: string, data: Partial): Promise { + const [updated] = await this.db + .update(schema.boardViews) + .set({ ...data, updatedAt: new Date() }) + .where(eq(schema.boardViews.id, id)) + .returning(); + + return updated ?? null; + } + + async removeView(id: string): Promise { + const result = await this.db + .delete(schema.boardViews) + .where(eq(schema.boardViews.id, id)) + .returning({ id: schema.boardViews.id }); + + return result.length > 0; + } + private groupByBoardId(items: T[]): Map { const grouped = new Map(); From 5e96f5e2f22c68a40f8276959125eb2e71116c6c Mon Sep 17 00:00:00 2001 From: Maxim Date: Thu, 7 May 2026 16:27:33 +0300 Subject: [PATCH 07/12] feat(boards): add k6 test script for boards CRUD operations --- infra/k6/scenarios/boards.js | 115 +++++++++++++++++++++++++++++++++++ 1 file changed, 115 insertions(+) create mode 100644 infra/k6/scenarios/boards.js diff --git a/infra/k6/scenarios/boards.js b/infra/k6/scenarios/boards.js new file mode 100644 index 0000000..6f78e6f --- /dev/null +++ b/infra/k6/scenarios/boards.js @@ -0,0 +1,115 @@ +import { SharedArray } from 'k6/data'; +import { sleep, check } from 'k6'; +import { GET_OPTIONS } from '../common/config.js'; +import getAuthUser from '../shared/get-auth-user.js'; + +const users = new SharedArray('test users', function () { + return JSON.parse(open('../data/users.json')); +}); +const teams = new SharedArray('test teams', function () { + return JSON.parse(open('../data/teams.json')); +}); +const randomStr = (len = 8) => + Math.random() + .toString(36) + .substring(2, 2 + len); +const randomNum = (min = 1, max = 100) => Math.floor(Math.random() * (max - min + 1)) + min; + +const baseOptions = GET_OPTIONS(); +baseOptions.thresholds = Object.assign({}, baseOptions.thresholds, { + 'http_req_duration{name:auth-sign-in}': ['p(95)<333'], + 'http_req_duration{name:post-teams-projects}': ['p(95)<333'], + 'http_req_duration{name:post-projects-boards}': ['p(95)<333'], + 'http_req_duration{name:get-projects-boards}': ['p(95)<333'], + 'http_req_duration{name:get-projects-boards-id}': ['p(95)<333'], + 'http_req_duration{name:patch-projects-boards}': ['p(95)<333'], + 'http_req_duration{name:delete-projects-boards}': ['p(95)<333'], + 'http_req_duration{name:delete-teams-projects}': ['p(95)<333'], +}); + +export const options = baseOptions; + +export default function () { + const user = users[(__VU - 1) % users.length]; + const team = teams[(__VU - 1) % teams.length]; + const { client } = getAuthUser(user); + + sleep(1); + + // --- create project --- + const project = { + name: `k6_board_project_${randomStr(6)}`, + key: `K6${randomNum(1000, 9999)}`, + description: 'k6 boards scenario project', + visibility: 'public', + }; + const createProjectRes = client.post(`/teams/${team.slug}/projects`, project, { + tags: { name: 'post-teams-projects' }, + }); + const projectId = createProjectRes.json().projectId; + + check(createProjectRes, { + 'POST /teams/:slug/projects: has projectId': (r) => r.json().projectId !== undefined, + }); + + sleep(1); + + // --- create board --- + const boardPayload = { + name: `k6_board_${randomStr(6)}`, + position: Date.now(), + }; + const createBoardRes = client.post(`/projects/${projectId}/boards`, boardPayload, { + tags: { name: 'post-projects-boards' }, + }); + const boardId = createBoardRes.json().id; + + check(createBoardRes, { + 'POST /projects/:id/boards: has id': (r) => r.json().id !== undefined, + }); + + sleep(1); + + // --- get all boards --- + const listRes = client.get( + `/projects/${projectId}/boards`, + {}, + { tags: { name: 'get-projects-boards' } }, + ); + check(listRes, { 'GET /projects/:id/boards: array': (r) => Array.isArray(r.json()) }); + + sleep(1); + + // --- get board --- + client.get( + `/projects/${projectId}/boards/${boardId}`, + {}, + { tags: { name: 'get-projects-boards-id' } }, + ); + + sleep(1); + + // --- update board --- + const updatedBoard = { + name: `k6_board_${randomStr(7)}`, + }; + client.patch(`/projects/${projectId}/boards/${boardId}`, updatedBoard, { + tags: { name: 'patch-projects-boards' }, + }); + + sleep(1); + + // --- delete board --- + client.delete(`/projects/${projectId}/boards/${boardId}`, { + tags: { name: 'delete-projects-boards' }, + }); + + sleep(1); + + // --- delete project --- + client.delete(`/teams/${team.slug}/projects/${projectId}`, { + tags: { name: 'delete-teams-projects' }, + }); + + sleep(1); +} From 945ea5ab75dca958ad7baba3ca9ef4a33a20fa31 Mon Sep 17 00:00:00 2001 From: Maxim Date: Thu, 7 May 2026 16:27:45 +0300 Subject: [PATCH 08/12] feat(boards): add k6 test script for boards-columns CRUD operations --- infra/k6/scenarios/boards-columns.js | 134 +++++++++++++++++++++++++++ 1 file changed, 134 insertions(+) create mode 100644 infra/k6/scenarios/boards-columns.js diff --git a/infra/k6/scenarios/boards-columns.js b/infra/k6/scenarios/boards-columns.js new file mode 100644 index 0000000..133fd36 --- /dev/null +++ b/infra/k6/scenarios/boards-columns.js @@ -0,0 +1,134 @@ +import { SharedArray } from 'k6/data'; +import { sleep, check } from 'k6'; +import { GET_OPTIONS } from '../common/config.js'; +import getAuthUser from '../shared/get-auth-user.js'; + +const users = new SharedArray('test users', function () { + return JSON.parse(open('../data/users.json')); +}); +const teams = new SharedArray('test teams', function () { + return JSON.parse(open('../data/teams.json')); +}); +const randomStr = (len = 8) => + Math.random() + .toString(36) + .substring(2, 2 + len); +const randomNum = (min = 1, max = 100) => Math.floor(Math.random() * (max - min + 1)) + min; + +const baseOptions = GET_OPTIONS(); +baseOptions.thresholds = Object.assign({}, baseOptions.thresholds, { + 'http_req_duration{name:auth-sign-in}': ['p(95)<333'], + 'http_req_duration{name:post-teams-projects}': ['p(95)<333'], + 'http_req_duration{name:post-projects-boards}': ['p(95)<333'], + 'http_req_duration{name:get-boards-columns}': ['p(95)<333'], + 'http_req_duration{name:post-boards-columns}': ['p(95)<333'], + 'http_req_duration{name:get-boards-columns-id}': ['p(95)<333'], + 'http_req_duration{name:patch-boards-columns}': ['p(95)<333'], + 'http_req_duration{name:delete-boards-columns}': ['p(95)<333'], + 'http_req_duration{name:delete-teams-projects}': ['p(95)<333'], +}); + +export const options = baseOptions; + +export default function () { + const user = users[(__VU - 1) % users.length]; + const team = teams[(__VU - 1) % teams.length]; + const { client } = getAuthUser(user); + + sleep(1); + + // --- create project --- + const project = { + name: `k6_columns_project_${randomStr(6)}`, + key: `K6${randomNum(1000, 9999)}`, + description: 'k6 columns scenario project', + visibility: 'public', + }; + const createProjectRes = client.post(`/teams/${team.slug}/projects`, project, { + tags: { name: 'post-teams-projects' }, + }); + const projectId = createProjectRes.json().projectId; + + check(createProjectRes, { + 'POST /teams/:slug/projects: has projectId': (r) => r.json().projectId !== undefined, + }); + + sleep(1); + + // --- create board --- + const boardPayload = { + name: `k6_columns_board_${randomStr(6)}`, + position: Date.now(), + }; + const createBoardRes = client.post(`/projects/${projectId}/boards`, boardPayload, { + tags: { name: 'post-projects-boards' }, + }); + const boardId = createBoardRes.json().id; + + check(createBoardRes, { + 'POST /projects/:id/boards: has id': (r) => r.json().id !== undefined, + }); + + sleep(1); + + // --- get all columns --- + const listRes = client.get( + `/boards/${boardId}/columns`, + {}, + { tags: { name: 'get-boards-columns' } }, + ); + check(listRes, { 'GET /boards/:id/columns: array': (r) => Array.isArray(r.json()) }); + + sleep(1); + + // --- create column --- + const columnPayload = { + name: `k6_column_${randomStr(6)}`, + position: 4000, + color: '#22c55e', + }; + const createColumnRes = client.post(`/boards/${boardId}/columns`, columnPayload, { + tags: { name: 'post-boards-columns' }, + }); + const columnId = createColumnRes.json().id; + + check(createColumnRes, { + 'POST /boards/:id/columns: has id': (r) => r.json().id !== undefined, + }); + + sleep(1); + + // --- get column --- + client.get( + `/boards/${boardId}/columns/${columnId}`, + {}, + { tags: { name: 'get-boards-columns-id' } }, + ); + + sleep(1); + + // --- update column --- + const updatedColumn = { + name: `k6_column_${randomStr(7)}`, + color: '#3b82f6', + }; + client.patch(`/boards/${boardId}/columns/${columnId}`, updatedColumn, { + tags: { name: 'patch-boards-columns' }, + }); + + sleep(1); + + // --- delete column --- + client.delete(`/boards/${boardId}/columns/${columnId}`, { + tags: { name: 'delete-boards-columns' }, + }); + + sleep(1); + + // --- delete project --- + client.delete(`/teams/${team.slug}/projects/${projectId}`, { + tags: { name: 'delete-teams-projects' }, + }); + + sleep(1); +} From 11830bff3194b0238c2dfae9a1bfa272a132344e Mon Sep 17 00:00:00 2001 From: Maxim Date: Thu, 7 May 2026 16:27:54 +0300 Subject: [PATCH 09/12] feat(boards): add k6 test script for boards-views CRUD and update scripts for k6 testing --- infra/k6/package.json | 5 +- infra/k6/scenarios/boards-views.js | 130 +++++++++++++++++++++++++++++ package.json | 2 +- 3 files changed, 135 insertions(+), 2 deletions(-) create mode 100644 infra/k6/scenarios/boards-views.js diff --git a/infra/k6/package.json b/infra/k6/package.json index b82ea65..9e8a28f 100644 --- a/infra/k6/package.json +++ b/infra/k6/package.json @@ -8,7 +8,10 @@ "test:teams": "k6 run scenarios/teams.js", "test:projects": "k6 run scenarios/projects.js", "test:users": "k6 run scenarios/users.js", - "test:board": "k6 run scenarios/board-full.js", + "test:boards:all": "k6 run scenarios/boards.js && k6 run scenarios/boards-columns.js && k6 run scenarios/boards-views.js", + "test:boards": "k6 run scenarios/boards.js", + "test:boards:columns": "k6 run scenarios/boards-columns.js", + "test:boards:views": "k6 run scenarios/boards-views.js", "test:tasks": "k6 run scenarios/tasks.js", "smoke": "k6 run smoke.js" }, diff --git a/infra/k6/scenarios/boards-views.js b/infra/k6/scenarios/boards-views.js new file mode 100644 index 0000000..6b7128c --- /dev/null +++ b/infra/k6/scenarios/boards-views.js @@ -0,0 +1,130 @@ +import { SharedArray } from 'k6/data'; +import { sleep, check } from 'k6'; +import { GET_OPTIONS } from '../common/config.js'; +import getAuthUser from '../shared/get-auth-user.js'; + +const users = new SharedArray('test users', function () { + return JSON.parse(open('../data/users.json')); +}); +const teams = new SharedArray('test teams', function () { + return JSON.parse(open('../data/teams.json')); +}); +const randomStr = (len = 8) => + Math.random() + .toString(36) + .substring(2, 2 + len); +const randomNum = (min = 1, max = 100) => Math.floor(Math.random() * (max - min + 1)) + min; + +const baseOptions = GET_OPTIONS(); +baseOptions.thresholds = Object.assign({}, baseOptions.thresholds, { + 'http_req_duration{name:auth-sign-in}': ['p(95)<333'], + 'http_req_duration{name:post-teams-projects}': ['p(95)<333'], + 'http_req_duration{name:post-projects-boards}': ['p(95)<333'], + 'http_req_duration{name:get-boards-views}': ['p(95)<333'], + 'http_req_duration{name:post-boards-views}': ['p(95)<333'], + 'http_req_duration{name:get-boards-views-id}': ['p(95)<333'], + 'http_req_duration{name:patch-boards-views}': ['p(95)<333'], + 'http_req_duration{name:delete-boards-views}': ['p(95)<333'], + 'http_req_duration{name:delete-teams-projects}': ['p(95)<333'], +}); + +export const options = baseOptions; + +export default function () { + const user = users[(__VU - 1) % users.length]; + const team = teams[(__VU - 1) % teams.length]; + const { client } = getAuthUser(user); + + sleep(1); + + // --- create project --- + const project = { + name: `k6_views_project_${randomStr(6)}`, + key: `K6${randomNum(1000, 9999)}`, + description: 'k6 views scenario project', + visibility: 'public', + }; + const createProjectRes = client.post(`/teams/${team.slug}/projects`, project, { + tags: { name: 'post-teams-projects' }, + }); + const projectId = createProjectRes.json().projectId; + + check(createProjectRes, { + 'POST /teams/:slug/projects: has projectId': (r) => r.json().projectId !== undefined, + }); + + sleep(1); + + // --- create board --- + const boardPayload = { + name: `k6_views_board_${randomStr(6)}`, + position: Date.now(), + }; + const createBoardRes = client.post(`/projects/${projectId}/boards`, boardPayload, { + tags: { name: 'post-projects-boards' }, + }); + const boardId = createBoardRes.json().id; + + check(createBoardRes, { + 'POST /projects/:id/boards: has id': (r) => r.json().id !== undefined, + }); + + sleep(1); + + // --- get all views --- + const listRes = client.get( + `/boards/${boardId}/views`, + {}, + { tags: { name: 'get-boards-views' } }, + ); + check(listRes, { 'GET /boards/:id/views: array': (r) => Array.isArray(r.json()) }); + + sleep(1); + + // --- create view --- + const viewPayload = { + type: 'kanban', + name: `k6_view_${randomStr(6)}`, + position: 4000, + settings: { mock: 'k6' }, + }; + const createViewRes = client.post(`/boards/${boardId}/views`, viewPayload, { + tags: { name: 'post-boards-views' }, + }); + const viewId = createViewRes.json().id; + + check(createViewRes, { + 'POST /boards/:id/views: has id': (r) => r.json().id !== undefined, + }); + + sleep(1); + + // --- get view --- + client.get(`/boards/${boardId}/views/${viewId}`, {}, { tags: { name: 'get-boards-views-id' } }); + + sleep(1); + + // --- update view --- + const updatedView = { + name: `k6_view_${randomStr(7)}`, + }; + client.patch(`/boards/${boardId}/views/${viewId}`, updatedView, { + tags: { name: 'patch-boards-views' }, + }); + + sleep(1); + + // --- delete view --- + client.delete(`/boards/${boardId}/views/${viewId}`, { + tags: { name: 'delete-boards-views' }, + }); + + sleep(1); + + // --- delete project --- + client.delete(`/teams/${team.slug}/projects/${projectId}`, { + tags: { name: 'delete-teams-projects' }, + }); + + sleep(1); +} diff --git a/package.json b/package.json index c3705c0..780be13 100644 --- a/package.json +++ b/package.json @@ -25,7 +25,7 @@ "k6:teams": "pnpm --filter @project/performance-tests test:teams", "k6:projects": "pnpm --filter @project/performance-tests test:projects", "k6:users": "pnpm --filter @project/performance-tests test:users", - "k6:board": "pnpm --filter @project/performance-tests test:board", + "k6:boards": "pnpm --filter @project/performance-tests test:boards:all", "k6:tasks": "pnpm --filter @project/performance-tests test:tasks", "k6:smoke": "pnpm --filter @project/performance-tests smoke", "k6:seed": "npx tsx infra/k6/scripts/seed-k6-data.ts", From 4458e4c9279e5ab35e0b4011ed0e2485df36e6ef Mon Sep 17 00:00:00 2001 From: Maxim Date: Thu, 7 May 2026 20:38:08 +0300 Subject: [PATCH 10/12] feat(boards): enhance board columns with status and visibility attributes --- src/boards/application/dtos/boards.dto.ts | 3 +- src/boards/domain/entities/boards.domain.ts | 4 +- src/boards/domain/factories/board.factory.ts | 46 +++++++++++++++++-- .../persistence/models/boards.model.ts | 15 ++++-- .../persistence/models/enums.ts | 11 +++++ .../persistence/models/index.ts | 1 + 6 files changed, 71 insertions(+), 9 deletions(-) create mode 100644 src/boards/infrastructure/persistence/models/enums.ts diff --git a/src/boards/application/dtos/boards.dto.ts b/src/boards/application/dtos/boards.dto.ts index 4f1b2df..26aca46 100644 --- a/src/boards/application/dtos/boards.dto.ts +++ b/src/boards/application/dtos/boards.dto.ts @@ -1,6 +1,6 @@ import { z } from 'zod/v4'; import { createZodDto } from 'nestjs-zod'; -import { boardTypeEnum } from '@core/boards/infrastructure/persistence/models/boards.model'; +import { boardTypeEnum, columnStatusEnum } from '@core/boards/infrastructure/persistence/models'; export const CreateBoardSchema = z.object({ name: z @@ -74,6 +74,7 @@ export const BoardColumnResponseSchema = z.object({ boardId: z.string().describe('ID доски'), name: z.string().describe('Название колонки'), position: z.number().describe('Позиция колонки'), + status: z.enum(columnStatusEnum.enumValues), color: z.string().describe('Цвет колонки в HEX'), createdAt: z.string().datetime().describe('Дата создания'), updatedAt: z.string().datetime().describe('Дата обновления'), diff --git a/src/boards/domain/entities/boards.domain.ts b/src/boards/domain/entities/boards.domain.ts index 8c79972..edb7813 100644 --- a/src/boards/domain/entities/boards.domain.ts +++ b/src/boards/domain/entities/boards.domain.ts @@ -4,9 +4,11 @@ import { boardViews, boardTypeEnum, boardColumns, -} from '@core/boards/infrastructure/persistence/models/boards.model'; + columnStatusEnum, +} from '@core/boards/infrastructure/persistence/models'; export type BoardType = (typeof boardTypeEnum.enumValues)[number]; +export type BoardColumnStatus = (typeof columnStatusEnum.enumValues)[number]; export type Board = InferSelectModel; export type NewBoard = InferInsertModel; diff --git a/src/boards/domain/factories/board.factory.ts b/src/boards/domain/factories/board.factory.ts index a91e1ad..69db0a0 100644 --- a/src/boards/domain/factories/board.factory.ts +++ b/src/boards/domain/factories/board.factory.ts @@ -46,10 +46,48 @@ export class BoardFactory { }), ); - const columns = [ - { id: createId(), boardId, name: 'К выполнению', position: 1000, color: '#64748b' }, - { id: createId(), boardId, name: 'В работе', position: 2000, color: '#3b82f6' }, - { id: createId(), boardId, name: 'Готово', position: 3000, color: '#22c55e' }, + const columns: NewBoardColumn[] = [ + { + id: createId(), + boardId, + name: 'Беклог', + position: 1000, + color: '#bd6f2b', + status: 'backlog', + }, + { + id: createId(), + boardId, + name: 'К выполнению', + position: 2000, + color: '#2d62ae', + status: 'todo', + }, + { + id: createId(), + boardId, + name: 'В работе', + position: 3000, + color: '#c1ab38', + status: 'in_progress', + }, + { + id: createId(), + boardId, + name: 'Готово', + position: 4000, + color: '#22c55e', + status: 'done', + }, + { + id: createId(), + boardId, + name: 'Отменено', + position: 99999, + color: '#78817b', + status: 'canceled', + visibility: false, + }, ]; return { board, columns, views }; diff --git a/src/boards/infrastructure/persistence/models/boards.model.ts b/src/boards/infrastructure/persistence/models/boards.model.ts index a7dc48d..c664430 100644 --- a/src/boards/infrastructure/persistence/models/boards.model.ts +++ b/src/boards/infrastructure/persistence/models/boards.model.ts @@ -1,9 +1,16 @@ -import { text, varchar, timestamp, jsonb, doublePrecision, uniqueIndex } from 'drizzle-orm/pg-core'; +import { + text, + varchar, + timestamp, + jsonb, + doublePrecision, + uniqueIndex, + boolean, +} from 'drizzle-orm/pg-core'; import { baseSchema, projects, users } from '@shared/entities'; +import { columnStatusEnum, boardTypeEnum } from './enums'; import { createId } from '@paralleldrive/cuid2'; -export const boardTypeEnum = baseSchema.enum('board_type', ['kanban', 'calendar', 'gantt_matrix']); - export const boards = baseSchema.table( 'boards', { @@ -33,6 +40,8 @@ export const boardColumns = baseSchema.table('board_columns', { .references(() => boards.id, { onDelete: 'cascade' }) .notNull(), name: varchar('name', { length: 50 }).notNull(), + status: columnStatusEnum('status').default('backlog').notNull(), + visibility: boolean('visibility').default(true).notNull(), position: doublePrecision('position').notNull(), color: varchar('color', { length: 7 }).default('#64748b').notNull(), diff --git a/src/boards/infrastructure/persistence/models/enums.ts b/src/boards/infrastructure/persistence/models/enums.ts new file mode 100644 index 0000000..7d4a308 --- /dev/null +++ b/src/boards/infrastructure/persistence/models/enums.ts @@ -0,0 +1,11 @@ +import { baseSchema } from '@shared/entities'; + +export const boardTypeEnum = baseSchema.enum('board_type', ['kanban', 'calendar', 'gantt_matrix']); + +export const columnStatusEnum = baseSchema.enum('column_status', [ + 'backlog', + 'todo', + 'in_progress', + 'done', + 'canceled', +]); diff --git a/src/boards/infrastructure/persistence/models/index.ts b/src/boards/infrastructure/persistence/models/index.ts index bc6e8e7..fc4838d 100644 --- a/src/boards/infrastructure/persistence/models/index.ts +++ b/src/boards/infrastructure/persistence/models/index.ts @@ -1 +1,2 @@ export * from './boards.model'; +export * from './enums'; From 3bee6698a1300fdc0e90c2ef1bae0ba41ae2dd09 Mon Sep 17 00:00:00 2001 From: Maxim Date: Fri, 8 May 2026 21:46:52 +0300 Subject: [PATCH 11/12] feat(boards): implement access validation for boards, columns, and views --- .../use-cases/create-board-column.use-case.ts | 6 +- .../use-cases/create-board-view.use-case.ts | 6 +- .../use-cases/create-board.use-case.ts | 4 + .../use-cases/delete-board-column.use-case.ts | 6 +- .../use-cases/delete-board-view.use-case.ts | 6 +- .../use-cases/delete-board.use-case.ts | 6 +- .../use-cases/get-board-column.query.ts | 12 +- .../use-cases/get-board-columns.query.ts | 6 +- .../use-cases/get-board-view.query.ts | 8 +- .../use-cases/get-board-views.query.ts | 6 +- .../application/use-cases/get-board.query.ts | 12 +- .../application/use-cases/get-boards.query.ts | 6 +- .../use-cases/update-board-column.use-case.ts | 8 +- .../use-cases/update-board-view.use-case.ts | 8 +- .../use-cases/update-board.use-case.ts | 10 +- src/boards/boards.module.ts | 5 +- .../domain/policy/board-access.policy.ts | 112 +++++++++++++++++- src/boards/domain/policy/index.ts | 4 - .../repository/boards.repository.interface.ts | 3 +- .../repositories/boards.repository.ts | 8 +- .../domain/policy/project-access.policy.ts | 47 ++++++++ src/projects/projects.module.ts | 4 +- 22 files changed, 255 insertions(+), 38 deletions(-) diff --git a/src/boards/application/use-cases/create-board-column.use-case.ts b/src/boards/application/use-cases/create-board-column.use-case.ts index e2ca285..e55c079 100644 --- a/src/boards/application/use-cases/create-board-column.use-case.ts +++ b/src/boards/application/use-cases/create-board-column.use-case.ts @@ -2,19 +2,23 @@ import { Inject, Injectable } from '@nestjs/common'; import { IBoardsRepository } from '@core/boards/domain/repository'; import { CreateBoardColumnDto } from '@core/boards/application/dtos'; import type { BoardColumn } from '@core/boards/domain/entities'; +import { BoardAccessPolicy } from '@core/boards/domain/policy'; @Injectable() export class CreateBoardColumnUseCase { constructor( @Inject('IBoardsRepository') private readonly boardsRepo: IBoardsRepository, + private readonly boardAccess: BoardAccessPolicy, ) {} public async execute( boardId: string, - _userId: string, + userId: string, dto: CreateBoardColumnDto, ): Promise { + await this.boardAccess.validateBoardAccess(boardId, userId); + return this.boardsRepo.createColumn({ boardId, ...dto }); } } diff --git a/src/boards/application/use-cases/create-board-view.use-case.ts b/src/boards/application/use-cases/create-board-view.use-case.ts index aa23f4e..d1007bd 100644 --- a/src/boards/application/use-cases/create-board-view.use-case.ts +++ b/src/boards/application/use-cases/create-board-view.use-case.ts @@ -2,19 +2,23 @@ import { Inject, Injectable } from '@nestjs/common'; import { IBoardsRepository } from '@core/boards/domain/repository'; import { CreateBoardViewDto } from '@core/boards/application/dtos'; import type { BoardView } from '@core/boards/domain/entities'; +import { BoardAccessPolicy } from '@core/boards/domain/policy'; @Injectable() export class CreateBoardViewUseCase { constructor( @Inject('IBoardsRepository') private readonly boardsRepo: IBoardsRepository, + private readonly boardAccess: BoardAccessPolicy, ) {} public async execute( boardId: string, - _userId: string, + userId: string, dto: CreateBoardViewDto, ): Promise { + await this.boardAccess.validateBoardAccess(boardId, userId); + return this.boardsRepo.createView({ boardId, ...dto }); } } diff --git a/src/boards/application/use-cases/create-board.use-case.ts b/src/boards/application/use-cases/create-board.use-case.ts index 0c12a2e..813d9fc 100644 --- a/src/boards/application/use-cases/create-board.use-case.ts +++ b/src/boards/application/use-cases/create-board.use-case.ts @@ -3,12 +3,14 @@ import { IBoardsRepository } from '@core/boards/domain/repository'; import { CreateBoardDto } from '@core/boards/application/dtos'; import { BoardFactory } from '@core/boards/domain/factories/board.factory'; import type { BoardWithRelations } from '@core/boards/domain/entities'; +import { BoardAccessPolicy } from '@core/boards/domain/policy'; @Injectable() export class CreateBoardUseCase { constructor( @Inject('IBoardsRepository') private readonly boardsRepo: IBoardsRepository, + private readonly boardAccess: BoardAccessPolicy, ) {} public async execute( @@ -16,6 +18,8 @@ export class CreateBoardUseCase { userId: string, dto: CreateBoardDto, ): Promise { + await this.boardAccess.validateProjectAccess(projectId, userId); + const { board, columns, views } = BoardFactory.createBoard(projectId, userId, dto); return this.boardsRepo.create(board, columns, views); diff --git a/src/boards/application/use-cases/delete-board-column.use-case.ts b/src/boards/application/use-cases/delete-board-column.use-case.ts index 311905a..de6eaef 100644 --- a/src/boards/application/use-cases/delete-board-column.use-case.ts +++ b/src/boards/application/use-cases/delete-board-column.use-case.ts @@ -1,14 +1,18 @@ import { Inject, Injectable } from '@nestjs/common'; import { IBoardsRepository } from '@core/boards/domain/repository'; +import { BoardAccessPolicy } from '@core/boards/domain/policy'; @Injectable() export class DeleteBoardColumnUseCase { constructor( @Inject('IBoardsRepository') private readonly boardsRepo: IBoardsRepository, + private readonly boardAccess: BoardAccessPolicy, ) {} - public async execute(id: string, _boardId: string, _userId: string): Promise { + public async execute(id: string, boardId: string, userId: string): Promise { + await this.boardAccess.validateColumnAccess(id, userId, boardId); + return this.boardsRepo.removeColumn(id); } } diff --git a/src/boards/application/use-cases/delete-board-view.use-case.ts b/src/boards/application/use-cases/delete-board-view.use-case.ts index e3e48fe..571f571 100644 --- a/src/boards/application/use-cases/delete-board-view.use-case.ts +++ b/src/boards/application/use-cases/delete-board-view.use-case.ts @@ -1,14 +1,18 @@ import { Inject, Injectable } from '@nestjs/common'; import { IBoardsRepository } from '@core/boards/domain/repository'; +import { BoardAccessPolicy } from '@core/boards/domain/policy'; @Injectable() export class DeleteBoardViewUseCase { constructor( @Inject('IBoardsRepository') private readonly boardsRepo: IBoardsRepository, + private readonly boardAccess: BoardAccessPolicy, ) {} - public async execute(id: string, _boardId: string, _userId: string): Promise { + public async execute(id: string, boardId: string, userId: string): Promise { + await this.boardAccess.validateViewAccess(id, userId, boardId); + return this.boardsRepo.removeView(id); } } diff --git a/src/boards/application/use-cases/delete-board.use-case.ts b/src/boards/application/use-cases/delete-board.use-case.ts index 80ad357..e1f667b 100644 --- a/src/boards/application/use-cases/delete-board.use-case.ts +++ b/src/boards/application/use-cases/delete-board.use-case.ts @@ -1,14 +1,18 @@ import { Inject, Injectable } from '@nestjs/common'; import { IBoardsRepository } from '@core/boards/domain/repository'; +import { BoardAccessPolicy } from '@core/boards/domain/policy'; @Injectable() export class DeleteBoardUseCase { constructor( @Inject('IBoardsRepository') private readonly boardsRepo: IBoardsRepository, + private readonly boardAccess: BoardAccessPolicy, ) {} - public async execute(id: string, _projectId: string, _userId: string): Promise { + public async execute(id: string, projectId: string, userId: string): Promise { + await this.boardAccess.validateBoardAccess(id, userId, projectId); + return this.boardsRepo.remove(id); } } diff --git a/src/boards/application/use-cases/get-board-column.query.ts b/src/boards/application/use-cases/get-board-column.query.ts index 8ba8b05..a7bc3b0 100644 --- a/src/boards/application/use-cases/get-board-column.query.ts +++ b/src/boards/application/use-cases/get-board-column.query.ts @@ -1,19 +1,19 @@ import { Inject, Injectable } from '@nestjs/common'; -import { IBoardsRepository } from '@core/boards/domain/repository'; import type { BoardColumn } from '@core/boards/domain/entities'; +import { IBoardsRepository } from '@core/boards/domain/repository'; +import { BoardAccessPolicy } from '@core/boards/domain/policy'; @Injectable() export class GetBoardColumnQuery { constructor( @Inject('IBoardsRepository') private readonly boardsRepo: IBoardsRepository, + private readonly boardAccess: BoardAccessPolicy, ) {} - public async execute( - id: string, - _boardId: string, - _userId: string, - ): Promise { + public async execute(id: string, boardId: string, userId: string): Promise { + await this.boardAccess.validateColumnAccess(id, userId, boardId); + return this.boardsRepo.findColumnById(id); } } diff --git a/src/boards/application/use-cases/get-board-columns.query.ts b/src/boards/application/use-cases/get-board-columns.query.ts index 2c24da8..22245b4 100644 --- a/src/boards/application/use-cases/get-board-columns.query.ts +++ b/src/boards/application/use-cases/get-board-columns.query.ts @@ -1,15 +1,19 @@ import { Inject, Injectable } from '@nestjs/common'; import { IBoardsRepository } from '@core/boards/domain/repository'; import type { BoardColumn } from '@core/boards/domain/entities'; +import { BoardAccessPolicy } from '@core/boards/domain/policy'; @Injectable() export class GetBoardColumnsQuery { constructor( @Inject('IBoardsRepository') private readonly boardsRepo: IBoardsRepository, + private readonly boardAccess: BoardAccessPolicy, ) {} - public async execute(boardId: string, _userId: string): Promise { + public async execute(boardId: string, userId: string): Promise { + await this.boardAccess.validateBoardAccess(boardId, userId); + return this.boardsRepo.findColumns(boardId); } } diff --git a/src/boards/application/use-cases/get-board-view.query.ts b/src/boards/application/use-cases/get-board-view.query.ts index 669f702..7155658 100644 --- a/src/boards/application/use-cases/get-board-view.query.ts +++ b/src/boards/application/use-cases/get-board-view.query.ts @@ -1,15 +1,19 @@ import { Inject, Injectable } from '@nestjs/common'; -import { IBoardsRepository } from '@core/boards/domain/repository'; import type { BoardView } from '@core/boards/domain/entities'; +import { IBoardsRepository } from '@core/boards/domain/repository'; +import { BoardAccessPolicy } from '@core/boards/domain/policy'; @Injectable() export class GetBoardViewQuery { constructor( @Inject('IBoardsRepository') private readonly boardsRepo: IBoardsRepository, + private readonly boardAccess: BoardAccessPolicy, ) {} - public async execute(id: string, _boardId: string, _userId: string): Promise { + public async execute(id: string, boardId: string, userId: string): Promise { + await this.boardAccess.validateViewAccess(id, userId, boardId); + return this.boardsRepo.findViewById(id); } } diff --git a/src/boards/application/use-cases/get-board-views.query.ts b/src/boards/application/use-cases/get-board-views.query.ts index af3f906..06ddda0 100644 --- a/src/boards/application/use-cases/get-board-views.query.ts +++ b/src/boards/application/use-cases/get-board-views.query.ts @@ -1,15 +1,19 @@ import { Inject, Injectable } from '@nestjs/common'; import { IBoardsRepository } from '@core/boards/domain/repository'; import type { BoardView } from '@core/boards/domain/entities'; +import { BoardAccessPolicy } from '@core/boards/domain/policy'; @Injectable() export class GetBoardViewsQuery { constructor( @Inject('IBoardsRepository') private readonly boardsRepo: IBoardsRepository, + private readonly boardAccess: BoardAccessPolicy, ) {} - public async execute(boardId: string, _userId: string): Promise { + public async execute(boardId: string, userId: string): Promise { + await this.boardAccess.validateBoardAccess(boardId, userId); + return this.boardsRepo.findViews(boardId); } } diff --git a/src/boards/application/use-cases/get-board.query.ts b/src/boards/application/use-cases/get-board.query.ts index 88cd30f..fce5f45 100644 --- a/src/boards/application/use-cases/get-board.query.ts +++ b/src/boards/application/use-cases/get-board.query.ts @@ -1,19 +1,23 @@ import { Inject, Injectable } from '@nestjs/common'; -import { IBoardsRepository } from '@core/boards/domain/repository'; import type { BoardWithRelations } from '@core/boards/domain/entities'; +import { IBoardsRepository } from '@core/boards/domain/repository'; +import { BoardAccessPolicy } from '@core/boards/domain/policy'; @Injectable() export class GetBoardQuery { constructor( @Inject('IBoardsRepository') private readonly boardsRepo: IBoardsRepository, + private readonly boardAccess: BoardAccessPolicy, ) {} public async execute( id: string, - _projectId: string, - _userId: string, + projectId: string, + userId: string, ): Promise { - return await this.boardsRepo.findById(id); + await this.boardAccess.validateBoardAccess(id, userId, projectId); + + return this.boardsRepo.findOne(id); } } diff --git a/src/boards/application/use-cases/get-boards.query.ts b/src/boards/application/use-cases/get-boards.query.ts index 7669e4b..72b01f4 100644 --- a/src/boards/application/use-cases/get-boards.query.ts +++ b/src/boards/application/use-cases/get-boards.query.ts @@ -1,15 +1,19 @@ import { Inject, Injectable } from '@nestjs/common'; import { IBoardsRepository } from '@core/boards/domain/repository'; import type { BoardWithRelations } from '@core/boards/domain/entities'; +import { BoardAccessPolicy } from '@core/boards/domain/policy'; @Injectable() export class GetBoardsQuery { constructor( @Inject('IBoardsRepository') private readonly boardsRepo: IBoardsRepository, + private readonly boardAccess: BoardAccessPolicy, ) {} - public async execute(projectId: string, _userId: string): Promise { + public async execute(projectId: string, userId: string): Promise { + await this.boardAccess.validateProjectAccess(projectId, userId); + return this.boardsRepo.findAll(projectId); } } diff --git a/src/boards/application/use-cases/update-board-column.use-case.ts b/src/boards/application/use-cases/update-board-column.use-case.ts index 27f0b14..86b87de 100644 --- a/src/boards/application/use-cases/update-board-column.use-case.ts +++ b/src/boards/application/use-cases/update-board-column.use-case.ts @@ -2,20 +2,24 @@ import { Inject, Injectable } from '@nestjs/common'; import { IBoardsRepository } from '@core/boards/domain/repository'; import { UpdateBoardColumnDto } from '@core/boards/application/dtos'; import type { BoardColumn } from '@core/boards/domain/entities'; +import { BoardAccessPolicy } from '@core/boards/domain/policy'; @Injectable() export class UpdateBoardColumnUseCase { constructor( @Inject('IBoardsRepository') private readonly boardsRepo: IBoardsRepository, + private readonly boardAccess: BoardAccessPolicy, ) {} public async execute( id: string, - _boardId: string, - _userId: string, + boardId: string, + userId: string, dto: UpdateBoardColumnDto, ): Promise { + await this.boardAccess.validateColumnAccess(id, userId, boardId); + return this.boardsRepo.updateColumn(id, dto); } } diff --git a/src/boards/application/use-cases/update-board-view.use-case.ts b/src/boards/application/use-cases/update-board-view.use-case.ts index b40091f..b8b9e0e 100644 --- a/src/boards/application/use-cases/update-board-view.use-case.ts +++ b/src/boards/application/use-cases/update-board-view.use-case.ts @@ -2,20 +2,24 @@ import { Inject, Injectable } from '@nestjs/common'; import { IBoardsRepository } from '@core/boards/domain/repository'; import { UpdateBoardViewDto } from '@core/boards/application/dtos'; import type { BoardView } from '@core/boards/domain/entities'; +import { BoardAccessPolicy } from '@core/boards/domain/policy'; @Injectable() export class UpdateBoardViewUseCase { constructor( @Inject('IBoardsRepository') private readonly boardsRepo: IBoardsRepository, + private readonly boardAccess: BoardAccessPolicy, ) {} public async execute( id: string, - _boardId: string, - _userId: string, + boardId: string, + userId: string, dto: UpdateBoardViewDto, ): Promise { + await this.boardAccess.validateViewAccess(id, userId, boardId); + return this.boardsRepo.updateView(id, dto); } } diff --git a/src/boards/application/use-cases/update-board.use-case.ts b/src/boards/application/use-cases/update-board.use-case.ts index 9020f0f..1e517e6 100644 --- a/src/boards/application/use-cases/update-board.use-case.ts +++ b/src/boards/application/use-cases/update-board.use-case.ts @@ -2,20 +2,24 @@ import { Inject, Injectable } from '@nestjs/common'; import { IBoardsRepository } from '@core/boards/domain/repository'; import { UpdateBoardDto } from '@core/boards/application/dtos'; import type { BoardWithRelations } from '@core/boards/domain/entities'; +import { BoardAccessPolicy } from '@core/boards/domain/policy'; @Injectable() export class UpdateBoardUseCase { constructor( @Inject('IBoardsRepository') private readonly boardsRepo: IBoardsRepository, + private readonly boardAccess: BoardAccessPolicy, ) {} public async execute( id: string, - _projectId: string, - _userId: string, + projectId: string, + userId: string, dto: UpdateBoardDto, ): Promise { - return await this.boardsRepo.update(id, dto); + await this.boardAccess.validateBoardAccess(id, userId, projectId); + + return this.boardsRepo.update(id, dto); } } diff --git a/src/boards/boards.module.ts b/src/boards/boards.module.ts index 4eaa918..4cd8363 100644 --- a/src/boards/boards.module.ts +++ b/src/boards/boards.module.ts @@ -1,8 +1,10 @@ import { Module } from '@nestjs/common'; +import { ProjectsModule } from '@core/projects'; import { BoardsRepository } from './infrastructure/persistence/repositories'; import { BoardsController, ColumnsController, ViewsController } from './application/controller'; import { BoardsFacade } from './application/boards.facade'; import { BoardQueries, BoardUseCases } from './application/use-cases'; +import { BoardAccessPolicy } from './domain/policy'; const REPOSITORY = { provide: 'IBoardsRepository', @@ -10,7 +12,8 @@ const REPOSITORY = { }; @Module({ + imports: [ProjectsModule], controllers: [BoardsController, ColumnsController, ViewsController], - providers: [REPOSITORY, BoardsFacade, ...BoardUseCases, ...BoardQueries], + providers: [REPOSITORY, BoardAccessPolicy, BoardsFacade, ...BoardUseCases, ...BoardQueries], }) export class BoardsModule {} diff --git a/src/boards/domain/policy/board-access.policy.ts b/src/boards/domain/policy/board-access.policy.ts index 0b584ae..415a5d6 100644 --- a/src/boards/domain/policy/board-access.policy.ts +++ b/src/boards/domain/policy/board-access.policy.ts @@ -1,4 +1,112 @@ -import { Injectable } from '@nestjs/common'; +import { HttpStatus, Inject, Injectable } from '@nestjs/common'; +import { IBoardsRepository } from '@core/boards/domain/repository'; +import { BaseException } from '@shared/error'; +import { ProjectAccessPolicy } from '@core/projects/domain/policy'; @Injectable() -export class BoardAccessPolicy {} +export class BoardAccessPolicy { + constructor( + @Inject('IBoardsRepository') + private readonly boardsRepo: IBoardsRepository, + private readonly projectAccessPolicy: ProjectAccessPolicy, + ) {} + + public async validateProjectAccess(projectId: string, userId: string): Promise { + await this.projectAccessPolicy.validateProjectAccessById(projectId, userId, 'viewer'); + + const permissions = true; + if (!permissions) { + throw new BaseException( + { code: 'ACCESS_DENIED', message: 'Недостаточно прав для доступа к проекту' }, + HttpStatus.FORBIDDEN, + ); + } + } + + public async validateBoardAccess( + boardId: string, + userId: string, + expectedProjectId?: string, + ): Promise { + const board = await this.boardsRepo.findBoardById(boardId); + + if (!board || (expectedProjectId && board.projectId !== expectedProjectId)) { + throw new BaseException( + { + code: 'BOARD_NOT_FOUND', + message: 'Доска не найдена', + details: [{ target: 'boardId', value: boardId }], + }, + HttpStatus.NOT_FOUND, + ); + } + + await this.validateProjectAccess(board.projectId, userId); + + const permissions = true; + if (!permissions) { + throw new BaseException( + { code: 'ACCESS_DENIED', message: 'Недостаточно прав для доступа к доске' }, + HttpStatus.FORBIDDEN, + ); + } + } + + public async validateColumnAccess( + columnId: string, + userId: string, + expectedBoardId?: string, + ): Promise { + const column = await this.boardsRepo.findColumnById(columnId); + + if (!column || (expectedBoardId && column.boardId !== expectedBoardId)) { + throw new BaseException( + { + code: 'BOARD_COLUMN_NOT_FOUND', + message: 'Колонка не найдена', + details: [{ target: 'columnId', value: columnId }], + }, + HttpStatus.NOT_FOUND, + ); + } + + await this.validateBoardAccess(column.boardId, userId); + + const permissions = true; + if (!permissions) { + throw new BaseException( + { code: 'ACCESS_DENIED', message: 'Недостаточно прав для доступа к колонке' }, + HttpStatus.FORBIDDEN, + ); + } + } + + public async validateViewAccess( + viewId: string, + userId: string, + expectedBoardId?: string, + ): Promise { + const view = await this.boardsRepo.findViewById(viewId); + + if (!view || (expectedBoardId && view.boardId !== expectedBoardId)) { + throw new BaseException( + { + code: 'BOARD_VIEW_NOT_FOUND', + message: 'Представление не найдено', + details: [{ target: 'viewId', value: viewId }], + }, + HttpStatus.NOT_FOUND, + ); + } + + await this.validateBoardAccess(view.boardId, userId); + + const permissions = true; + if (!permissions) { + throw new BaseException( + { code: 'ACCESS_DENIED', message: 'Недостаточно прав для доступа к представлению' }, + HttpStatus.FORBIDDEN, + ); + } + } +} diff --git a/src/boards/domain/policy/index.ts b/src/boards/domain/policy/index.ts index 9186dac..29116c3 100644 --- a/src/boards/domain/policy/index.ts +++ b/src/boards/domain/policy/index.ts @@ -1,5 +1 @@ -import { BoardAccessPolicy } from './board-access.policy'; - export * from './board-access.policy'; - -export const POLICIES = [BoardAccessPolicy]; diff --git a/src/boards/domain/repository/boards.repository.interface.ts b/src/boards/domain/repository/boards.repository.interface.ts index 4c5e7c1..e495796 100644 --- a/src/boards/domain/repository/boards.repository.interface.ts +++ b/src/boards/domain/repository/boards.repository.interface.ts @@ -10,7 +10,8 @@ import { export interface IBoardsRepository { findAll(projectId: string): Promise; - findById(id: string): Promise; + findOne(id: string): Promise; + findBoardById(id: string): Promise; create( board: NewBoard, columns: NewBoardColumn[], diff --git a/src/boards/infrastructure/persistence/repositories/boards.repository.ts b/src/boards/infrastructure/persistence/repositories/boards.repository.ts index 3102775..d2ef6b6 100644 --- a/src/boards/infrastructure/persistence/repositories/boards.repository.ts +++ b/src/boards/infrastructure/persistence/repositories/boards.repository.ts @@ -55,7 +55,7 @@ export class BoardsRepository implements IBoardsRepository { })); } - async findById(id: string): Promise { + async findOne(id: string): Promise { const [board] = await this.db.select().from(schema.boards).where(eq(schema.boards.id, id)); if (!board) { @@ -82,6 +82,12 @@ export class BoardsRepository implements IBoardsRepository { }; } + async findBoardById(id: string): Promise { + const [board] = await this.db.select().from(schema.boards).where(eq(schema.boards.id, id)); + + return board ?? null; + } + async create( board: NewBoard, columns: NewBoardColumn[], diff --git a/src/projects/domain/policy/project-access.policy.ts b/src/projects/domain/policy/project-access.policy.ts index 7001ebf..89a4df9 100644 --- a/src/projects/domain/policy/project-access.policy.ts +++ b/src/projects/domain/policy/project-access.policy.ts @@ -75,4 +75,51 @@ export class ProjectAccessPolicy { return { project, member, team }; } + + /** + * Проверка доступа к проекту по projectId (без slug) + */ + public async validateProjectAccessById( + projectId: string, + userId: string, + minRole: keyof typeof ROLE_PRIORITY = 'viewer', + ) { + const project = await this.projectsRepo.findOne(projectId); + if (!project) { + throw new BaseException( + { code: 'PROJECT_NOT_FOUND', message: 'Проект не найден' }, + HttpStatus.NOT_FOUND, + ); + } + + const member = await this.findTeamMemberQ.execute(project.teamId, userId); + if (!member) { + throw new BaseException( + { code: 'NOT_TEAM_MEMBER', message: 'Вы не участник команды' }, + HttpStatus.FORBIDDEN, + ); + } + + // TODO: replace with project members query + const isProjectMember = true; + if (!isProjectMember) { + throw new BaseException( + { code: 'ACCESS_DENIED', message: 'Вы не являетесь участником этого проекта' }, + HttpStatus.FORBIDDEN, + ); + } + + if (ROLE_PRIORITY[member.role] < ROLE_PRIORITY[minRole]) { + throw new BaseException( + { + code: 'INSUFFICIENT_PERMISSIONS', + message: `Требуется роль ${minRole} или выше`, + details: [{ target: 'role', current: member.role, required: minRole }], + }, + HttpStatus.FORBIDDEN, + ); + } + + return { project, member }; + } } diff --git a/src/projects/projects.module.ts b/src/projects/projects.module.ts index 4a74316..3689739 100644 --- a/src/projects/projects.module.ts +++ b/src/projects/projects.module.ts @@ -3,7 +3,7 @@ import { ProjectsRepository } from './infrastructure/persistence/repositories'; import { TeamsModule } from '@core/teams'; import { ProjectsController } from './application/controller'; import { FindProjectQuery, ProjectQueries, ProjectUseCases } from './application/use-cases'; -import { POLICIES } from './domain/policy'; +import { POLICIES, ProjectAccessPolicy } from './domain/policy'; import { ProjectsFacade } from './application/projects.facade'; const REPOSITORY = { @@ -15,6 +15,6 @@ const REPOSITORY = { imports: [forwardRef(() => TeamsModule)], controllers: [ProjectsController], providers: [REPOSITORY, ...POLICIES, ...ProjectUseCases, ...ProjectQueries, ProjectsFacade], - exports: [FindProjectQuery], + exports: [FindProjectQuery, ProjectAccessPolicy], }) export class ProjectsModule {} From e719c3f4d97ba21863645697ddbab6c2d5ba8365 Mon Sep 17 00:00:00 2001 From: Maxim Date: Fri, 8 May 2026 21:47:43 +0300 Subject: [PATCH 12/12] feat(boards): add migrations for boards, columns, and views --- migrations/0006_new_omega_red.sql | 41 + migrations/meta/0006_snapshot.json | 1448 ++++++++++++++++++++++++++++ migrations/meta/_journal.json | 7 + 3 files changed, 1496 insertions(+) create mode 100644 migrations/0006_new_omega_red.sql create mode 100644 migrations/meta/0006_snapshot.json diff --git a/migrations/0006_new_omega_red.sql b/migrations/0006_new_omega_red.sql new file mode 100644 index 0000000..3dfc12b --- /dev/null +++ b/migrations/0006_new_omega_red.sql @@ -0,0 +1,41 @@ +CREATE TYPE "base"."board_type" AS ENUM('kanban', 'calendar', 'gantt_matrix');--> statement-breakpoint +CREATE TYPE "base"."column_status" AS ENUM('backlog', 'todo', 'in_progress', 'done', 'canceled');--> statement-breakpoint +CREATE TABLE "base"."board_columns" ( + "id" text PRIMARY KEY NOT NULL, + "board_id" text NOT NULL, + "name" varchar(50) NOT NULL, + "status" "base"."column_status" DEFAULT 'backlog' NOT NULL, + "visibility" boolean DEFAULT true NOT NULL, + "position" double precision NOT NULL, + "color" varchar(7) DEFAULT '#64748b' NOT NULL, + "created_at" timestamp DEFAULT now() NOT NULL, + "updated_at" timestamp DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "base"."boards_views" ( + "id" text PRIMARY KEY NOT NULL, + "board_id" text NOT NULL, + "type" "base"."board_type" DEFAULT 'kanban' NOT NULL, + "name" varchar(100) NOT NULL, + "settings" jsonb DEFAULT '{}'::jsonb NOT NULL, + "position" double precision NOT NULL, + "created_at" timestamp DEFAULT now() NOT NULL, + "updated_at" timestamp DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "base"."boards" ( + "id" text PRIMARY KEY NOT NULL, + "name" varchar(100) NOT NULL, + "project_id" text NOT NULL, + "settings" jsonb DEFAULT '{}'::jsonb NOT NULL, + "position" double precision NOT NULL, + "owner_id" text, + "created_at" timestamp DEFAULT now() NOT NULL, + "updated_at" timestamp DEFAULT now() NOT NULL +); +--> statement-breakpoint +ALTER TABLE "base"."board_columns" ADD CONSTRAINT "board_columns_board_id_boards_id_fk" FOREIGN KEY ("board_id") REFERENCES "base"."boards"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "base"."boards_views" ADD CONSTRAINT "boards_views_board_id_boards_id_fk" FOREIGN KEY ("board_id") REFERENCES "base"."boards"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "base"."boards" ADD CONSTRAINT "boards_project_id_projects_id_fk" FOREIGN KEY ("project_id") REFERENCES "base"."projects"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "base"."boards" ADD CONSTRAINT "boards_owner_id_users_id_fk" FOREIGN KEY ("owner_id") REFERENCES "base"."users"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +CREATE UNIQUE INDEX "project_board_name_idx" ON "base"."boards" USING btree ("project_id","name"); \ No newline at end of file diff --git a/migrations/meta/0006_snapshot.json b/migrations/meta/0006_snapshot.json new file mode 100644 index 0000000..7cf3011 --- /dev/null +++ b/migrations/meta/0006_snapshot.json @@ -0,0 +1,1448 @@ +{ + "id": "dc5b8c53-d095-42c8-bceb-e8105f8f2ad8", + "prevId": "4cc11042-2c5e-4ffe-bf71-faedea5219e3", + "version": "7", + "dialect": "postgresql", + "tables": { + "base.user_activity": { + "name": "user_activity", + "schema": "base", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "event_type": { + "name": "event_type", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "entity_id": { + "name": "entity_id", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "user_activity_user_id_users_id_fk": { + "name": "user_activity_user_id_users_id_fk", + "tableFrom": "user_activity", + "tableTo": "users", + "schemaTo": "base", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "base.user_notifications": { + "name": "user_notifications", + "schema": "base", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "settings": { + "name": "settings", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{\"email\":{\"task_assigned\":true,\"mentions\":true,\"daily_summary\":false},\"push\":{\"task_assigned\":true,\"reminders\":true}}'::jsonb" + } + }, + "indexes": {}, + "foreignKeys": { + "user_notifications_user_id_users_id_fk": { + "name": "user_notifications_user_id_users_id_fk", + "tableFrom": "user_notifications", + "tableTo": "users", + "schemaTo": "base", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "base.user_security": { + "name": "user_security", + "schema": "base", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "password_hash": { + "name": "password_hash", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "is_2fa_enabled": { + "name": "is_2fa_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "two_factor_secret": { + "name": "two_factor_secret", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_password_change": { + "name": "last_password_change", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "user_security_user_id_users_id_fk": { + "name": "user_security_user_id_users_id_fk", + "tableFrom": "user_security", + "tableTo": "users", + "schemaTo": "base", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "base.users": { + "name": "users", + "schema": "base", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "first_name": { + "name": "first_name", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "last_name": { + "name": "last_name", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "middle_name": { + "name": "middle_name", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "bio": { + "name": "bio", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "avatar_url": { + "name": "avatar_url", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false + }, + "timezone": { + "name": "timezone", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true, + "default": "'UTC'" + }, + "language": { + "name": "language", + "type": "varchar(5)", + "primaryKey": false, + "notNull": true, + "default": "'ru'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users_email_unique": { + "name": "users_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "base.sessions": { + "name": "sessions", + "schema": "base", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "device_type": { + "name": "device_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "browser": { + "name": "browser", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + }, + "os": { + "name": "os", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "ip": { + "name": "ip", + "type": "varchar(45)", + "primaryKey": false, + "notNull": true + }, + "city": { + "name": "city", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "country_code": { + "name": "country_code", + "type": "varchar(5)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "is_revoked": { + "name": "is_revoked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": {}, + "foreignKeys": { + "sessions_user_id_users_id_fk": { + "name": "sessions_user_id_users_id_fk", + "tableFrom": "sessions", + "tableTo": "users", + "schemaTo": "base", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "base.tags": { + "name": "tags", + "schema": "base", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "tags_name_unique": { + "name": "tags_name_unique", + "nullsNotDistinct": false, + "columns": [ + "name" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "base.team_members": { + "name": "team_members", + "schema": "base", + "columns": { + "team_id": { + "name": "team_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "team_role", + "typeSchema": "base", + "primaryKey": false, + "notNull": true, + "default": "'member'" + }, + "status": { + "name": "status", + "type": "member_status", + "typeSchema": "base", + "primaryKey": false, + "notNull": true, + "default": "'inactive'" + }, + "joined_at": { + "name": "joined_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "member_status_idx": { + "name": "member_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "member_role_idx": { + "name": "member_role_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "role", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "team_members_team_id_teams_id_fk": { + "name": "team_members_team_id_teams_id_fk", + "tableFrom": "team_members", + "tableTo": "teams", + "schemaTo": "base", + "columnsFrom": [ + "team_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "team_members_user_id_users_id_fk": { + "name": "team_members_user_id_users_id_fk", + "tableFrom": "team_members", + "tableTo": "users", + "schemaTo": "base", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "team_members_team_id_user_id_pk": { + "name": "team_members_team_id_user_id_pk", + "columns": [ + "team_id", + "user_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "base.teams": { + "name": "teams", + "schema": "base", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "varchar(120)", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "avatar_url": { + "name": "avatar_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cover_url": { + "name": "cover_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "owner_id": { + "name": "owner_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "team_active_slug_idx": { + "name": "team_active_slug_idx", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"base\".\"teams\".\"deleted_at\" is null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "team_slug_idx": { + "name": "team_slug_idx", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "team_owner_idx": { + "name": "team_owner_idx", + "columns": [ + { + "expression": "owner_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "team_deleted_at_idx": { + "name": "team_deleted_at_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "teams_owner_id_users_id_fk": { + "name": "teams_owner_id_users_id_fk", + "tableFrom": "teams", + "tableTo": "users", + "schemaTo": "base", + "columnsFrom": [ + "owner_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "teams_slug_unique": { + "name": "teams_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "slug" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "base.teams_to_tags": { + "name": "teams_to_tags", + "schema": "base", + "columns": { + "team_id": { + "name": "team_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tag_id": { + "name": "tag_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "teams_to_tags_tag_id_idx": { + "name": "teams_to_tags_tag_id_idx", + "columns": [ + { + "expression": "tag_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "teams_to_tags_team_id_teams_id_fk": { + "name": "teams_to_tags_team_id_teams_id_fk", + "tableFrom": "teams_to_tags", + "tableTo": "teams", + "schemaTo": "base", + "columnsFrom": [ + "team_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "teams_to_tags_tag_id_tags_id_fk": { + "name": "teams_to_tags_tag_id_tags_id_fk", + "tableFrom": "teams_to_tags", + "tableTo": "tags", + "schemaTo": "base", + "columnsFrom": [ + "tag_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "teams_to_tags_team_id_tag_id_pk": { + "name": "teams_to_tags_team_id_tag_id_pk", + "columns": [ + "team_id", + "tag_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "base.project_shares": { + "name": "project_shares", + "schema": "base", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "token_idx": { + "name": "token_idx", + "columns": [ + { + "expression": "token", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "project_share_project_id_idx": { + "name": "project_share_project_id_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "project_shares_project_id_projects_id_fk": { + "name": "project_shares_project_id_projects_id_fk", + "tableFrom": "project_shares", + "tableTo": "projects", + "schemaTo": "base", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "project_shares_token_unique": { + "name": "project_shares_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "base.projects": { + "name": "projects", + "schema": "base", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "team_id": { + "name": "team_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "varchar(10)", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "icon": { + "name": "icon", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "color": { + "name": "color", + "type": "varchar(7)", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "project_status", + "typeSchema": "base", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "task_sequence": { + "name": "task_sequence", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "owner_id": { + "name": "owner_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "visibility": { + "name": "visibility", + "type": "project_visibility", + "typeSchema": "base", + "primaryKey": false, + "notNull": true, + "default": "'public'" + }, + "settings": { + "name": "settings", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "project_team_key_idx": { + "name": "project_team_key_idx", + "columns": [ + { + "expression": "team_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"base\".\"projects\".\"deleted_at\" is null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "project_team_name_idx": { + "name": "project_team_name_idx", + "columns": [ + { + "expression": "team_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"base\".\"projects\".\"deleted_at\" is null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "project_owner_id_idx": { + "name": "project_owner_id_idx", + "columns": [ + { + "expression": "owner_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "project_team_id_idx": { + "name": "project_team_id_idx", + "columns": [ + { + "expression": "team_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "projects_team_id_teams_id_fk": { + "name": "projects_team_id_teams_id_fk", + "tableFrom": "projects", + "tableTo": "teams", + "schemaTo": "base", + "columnsFrom": [ + "team_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "projects_owner_id_users_id_fk": { + "name": "projects_owner_id_users_id_fk", + "tableFrom": "projects", + "tableTo": "users", + "schemaTo": "base", + "columnsFrom": [ + "owner_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "base.board_columns": { + "name": "board_columns", + "schema": "base", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "board_id": { + "name": "board_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "column_status", + "typeSchema": "base", + "primaryKey": false, + "notNull": true, + "default": "'backlog'" + }, + "visibility": { + "name": "visibility", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "position": { + "name": "position", + "type": "double precision", + "primaryKey": false, + "notNull": true + }, + "color": { + "name": "color", + "type": "varchar(7)", + "primaryKey": false, + "notNull": true, + "default": "'#64748b'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "board_columns_board_id_boards_id_fk": { + "name": "board_columns_board_id_boards_id_fk", + "tableFrom": "board_columns", + "tableTo": "boards", + "schemaTo": "base", + "columnsFrom": [ + "board_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "base.boards_views": { + "name": "boards_views", + "schema": "base", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "board_id": { + "name": "board_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "board_type", + "typeSchema": "base", + "primaryKey": false, + "notNull": true, + "default": "'kanban'" + }, + "name": { + "name": "name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "settings": { + "name": "settings", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "position": { + "name": "position", + "type": "double precision", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "boards_views_board_id_boards_id_fk": { + "name": "boards_views_board_id_boards_id_fk", + "tableFrom": "boards_views", + "tableTo": "boards", + "schemaTo": "base", + "columnsFrom": [ + "board_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "base.boards": { + "name": "boards", + "schema": "base", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "settings": { + "name": "settings", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "position": { + "name": "position", + "type": "double precision", + "primaryKey": false, + "notNull": true + }, + "owner_id": { + "name": "owner_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "project_board_name_idx": { + "name": "project_board_name_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "boards_project_id_projects_id_fk": { + "name": "boards_project_id_projects_id_fk", + "tableFrom": "boards", + "tableTo": "projects", + "schemaTo": "base", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "boards_owner_id_users_id_fk": { + "name": "boards_owner_id_users_id_fk", + "tableFrom": "boards", + "tableTo": "users", + "schemaTo": "base", + "columnsFrom": [ + "owner_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "base.team_role": { + "name": "team_role", + "schema": "base", + "values": [ + "owner", + "admin", + "lead", + "moderator", + "member", + "viewer" + ] + }, + "base.member_status": { + "name": "member_status", + "schema": "base", + "values": [ + "active", + "banned", + "inactive" + ] + }, + "base.project_status": { + "name": "project_status", + "schema": "base", + "values": [ + "active", + "archived", + "template" + ] + }, + "base.project_visibility": { + "name": "project_visibility", + "schema": "base", + "values": [ + "public", + "private" + ] + }, + "base.board_type": { + "name": "board_type", + "schema": "base", + "values": [ + "kanban", + "calendar", + "gantt_matrix" + ] + }, + "base.column_status": { + "name": "column_status", + "schema": "base", + "values": [ + "backlog", + "todo", + "in_progress", + "done", + "canceled" + ] + } + }, + "schemas": { + "base": "base" + }, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/migrations/meta/_journal.json b/migrations/meta/_journal.json index baeab62..0c1a548 100644 --- a/migrations/meta/_journal.json +++ b/migrations/meta/_journal.json @@ -43,6 +43,13 @@ "when": 1776614072462, "tag": "0005_calm_vivisector", "breakpoints": true + }, + { + "idx": 6, + "version": "7", + "when": 1778174900584, + "tag": "0006_new_omega_red", + "breakpoints": true } ] } \ No newline at end of file