From 3a9c0665a85b7156353d17731b3aef24fe466c5c Mon Sep 17 00:00:00 2001 From: soorq Date: Sun, 7 Jun 2026 23:00:13 +0300 Subject: [PATCH 1/5] feat(states): add rest design for project statuses #1 --- src/projects/application/controller/index.ts | 5 +- .../controller/project-states/controller.ts | 73 +++++ .../controller/project-states/swagger.ts | 298 ++++++++++++++++++ src/projects/application/dtos/projects.dto.ts | 2 +- .../application/mappers/projects.mapper.ts | 8 +- .../use-cases/create-project.use-case.ts | 2 +- .../use-cases/update-project.use-case.ts | 4 +- .../persistence/models/enums.ts | 25 +- .../persistence/models/projects.model.ts | 83 ++++- src/projects/projects.module.ts | 13 +- 10 files changed, 485 insertions(+), 28 deletions(-) create mode 100644 src/projects/application/controller/project-states/controller.ts create mode 100644 src/projects/application/controller/project-states/swagger.ts diff --git a/src/projects/application/controller/index.ts b/src/projects/application/controller/index.ts index 4c96d63..bd5ef35 100644 --- a/src/projects/application/controller/index.ts +++ b/src/projects/application/controller/index.ts @@ -1 +1,4 @@ -export { ProjectsController } from './projects/controller'; +import { ProjectsStatesController } from './project-states/controller'; +import { ProjectsController } from './projects/controller'; + +export const CONTROLLERS = [ProjectsController, ProjectsStatesController]; diff --git a/src/projects/application/controller/project-states/controller.ts b/src/projects/application/controller/project-states/controller.ts new file mode 100644 index 0000000..9f2ad4f --- /dev/null +++ b/src/projects/application/controller/project-states/controller.ts @@ -0,0 +1,73 @@ +import { Body, Delete, Get, Param, Patch, Post, Query } from '@nestjs/common'; +import { ApiBaseController, Public } from '@shared/decorators'; +import { + CreateProjectStateSwagger, + FindAllProjectStatesSwagger, + FindOneProjectStateSwagger, + RemoveProjectStateSwagger, + ReorderProjectStatesSwagger, + RestoreProjectStateSwagger, + UpdateProjectStateSwagger, +} from './swagger'; +import { ProjectsFacade } from '../../projects.facade'; + +@ApiBaseController('projects/:slug/states', 'Project States', true) +export class ProjectsStatesController { + constructor(private readonly facade: ProjectsFacade) { + void this.facade; + } + + @Get() + @Public() + @FindAllProjectStatesSwagger() + async getAll( + @Param('projectId') projectId: string, + @Query('hidden') hidden?: boolean, + @Query('counts') counts?: boolean, + @Query('my') my?: boolean, + @Query('category') category?: string, + @Query('overdue') overdue?: boolean, + ) { + return { projectId, hidden, category, my, counts, overdue }; + } + + @Get(':stateSlug') + @FindOneProjectStateSwagger() + async findOne(@Param('slug') slug: string, @Param('stateSlug') stateSlug: string) { + return { slug, stateSlug }; + } + + @Post() + @CreateProjectStateSwagger() + async create(@Param('slug') slug: string, @Body() dto: any) { + return { slug, dto }; + } + + @Delete(':stateSlug') + @RemoveProjectStateSwagger() + async delete(@Param('slug') slug: string, @Param('stateSlug') stateSlug: string) { + return { slug, stateSlug }; + } + + @Patch('reorder') + @ReorderProjectStatesSwagger() + async reorder(@Param('slug') slug: string, @Body() dto: unknown) { + return { slug, dto }; + } + + @Patch(':stateSlug') + @UpdateProjectStateSwagger() + async update( + @Param('slug') slug: string, + @Param('stateSlug') stateSlug: string, + @Body() dto: unknown, + ) { + return { slug, stateSlug, dto }; + } + + @Post(':stateSlug/restore') + @RestoreProjectStateSwagger() + async restore(@Param('slug') slug: string, @Param('stateSlug') stateSlug: string) { + return { slug, stateSlug }; + } +} diff --git a/src/projects/application/controller/project-states/swagger.ts b/src/projects/application/controller/project-states/swagger.ts new file mode 100644 index 0000000..218167f --- /dev/null +++ b/src/projects/application/controller/project-states/swagger.ts @@ -0,0 +1,298 @@ +import { applyDecorators, SetMetadata } from '@nestjs/common'; +import { ApiOperation, ApiParam, ApiQuery, ApiResponse, ApiBody } from '@nestjs/swagger'; +import { ActionResponse } from '@shared/dtos'; +import { + ApiUnauthorized, + ApiNotFound, + ApiValidationError, + ApiForbidden, + ApiConflict, +} from '@shared/error'; +import { ZOD_RESPONSE_TOKEN } from '@shared/interceptors'; + +export const FindAllProjectStatesSwagger = () => + applyDecorators( + ApiOperation({ + summary: 'Получить все состояния проекта', + description: 'Возвращает список всех статусов (колонок) проекта с их настройками', + }), + ApiParam({ + name: 'projectId', + type: 'string', + description: 'CUID проекта', + example: 'clv123456', + }), + ApiQuery({ + name: 'hidden', + required: false, + type: Boolean, + description: 'Показать скрытые статусы', + example: 'false', + }), + ApiQuery({ + name: 'category', + required: false, + enum: ['backlog', 'active', 'review', 'completed', 'archived'], + description: 'Фильтр по категории статусов', + example: 'active', + }), + ApiQuery({ + name: 'counts', + required: false, + type: Boolean, + description: 'Добавить количество задач в каждом статусе (tasksCount)', + example: 'true', + }), + ApiQuery({ + name: 'my', + required: false, + type: Boolean, + description: 'Показать только мои задачи (добавляет myTasksCount)', + example: 'false', + }), + ApiQuery({ + name: 'overdue', + required: false, + type: Boolean, + description: + 'Добавить информацию о просроченных задачах (hasOverdueTasks, overdueTasksCount)', + example: 'true', + }), + ApiResponse({ + status: 200, + description: 'Список состояний получен', + // type: ProjectStateListResponse.Output, + }), + ApiUnauthorized(), + ApiNotFound('Проект не найден'), + + // SetMetadata(ZOD_RESPONSE_TOKEN, ProjectStateListResponse), + ); + +export const FindOneProjectStateSwagger = () => + applyDecorators( + ApiOperation({ + summary: 'Получить детальную информацию о состоянии', + description: 'Возвращает полную информацию о статусе проекта', + }), + ApiParam({ + name: 'projectId', + type: 'string', + description: 'CUID проекта', + example: 'clv123456', + }), + ApiParam({ + name: 'stateId', + type: 'string', + description: 'CUID состояния', + example: 'clv789012', + }), + ApiResponse({ + status: 200, + description: 'Информация о состоянии получена', + // type: ProjectStateDetailResponse.Output, + }), + ApiNotFound('Состояние не найдено'), + ApiUnauthorized(), + + // SetMetadata(ZOD_RESPONSE_TOKEN, ProjectStateDetailResponse), + ); + +export const CreateProjectStateSwagger = () => + applyDecorators( + ApiOperation({ + summary: 'Создать новое состояние', + description: + 'Создаёт новый статус (колонку) для проекта. Можно указать тип, иконку, цвет и WIP лимит.', + }), + ApiParam({ + name: 'projectId', + type: 'string', + description: 'CUID проекта', + example: 'clv123456', + }), + ApiBody({ + // type: CreateProjectStateDto.Output, + description: 'Данные для создания состояния', + }), + ApiResponse({ + status: 201, + description: 'Состояние успешно создано', + // type: CreateProjectStateResponse.Output, + }), + ApiValidationError(), + ApiUnauthorized(), + ApiForbidden('Нет прав для создания состояния в этом проекте'), + ApiConflict('Состояние с таким названием или типом уже существует'), + + // SetMetadata(ZOD_RESPONSE_TOKEN, CreateProjectStateResponse), + ); + +export const UpdateProjectStateSwagger = () => + applyDecorators( + ApiOperation({ + summary: 'Обновить состояние', + description: + 'Обновляет параметры статуса. Системные статусы (isLocked=true) нельзя переименовать, но можно изменить визуал.', + }), + ApiParam({ + name: 'projectId', + type: 'string', + description: 'CUID проекта', + example: 'clv123456', + }), + ApiParam({ + name: 'stateId', + type: 'string', + description: 'CUID состояния', + example: 'clv789012', + }), + ApiBody({ + // type: UpdateProjectStateDto.Output, + description: 'Обновляемые поля', + }), + ApiResponse({ + status: 200, + description: 'Состояние обновлено', + // type: UpdateProjectStateResponse.Output, + }), + ApiValidationError(), + ApiNotFound('Состояние не найдено'), + ApiUnauthorized(), + ApiForbidden('Нельзя изменить системный статус'), + ApiConflict('Состояние с таким названием уже существует'), + + // SetMetadata(ZOD_RESPONSE_TOKEN, UpdateProjectStateResponse), + ); + +export const ReorderProjectStatesSwagger = () => + applyDecorators( + ApiOperation({ + summary: 'Переупорядочить состояния', + description: + 'Меняет порядок колонок на доске. Принимает массив ID состояний в новом порядке.', + }), + ApiParam({ + name: 'projectId', + type: 'string', + description: 'CUID проекта', + example: 'clv123456', + }), + ApiBody({ + // type: ReorderStatesDto.Output, + description: 'Массив ID состояний в правильном порядке', + }), + ApiResponse({ + status: 200, + description: 'Порядок обновлён', + // type: ReorderStatesResponse.Output, + }), + ApiValidationError(), + ApiNotFound('Одно или несколько состояний не найдены'), + ApiUnauthorized(), + ApiForbidden('Нет прав для изменения порядка'), + + // SetMetadata(ZOD_RESPONSE_TOKEN, ReorderStatesResponse), + ); + +export const RemoveProjectStateSwagger = () => + applyDecorators( + ApiOperation({ + summary: 'Удалить состояние', + description: + 'Мягкое удаление статуса. Статус можно удалить только если в нём нет задач.', + }), + ApiParam({ + name: 'projectId', + type: 'string', + description: 'CUID проекта', + example: 'clv123456', + }), + ApiParam({ + name: 'stateId', + type: 'string', + description: 'CUID состояния', + example: 'clv789012', + }), + ApiResponse({ + status: 200, + description: 'Состояние удалено', + type: ActionResponse.Output, + }), + ApiNotFound('Состояние не найдено'), + ApiUnauthorized(), + ApiForbidden('Нельзя удалить системный статус'), + ApiConflict('Нельзя удалить статус, в котором есть задачи'), + + SetMetadata(ZOD_RESPONSE_TOKEN, ActionResponse), + ); + +export const RestoreProjectStateSwagger = () => + applyDecorators( + ApiOperation({ + summary: 'Восстановить удалённое состояние', + description: 'Восстанавливает мягко удалённый статус', + }), + ApiParam({ + name: 'projectId', + type: 'string', + description: 'CUID проекта', + example: 'clv123456', + }), + ApiParam({ + name: 'stateId', + type: 'string', + description: 'CUID состояния', + example: 'clv789012', + }), + ApiResponse({ + status: 200, + description: 'Состояние восстановлено', + type: ActionResponse.Output, + }), + ApiNotFound('Удалённое состояние не найдено'), + ApiUnauthorized(), + ApiForbidden('Нет прав для восстановления'), + + SetMetadata(ZOD_RESPONSE_TOKEN, ActionResponse), + ); + +export const GetProjectStatesStatsSwagger = () => + applyDecorators( + ApiOperation({ + summary: 'Получить статистику по состояниям', + description: 'Возвращает количество задач в каждом статусе и метрики WIP', + }), + ApiParam({ + name: 'projectId', + type: 'string', + description: 'CUID проекта', + example: 'clv123456', + }), + ApiResponse({ + status: 200, + description: 'Статистика получена', + schema: { + type: 'object', + properties: { + data: { + type: 'array', + items: { + type: 'object', + properties: { + stateId: { type: 'string' }, + title: { type: 'string' }, + stateType: { type: 'string' }, + tasksCount: { type: 'integer' }, + maxTasksLimit: { type: 'integer', nullable: true }, + isOverLimit: { type: 'boolean' }, + averageTimeInState: { type: 'integer', nullable: true }, + }, + }, + }, + }, + }, + }), + ApiUnauthorized(), + ApiNotFound('Проект не найден'), + ); diff --git a/src/projects/application/dtos/projects.dto.ts b/src/projects/application/dtos/projects.dto.ts index f2492ac..35f9e75 100644 --- a/src/projects/application/dtos/projects.dto.ts +++ b/src/projects/application/dtos/projects.dto.ts @@ -9,7 +9,7 @@ export const CreateProjectSchema = z.object({ .string() .min(1, 'Название проекта не может быть пустым') .max(100, 'Название не должно превышать 100 символов'), - key: z + slug: z .string() .min(2, 'Ключ проекта должен быть от 2 до 10 символов') .max(10) diff --git a/src/projects/application/mappers/projects.mapper.ts b/src/projects/application/mappers/projects.mapper.ts index 4aa1a12..5a5f784 100644 --- a/src/projects/application/mappers/projects.mapper.ts +++ b/src/projects/application/mappers/projects.mapper.ts @@ -6,7 +6,7 @@ export class ProjectsMapper { public static toDetailResponse(project: Project, member?: RawMemberRow, token?: string) { const { id, - key, + slug, name, status, description, @@ -23,7 +23,7 @@ export class ProjectsMapper { return { id, - key, + slug, name, status, description, @@ -47,11 +47,11 @@ export class ProjectsMapper { } public static toListResponse(project: Project, member: RawMemberRow) { - const { id, key, name, status, color, icon, createdAt } = project; + const { id, slug, name, status, color, icon, createdAt } = project; return { id, - key, + slug, name, status, color: color ?? '#3b82f6', diff --git a/src/projects/application/use-cases/create-project.use-case.ts b/src/projects/application/use-cases/create-project.use-case.ts index e7d0d23..6147c30 100644 --- a/src/projects/application/use-cases/create-project.use-case.ts +++ b/src/projects/application/use-cases/create-project.use-case.ts @@ -19,7 +19,7 @@ export class CreateProjectUseCase { ...dto, teamId: team.id, ownerId: userId, - key: dto.key.toUpperCase(), + slug: dto.slug.toUpperCase(), status: ProjectStatus.Active, }; diff --git a/src/projects/application/use-cases/update-project.use-case.ts b/src/projects/application/use-cases/update-project.use-case.ts index 68ffccf..a41dc44 100644 --- a/src/projects/application/use-cases/update-project.use-case.ts +++ b/src/projects/application/use-cases/update-project.use-case.ts @@ -14,11 +14,11 @@ export class UpdateProjectUseCase { public async execute(id: string, teamId: string, userId: string, dto: UpdateProjectDto) { const { project } = await this.policy.validateProjectAccess(id, teamId, userId); - const { isPublic, key, ...data } = dto; + const { isPublic, slug, ...data } = dto; const result = await this.projectsRepo.update(project.id, { ...data, - ...(key && { key: key.toUpperCase() }), + ...(slug && { slug: slug.toUpperCase() }), ...(typeof isPublic === 'boolean' && { visibility: isPublic ? 'public' : 'private', }), diff --git a/src/projects/infrastructure/persistence/models/enums.ts b/src/projects/infrastructure/persistence/models/enums.ts index 5cfc624..e4987c8 100644 --- a/src/projects/infrastructure/persistence/models/enums.ts +++ b/src/projects/infrastructure/persistence/models/enums.ts @@ -1,8 +1,31 @@ import { baseSchema } from '@shared/entities'; +export const stateTypeEnum = baseSchema.enum('state_type', [ + 'backlog', + 'todo', + 'in_progress', + 'review', + 'done', + 'archived', + 'custom', +]); + +export const stateCategoryEnum = baseSchema.enum('state_category', [ + 'backlog', + 'active', + 'review', + 'completed', + 'archived', +]); + export const projectStatusEnum = baseSchema.enum('project_status', [ 'active', 'archived', 'template', ]); -export const projectVisibilityEnum = baseSchema.enum('project_visibility', ['public', 'private']); + +export const projectVisibilityEnum = baseSchema.enum('project_visibility', [ + 'public', + 'private', + 'team_only', +]); diff --git a/src/projects/infrastructure/persistence/models/projects.model.ts b/src/projects/infrastructure/persistence/models/projects.model.ts index 3266c79..1bd28df 100644 --- a/src/projects/infrastructure/persistence/models/projects.model.ts +++ b/src/projects/infrastructure/persistence/models/projects.model.ts @@ -1,8 +1,22 @@ -import { text, varchar, timestamp, jsonb, integer, uniqueIndex, index } from 'drizzle-orm/pg-core'; +import { + text, + boolean, + varchar, + timestamp, + jsonb, + integer, + uniqueIndex, + index, +} from 'drizzle-orm/pg-core'; import { baseSchema, teams, users } from '@shared/entities'; import { createId } from '@paralleldrive/cuid2'; -import { isNull } from 'drizzle-orm'; -import { projectStatusEnum, projectVisibilityEnum } from './enums'; +import { isNull, sql } from 'drizzle-orm'; +import { + projectStatusEnum, + projectVisibilityEnum, + stateCategoryEnum, + stateTypeEnum, +} from './enums'; export const projects = baseSchema.table( 'projects', @@ -13,7 +27,7 @@ export const projects = baseSchema.table( teamId: text('team_id') .references(() => teams.id, { onDelete: 'cascade' }) .notNull(), - key: varchar('key', { length: 10 }).notNull(), + slug: varchar('slug', { length: 100 }).notNull(), name: varchar('name', { length: 100 }).notNull(), description: text('description'), icon: varchar('icon', { length: 255 }), @@ -32,8 +46,8 @@ export const projects = baseSchema.table( deletedAt: timestamp('deleted_at', { withTimezone: true, mode: 'string' }), }, (t) => ({ - uniqueTeamKey: uniqueIndex('project_team_key_idx') - .on(t.teamId, t.key) + uniqueTeamSlug: uniqueIndex('project_team_slug_idx') + .on(t.teamId, t.slug) .where(isNull(t.deletedAt)), uniqueTeamName: uniqueIndex('project_team_name_idx') .on(t.teamId, t.name) @@ -43,6 +57,57 @@ export const projects = baseSchema.table( }), ); +export const projectStates = baseSchema.table( + 'project_states', + { + id: text('id') + .primaryKey() + .$defaultFn(() => createId()), + projectId: text('project_id').references(() => projects.id, { onDelete: 'cascade' }), + + title: text('title').notNull(), + description: text('description'), + + slug: varchar('slug', { length: 50 }), + + stateType: stateTypeEnum('state_type').notNull().default('custom'), + category: stateCategoryEnum('category').notNull().default('active'), + + color: varchar('color', { length: 10 }), + icon: varchar('icon', { length: 20 }), + + orderIndex: integer('order_index').notNull().default(0), + isVisible: boolean('is_visible').notNull().default(true), + maxTasksLimit: integer('max_tasks_limit'), + autoTransitionTo: text('auto_transition_to'), + + notifyOnEnter: boolean('notify_on_enter').default(false), + notifyOnExit: boolean('notify_on_exit').default(false), + isLocked: boolean('is_locked').default(false), + version: integer('version').default(0), + + createdAt: timestamp('created_at').notNull().defaultNow(), + updatedAt: timestamp('updated_at').notNull().defaultNow(), + createdBy: text('created_by').references(() => users.id), + deletedAt: timestamp('deleted_at'), + }, + (t) => ({ + projectOrderIdx: index('idx_project_states_project_order').on(t.projectId, t.orderIndex), + + uniqueProjectStateType: uniqueIndex('idx_project_states_unique_type') + .on(t.projectId, t.stateType) + .where(sql`deleted_at IS NULL AND state_type != 'custom'`), + + uniqueProjectStateTitle: uniqueIndex('idx_project_states_unique_title') + .on(t.projectId, t.title) + .where(sql`deleted_at IS NULL`), + + deletedAtIdx: index('idx_project_states_deleted_at') + .on(t.deletedAt) + .where(sql`deleted_at IS NOT NULL`), + }), +); + export const projectShares = baseSchema.table( 'project_shares', { @@ -59,8 +124,8 @@ export const projectShares = baseSchema.table( .defaultNow() .notNull(), }, - (table) => ({ - tokenIdx: index('token_idx').on(table.token), - projectIdx: index('project_share_project_id_idx').on(table.projectId), + (t) => ({ + tokenIdx: index('token_idx').on(t.token), + projectIdx: index('project_share_project_id_idx').on(t.projectId), }), ); diff --git a/src/projects/projects.module.ts b/src/projects/projects.module.ts index 3de954e..d0fcb0a 100644 --- a/src/projects/projects.module.ts +++ b/src/projects/projects.module.ts @@ -1,13 +1,8 @@ import { forwardRef, Module } from '@nestjs/common'; import { ProjectsRepository } from './infrastructure/persistence/repositories'; import { TeamsModule } from '@core/teams'; -import { ProjectsController } from './application/controller'; -import { - CreateProjectUseCase, - FindProjectQuery, - ProjectQueries, - ProjectUseCases, -} from './application/use-cases'; +import { CONTROLLERS } from './application/controller'; +import { FindProjectQuery, ProjectQueries, ProjectUseCases } from './application/use-cases'; import { POLICIES, ProjectAccessPolicy } from './domain/policy'; import { ProjectsFacade } from './application/projects.facade'; @@ -18,8 +13,8 @@ const REPOSITORY = { @Module({ imports: [forwardRef(() => TeamsModule)], - controllers: [ProjectsController], + controllers: CONTROLLERS, providers: [REPOSITORY, ...POLICIES, ...ProjectUseCases, ...ProjectQueries, ProjectsFacade], - exports: [FindProjectQuery, ProjectAccessPolicy, CreateProjectUseCase], + exports: [FindProjectQuery, ProjectAccessPolicy], }) export class ProjectsModule {} From 9e039dbca761f10e855430b5b027e485201d9c39 Mon Sep 17 00:00:00 2001 From: soorq Date: Thu, 11 Jun 2026 00:31:51 +0300 Subject: [PATCH 2/5] feat(states): add base CRUD operations for project states --- src/projects/application/controller/index.ts | 2 +- .../controller/project-states/controller.ts | 73 -------- .../controller/projects/controller.ts | 32 ++-- .../controller/states/controller.ts | 102 +++++++++++ .../{project-states => states}/swagger.ts | 131 ++++++-------- src/projects/application/dtos/index.ts | 10 +- src/projects/application/dtos/projects.dto.ts | 44 ++++- src/projects/application/dtos/states.dto.ts | 161 ++++++++++++++++++ src/projects/application/projects.facade.ts | 77 +++++++-- .../use-cases/create-project.use-case.ts | 4 +- .../use-cases/delete-project.use-case.ts | 4 +- .../use-cases/find-project.query.ts | 8 +- .../use-cases/find-projects-by-team.query.ts | 2 +- .../generate-share-token.use-case.ts | 4 +- .../use-cases/get-project-detail.query.ts | 4 +- src/projects/application/use-cases/index.ts | 33 +++- .../use-cases/set-project-status.use-case.ts | 4 +- .../use-cases/states/create-state.use-case.ts | 97 +++++++++++ .../use-cases/states/delete-state.use-case.ts | 91 ++++++++++ .../use-cases/states/get-state.query.ts | 51 ++++++ .../use-cases/states/get-states.query.ts | 36 ++++ .../states/reorder-states.use-case.ts | 61 +++++++ .../states/restore-state.use-state.ts | 60 +++++++ .../use-cases/states/update-state.use-case.ts | 80 +++++++++ .../use-cases/update-project.use-case.ts | 11 +- src/projects/domain/entities/index.ts | 3 +- .../{entities.domain.ts => project.domain.ts} | 0 src/projects/domain/entities/state.domain.ts | 5 + src/projects/domain/errors/index.ts | 2 + src/projects/domain/errors/project.errors.ts | 51 ++++++ src/projects/domain/errors/state.errors.ts | 130 ++++++++++++++ .../domain/policy/project-access.policy.ts | 10 +- src/projects/domain/repository/index.ts | 3 +- .../project-states.repository.interface.ts | 16 ++ .../projects.repository.interface.ts | 10 +- .../persistence/models/index.ts | 2 +- .../persistence/models/projects.model.ts | 36 ++-- .../persistence/repositories/index.ts | 15 +- .../repositories/projects.repository.ts | 4 +- .../repositories/states.repository.ts | 122 +++++++++++++ src/projects/projects.module.ts | 11 +- 41 files changed, 1335 insertions(+), 267 deletions(-) delete mode 100644 src/projects/application/controller/project-states/controller.ts create mode 100644 src/projects/application/controller/states/controller.ts rename src/projects/application/controller/{project-states => states}/swagger.ts (70%) create mode 100644 src/projects/application/dtos/states.dto.ts create mode 100644 src/projects/application/use-cases/states/create-state.use-case.ts create mode 100644 src/projects/application/use-cases/states/delete-state.use-case.ts create mode 100644 src/projects/application/use-cases/states/get-state.query.ts create mode 100644 src/projects/application/use-cases/states/get-states.query.ts create mode 100644 src/projects/application/use-cases/states/reorder-states.use-case.ts create mode 100644 src/projects/application/use-cases/states/restore-state.use-state.ts create mode 100644 src/projects/application/use-cases/states/update-state.use-case.ts rename src/projects/domain/entities/{entities.domain.ts => project.domain.ts} (100%) create mode 100644 src/projects/domain/entities/state.domain.ts create mode 100644 src/projects/domain/errors/index.ts create mode 100644 src/projects/domain/errors/project.errors.ts create mode 100644 src/projects/domain/errors/state.errors.ts create mode 100644 src/projects/domain/repository/project-states.repository.interface.ts create mode 100644 src/projects/infrastructure/persistence/repositories/states.repository.ts diff --git a/src/projects/application/controller/index.ts b/src/projects/application/controller/index.ts index bd5ef35..8df61cd 100644 --- a/src/projects/application/controller/index.ts +++ b/src/projects/application/controller/index.ts @@ -1,4 +1,4 @@ -import { ProjectsStatesController } from './project-states/controller'; +import { ProjectsStatesController } from './states/controller'; import { ProjectsController } from './projects/controller'; export const CONTROLLERS = [ProjectsController, ProjectsStatesController]; diff --git a/src/projects/application/controller/project-states/controller.ts b/src/projects/application/controller/project-states/controller.ts deleted file mode 100644 index 9f2ad4f..0000000 --- a/src/projects/application/controller/project-states/controller.ts +++ /dev/null @@ -1,73 +0,0 @@ -import { Body, Delete, Get, Param, Patch, Post, Query } from '@nestjs/common'; -import { ApiBaseController, Public } from '@shared/decorators'; -import { - CreateProjectStateSwagger, - FindAllProjectStatesSwagger, - FindOneProjectStateSwagger, - RemoveProjectStateSwagger, - ReorderProjectStatesSwagger, - RestoreProjectStateSwagger, - UpdateProjectStateSwagger, -} from './swagger'; -import { ProjectsFacade } from '../../projects.facade'; - -@ApiBaseController('projects/:slug/states', 'Project States', true) -export class ProjectsStatesController { - constructor(private readonly facade: ProjectsFacade) { - void this.facade; - } - - @Get() - @Public() - @FindAllProjectStatesSwagger() - async getAll( - @Param('projectId') projectId: string, - @Query('hidden') hidden?: boolean, - @Query('counts') counts?: boolean, - @Query('my') my?: boolean, - @Query('category') category?: string, - @Query('overdue') overdue?: boolean, - ) { - return { projectId, hidden, category, my, counts, overdue }; - } - - @Get(':stateSlug') - @FindOneProjectStateSwagger() - async findOne(@Param('slug') slug: string, @Param('stateSlug') stateSlug: string) { - return { slug, stateSlug }; - } - - @Post() - @CreateProjectStateSwagger() - async create(@Param('slug') slug: string, @Body() dto: any) { - return { slug, dto }; - } - - @Delete(':stateSlug') - @RemoveProjectStateSwagger() - async delete(@Param('slug') slug: string, @Param('stateSlug') stateSlug: string) { - return { slug, stateSlug }; - } - - @Patch('reorder') - @ReorderProjectStatesSwagger() - async reorder(@Param('slug') slug: string, @Body() dto: unknown) { - return { slug, dto }; - } - - @Patch(':stateSlug') - @UpdateProjectStateSwagger() - async update( - @Param('slug') slug: string, - @Param('stateSlug') stateSlug: string, - @Body() dto: unknown, - ) { - return { slug, stateSlug, dto }; - } - - @Post(':stateSlug/restore') - @RestoreProjectStateSwagger() - async restore(@Param('slug') slug: string, @Param('stateSlug') stateSlug: string) { - return { slug, stateSlug }; - } -} diff --git a/src/projects/application/controller/projects/controller.ts b/src/projects/application/controller/projects/controller.ts index 665df56..d24bafb 100644 --- a/src/projects/application/controller/projects/controller.ts +++ b/src/projects/application/controller/projects/controller.ts @@ -13,7 +13,7 @@ import { CreateProjectDto, CreateShareTokenDto, UpdateProjectDto } from '../../d import { ProjectStatus } from '@core/projects/domain/entities'; import { ProjectsFacade } from '../../projects.facade'; -@ApiBaseController('teams/:teamId/projects', 'Team Projects', true) +@ApiBaseController('teams/:teamId/projects', 'Projects', true) export class ProjectsController { constructor(private readonly facade: ProjectsFacade) {} @@ -23,37 +23,37 @@ export class ProjectsController { return this.facade.getTeamProjects(teamId, userId); } - @Get(':id') + @Get(':slug') @Public() @FindOneProjectSwagger() async getOne( - @Param('id') id: string, + @Param('slug') slug: string, @Param('teamId') teamId: string, @GetUserId() userId?: string, @Query('token') token?: string, ) { - return this.facade.getDetail(id, teamId, userId, token); + return this.facade.getDetail(slug, teamId, userId, token); } - @Post(':id/share') + @Post(':slug/share') @CreateShareTokenSwagger() async generateShareToken( - @Param('id') id: string, + @Param('slug') slug: string, @Param('teamId') teamId: string, @GetUserId() userId: string, @Body() dto: CreateShareTokenDto, ) { - return this.facade.generateShareToken(id, teamId, userId, dto); + return this.facade.generateShareToken(slug, teamId, userId, dto); } - @Post(':id/archive') + @Post(':slug/archive') @ArchiveProjectSwagger() async archive( - @Param('id') id: string, + @Param('slug') slug: string, @Param('teamId') teamId: string, @GetUserId() userId: string, ) { - return this.facade.setStatus(id, teamId, userId, ProjectStatus.Archived); + return this.facade.setStatus(slug, teamId, userId, ProjectStatus.Archived); } @Post() @@ -66,24 +66,24 @@ export class ProjectsController { return this.facade.create(userId, teamId, dto); } - @Patch(':id') + @Patch(':slug') @UpdateProjectSwagger() async update( - @Param('id') id: string, + @Param('slug') slug: string, @Param('teamId') teamId: string, @GetUserId() userId: string, @Body() dto: UpdateProjectDto, ) { - return this.facade.update(id, teamId, userId, dto); + return this.facade.update(slug, teamId, userId, dto); } - @Delete(':id') + @Delete(':slug') @RemoveProjectSwagger() async remove( - @Param('id') id: string, + @Param('slug') slug: string, @Param('teamId') teamId: string, @GetUserId() userId: string, ) { - return this.facade.delete(id, teamId, userId); + return this.facade.delete(slug, teamId, userId); } } diff --git a/src/projects/application/controller/states/controller.ts b/src/projects/application/controller/states/controller.ts new file mode 100644 index 0000000..417554b --- /dev/null +++ b/src/projects/application/controller/states/controller.ts @@ -0,0 +1,102 @@ +import { Body, Delete, Get, Param, Patch, Post, Query } from '@nestjs/common'; +import { ApiBaseController, GetUserId, Public } from '@shared/decorators'; +import { + CreateProjectStateSwagger, + FindAllProjectStatesSwagger, + FindOneProjectStateSwagger, + RemoveProjectStateSwagger, + ReorderProjectStatesSwagger, + RestoreProjectStateSwagger, + UpdateProjectStateSwagger, +} from './swagger'; +import { ProjectsFacade } from '../../projects.facade'; +import { CreateProjectStateDto, ReorderProjectsStatesDto, UpdateProjectStateDto } from '../../dtos'; + +@ApiBaseController('projects/:slug/states', 'Project States', true) +export class ProjectsStatesController { + constructor(private readonly facade: ProjectsFacade) {} + + @Get() + @Public() + @FindAllProjectStatesSwagger() + async getAll( + @Param('slug') slug: string, + @GetUserId() userId: string, + @Query('hidden') hidden?: boolean, + @Query('counts') counts?: boolean, + @Query('my') my?: boolean, + @Query('category') category?: string, + @Query('overdue') overdue?: boolean, + ) { + const query = { + hidden, + counts, + my, + category, + overdue, + }; + + return this.facade.getStates(slug, query, userId); + } + + @Get(':stateId') + @FindOneProjectStateSwagger() + async findOne( + @Param('slug') slug: string, + @Param('stateId') stateId: string, + @GetUserId() userId: string, + ) { + return this.facade.getDetailState(slug, stateId, userId); + } + + @Post() + @CreateProjectStateSwagger() + async create( + @Param('slug') slug: string, + @Body() dto: CreateProjectStateDto, + @GetUserId() userId: string, + ) { + return this.facade.createState(slug, dto, userId); + } + + @Delete(':stateId') + @RemoveProjectStateSwagger() + async delete( + @Param('slug') slug: string, + @Param('stateId') stateId: string, + @GetUserId() userId: string, + ) { + return this.facade.deleteState(slug, stateId, userId); + } + + @Patch('reorder') + @ReorderProjectStatesSwagger() + async reorder( + @Param('slug') slug: string, + @Body() dto: ReorderProjectsStatesDto, + @GetUserId() userId: string, + ) { + return this.facade.reoderStates(slug, dto, userId); + } + + @Patch(':stateId') + @UpdateProjectStateSwagger() + async update( + @Param('slug') slug: string, + @Param('stateId') stateId: string, + @Body() dto: UpdateProjectStateDto, + @GetUserId() userId: string, + ) { + return this.facade.updateState(slug, stateId, dto, userId); + } + + @Post(':stateId/restore') + @RestoreProjectStateSwagger() + async restore( + @Param('slug') slug: string, + @Param('stateId') stateId: string, + @GetUserId() userId: string, + ) { + return this.facade.restoreState(slug, stateId, userId); + } +} diff --git a/src/projects/application/controller/project-states/swagger.ts b/src/projects/application/controller/states/swagger.ts similarity index 70% rename from src/projects/application/controller/project-states/swagger.ts rename to src/projects/application/controller/states/swagger.ts index 218167f..b4ad804 100644 --- a/src/projects/application/controller/project-states/swagger.ts +++ b/src/projects/application/controller/states/swagger.ts @@ -9,6 +9,13 @@ import { ApiConflict, } from '@shared/error'; import { ZOD_RESPONSE_TOKEN } from '@shared/interceptors'; +import { + CreateProjectStateDto, + CreateProjectStateResponse, + ProjectStateResponse, + ReorderProjectsStatesDto, + UpdateProjectStateDto, +} from '../../dtos'; export const FindAllProjectStatesSwagger = () => applyDecorators( @@ -17,10 +24,10 @@ export const FindAllProjectStatesSwagger = () => description: 'Возвращает список всех статусов (колонок) проекта с их настройками', }), ApiParam({ - name: 'projectId', + name: 'slug', type: 'string', - description: 'CUID проекта', - example: 'clv123456', + description: 'Slug проекта', + example: 'super-project', }), ApiQuery({ name: 'hidden', @@ -61,12 +68,12 @@ export const FindAllProjectStatesSwagger = () => ApiResponse({ status: 200, description: 'Список состояний получен', - // type: ProjectStateListResponse.Output, + type: [ProjectStateResponse.Output], }), ApiUnauthorized(), ApiNotFound('Проект не найден'), - // SetMetadata(ZOD_RESPONSE_TOKEN, ProjectStateListResponse), + SetMetadata(ZOD_RESPONSE_TOKEN, ProjectStateResponse), ); export const FindOneProjectStateSwagger = () => @@ -76,26 +83,26 @@ export const FindOneProjectStateSwagger = () => description: 'Возвращает полную информацию о статусе проекта', }), ApiParam({ - name: 'projectId', + name: 'slug', type: 'string', - description: 'CUID проекта', - example: 'clv123456', + description: 'Slug проекта', + example: 'super-project', }), ApiParam({ name: 'stateId', type: 'string', - description: 'CUID состояния', - example: 'clv789012', + description: 'State id состояния', + example: 'clv123456', }), ApiResponse({ status: 200, description: 'Информация о состоянии получена', - // type: ProjectStateDetailResponse.Output, + type: ProjectStateResponse.Output, }), ApiNotFound('Состояние не найдено'), ApiUnauthorized(), - // SetMetadata(ZOD_RESPONSE_TOKEN, ProjectStateDetailResponse), + SetMetadata(ZOD_RESPONSE_TOKEN, ProjectStateResponse), ); export const CreateProjectStateSwagger = () => @@ -106,26 +113,26 @@ export const CreateProjectStateSwagger = () => 'Создаёт новый статус (колонку) для проекта. Можно указать тип, иконку, цвет и WIP лимит.', }), ApiParam({ - name: 'projectId', + name: 'slug', type: 'string', - description: 'CUID проекта', - example: 'clv123456', + description: 'Slug проекта', + example: 'super-project', }), ApiBody({ - // type: CreateProjectStateDto.Output, + type: CreateProjectStateDto.Output, description: 'Данные для создания состояния', }), ApiResponse({ status: 201, description: 'Состояние успешно создано', - // type: CreateProjectStateResponse.Output, + type: CreateProjectStateResponse.Output, }), ApiValidationError(), ApiUnauthorized(), ApiForbidden('Нет прав для создания состояния в этом проекте'), ApiConflict('Состояние с таким названием или типом уже существует'), - // SetMetadata(ZOD_RESPONSE_TOKEN, CreateProjectStateResponse), + SetMetadata(ZOD_RESPONSE_TOKEN, CreateProjectStateResponse), ); export const UpdateProjectStateSwagger = () => @@ -136,25 +143,25 @@ export const UpdateProjectStateSwagger = () => 'Обновляет параметры статуса. Системные статусы (isLocked=true) нельзя переименовать, но можно изменить визуал.', }), ApiParam({ - name: 'projectId', + name: 'slug', type: 'string', - description: 'CUID проекта', - example: 'clv123456', + description: 'Slug проекта', + example: 'super-project', }), ApiParam({ name: 'stateId', type: 'string', - description: 'CUID состояния', - example: 'clv789012', + description: 'State id состояния', + example: 'clv123456', }), ApiBody({ - // type: UpdateProjectStateDto.Output, + type: UpdateProjectStateDto.Output, description: 'Обновляемые поля', }), ApiResponse({ status: 200, description: 'Состояние обновлено', - // type: UpdateProjectStateResponse.Output, + type: ActionResponse.Output, }), ApiValidationError(), ApiNotFound('Состояние не найдено'), @@ -162,7 +169,7 @@ export const UpdateProjectStateSwagger = () => ApiForbidden('Нельзя изменить системный статус'), ApiConflict('Состояние с таким названием уже существует'), - // SetMetadata(ZOD_RESPONSE_TOKEN, UpdateProjectStateResponse), + SetMetadata(ZOD_RESPONSE_TOKEN, ActionResponse), ); export const ReorderProjectStatesSwagger = () => @@ -173,26 +180,26 @@ export const ReorderProjectStatesSwagger = () => 'Меняет порядок колонок на доске. Принимает массив ID состояний в новом порядке.', }), ApiParam({ - name: 'projectId', + name: 'slug', type: 'string', - description: 'CUID проекта', - example: 'clv123456', + description: 'Slug проекта', + example: 'super-project', }), ApiBody({ - // type: ReorderStatesDto.Output, + type: ReorderProjectsStatesDto.Output, description: 'Массив ID состояний в правильном порядке', }), ApiResponse({ status: 200, description: 'Порядок обновлён', - // type: ReorderStatesResponse.Output, + type: ActionResponse.Output, }), ApiValidationError(), ApiNotFound('Одно или несколько состояний не найдены'), ApiUnauthorized(), ApiForbidden('Нет прав для изменения порядка'), - // SetMetadata(ZOD_RESPONSE_TOKEN, ReorderStatesResponse), + SetMetadata(ZOD_RESPONSE_TOKEN, ActionResponse), ); export const RemoveProjectStateSwagger = () => @@ -203,16 +210,16 @@ export const RemoveProjectStateSwagger = () => 'Мягкое удаление статуса. Статус можно удалить только если в нём нет задач.', }), ApiParam({ - name: 'projectId', + name: 'slug', type: 'string', - description: 'CUID проекта', - example: 'clv123456', + description: 'Slug проекта', + example: 'super-project', }), ApiParam({ name: 'stateId', type: 'string', - description: 'CUID состояния', - example: 'clv789012', + description: 'State id состояния', + example: 'clv123456', }), ApiResponse({ status: 200, @@ -234,16 +241,16 @@ export const RestoreProjectStateSwagger = () => description: 'Восстанавливает мягко удалённый статус', }), ApiParam({ - name: 'projectId', + name: 'slug', type: 'string', - description: 'CUID проекта', - example: 'clv123456', + description: 'Slug проекта', + example: 'super-project', }), ApiParam({ name: 'stateId', type: 'string', - description: 'CUID состояния', - example: 'clv789012', + description: 'State id состояния', + example: 'clv123456', }), ApiResponse({ status: 200, @@ -256,43 +263,3 @@ export const RestoreProjectStateSwagger = () => SetMetadata(ZOD_RESPONSE_TOKEN, ActionResponse), ); - -export const GetProjectStatesStatsSwagger = () => - applyDecorators( - ApiOperation({ - summary: 'Получить статистику по состояниям', - description: 'Возвращает количество задач в каждом статусе и метрики WIP', - }), - ApiParam({ - name: 'projectId', - type: 'string', - description: 'CUID проекта', - example: 'clv123456', - }), - ApiResponse({ - status: 200, - description: 'Статистика получена', - schema: { - type: 'object', - properties: { - data: { - type: 'array', - items: { - type: 'object', - properties: { - stateId: { type: 'string' }, - title: { type: 'string' }, - stateType: { type: 'string' }, - tasksCount: { type: 'integer' }, - maxTasksLimit: { type: 'integer', nullable: true }, - isOverLimit: { type: 'boolean' }, - averageTimeInState: { type: 'integer', nullable: true }, - }, - }, - }, - }, - }, - }), - ApiUnauthorized(), - ApiNotFound('Проект не найден'), - ); diff --git a/src/projects/application/dtos/index.ts b/src/projects/application/dtos/index.ts index b9c1ed7..0fe3411 100644 --- a/src/projects/application/dtos/index.ts +++ b/src/projects/application/dtos/index.ts @@ -1,8 +1,2 @@ -export { - CreateProjectDto, - UpdateProjectDto, - CreateProjectResponse, - CreateShareTokenDto, - ProjectListResponse, - ProjectDetailResponse, -} from './projects.dto'; +export * from './projects.dto'; +export * from './states.dto'; diff --git a/src/projects/application/dtos/projects.dto.ts b/src/projects/application/dtos/projects.dto.ts index 35f9e75..cc55c57 100644 --- a/src/projects/application/dtos/projects.dto.ts +++ b/src/projects/application/dtos/projects.dto.ts @@ -4,23 +4,51 @@ import { ActionResponseSchema } from '@shared/dtos'; import { createPaginationSchema } from '@shared/schemas'; import { ProjectStatus, ProjectVisibility } from '@core/projects/domain/entities'; +export const ProjectVisibilitySchema = z.enum(['public', 'private']).default('private'); + export const CreateProjectSchema = z.object({ name: z .string() .min(1, 'Название проекта не может быть пустым') - .max(100, 'Название не должно превышать 100 символов'), + .max(100, 'Название не должно превышать 100 символов') + .describe('Название проекта'), slug: z .string() .min(2, 'Ключ проекта должен быть от 2 до 10 символов') .max(10) - .regex(/^[A-Z0-9]+$/, 'Ключ должен содержать только заглавные латинские буквы и цифры'), - description: z.string().max(2000, 'Описание слишком длинное').optional().nullable(), - icon: z.string().optional().nullable(), + .regex(/^[a-z0-9]+$/, 'Ключ должен содержать только строчные латинские буквы и цифры') + .describe('Уникальный ключ проекта в URL (2-10 символов, a-z0-9)'), + description: z + .string() + .max(2000, 'Описание слишком длинное') + .nullable() + .optional() + .default(null) + .describe('Описание проекта'), + icon: z + .string() + .max(255, 'URL иконки слишком длинный') + .nullable() + .optional() + .default(null) + .describe('Иконка проекта (эмодзи, URL или id шрифта)'), color: z .string() - .regex(/^#[A-Fa-f0-9]{6}$/, 'Цвет должен быть в формате HEX (например, #FFFFFF)') - .optional(), - visibility: z.enum(['public', 'private']).default('public'), + .regex(/^#[A-Fa-f0-9]{6}$/, 'Цвет должен быть в формате HEX') + .nullable() + .optional() + .default('#3B82F6') + .describe('Цвет проекта в HEX (#RRGGBB)'), + visibility: ProjectVisibilitySchema.optional() + .default('public') + .describe('Видимость: public | private'), + taskSequence: z + .number() + .int() + .min(0) + .optional() + .default(0) + .describe('Счётчик для автонумерации задач'), }); export class CreateProjectDto extends createZodDto(CreateProjectSchema) {} @@ -38,7 +66,7 @@ export const UpdateProjectSchema = CreateProjectSchema.extend({ export class UpdateProjectDto extends createZodDto(UpdateProjectSchema) {} const CreateProjectsResponseSchema = ActionResponseSchema.extend({ - projectId: z.string().describe('Уникальный идентификатор проекта в системе'), + slug: z.string().describe('Уникальный идентификатор проекта в системе'), }); export class CreateProjectResponse extends createZodDto(CreateProjectsResponseSchema) {} diff --git a/src/projects/application/dtos/states.dto.ts b/src/projects/application/dtos/states.dto.ts new file mode 100644 index 0000000..8c45f4e --- /dev/null +++ b/src/projects/application/dtos/states.dto.ts @@ -0,0 +1,161 @@ +import { z } from 'zod/v4'; +import { createZodDto } from 'nestjs-zod'; +import { ActionResponseSchema } from '@shared/dtos'; + +export const StateTypeSchema = z + .enum(['backlog', 'todo', 'in_progress', 'review', 'done', 'archived', 'custom']) + .describe('Тип состояния: системный или кастомный'); + +export const StateCategorySchema = z + .enum(['active', 'completed', 'backlog', 'review', 'archived']) + .describe('Категория состояния: активное, завершённое или отменённое'); + +export const StateSchema = z.object({ + id: z + .string() + .min(1, 'ID не может быть пустым') + .describe('Уникальный идентификатор состояния (UUID или наноид)'), + + projectId: z + .string() + .min(1, 'ID проекта обязателен') + .describe('ID проекта, к которому принадлежит состояние'), + + title: z + .string() + .min(1, 'Название состояния обязательно') + .max(255, 'Название не должно превышать 255 символов') + .describe('Отображаемое название состояния (например: "To Do", "In Progress", "Done")'), + + description: z + .string() + .nullable() + .optional() + .describe('Описание состояния, его назначение и правила использования в workflow'), + + stateType: StateTypeSchema.default('custom').describe( + 'Тип состояния: custom — пользовательское, default — системное (нельзя удалить)', + ), + + category: StateCategorySchema.default('active').describe( + 'Группа для аналитики и фильтрации: backlog, active, done, closed', + ), + + color: z + .string() + .regex( + /^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$/, + 'Цвет должен быть в HEX формате (#RRGGBB или #RGB)', + ) + .nullable() + .optional() + .describe('HEX-код цвета для визуального отображения на доске (например: "#4A90E2")'), + + icon: z + .string() + .max(20, 'Иконка должна быть не длиннее 20 символов') + .nullable() + .optional() + .describe('Emoji или иконка для визуального обозначения (например: "📋", "🚀", "✅")'), + + orderIndex: z + .number() + .int('Порядковый номер должен быть целым числом') + .min(0, 'Порядковый номер не может быть отрицательным') + .default(0) + .describe('Порядок отображения на доске (меньше число — левее/выше)'), + + isVisible: z + .boolean() + .default(true) + .describe('Видимость состояния на доске и в выпадающих списках (можно скрыть, не удаляя)'), + + maxTasksLimit: z + .number() + .int('Лимит задач должен быть целым числом') + .positive('Лимит задач должен быть положительным числом') + .nullable() + .optional() + .describe( + 'Максимальное количество задач в этом состоянии (WIP лимит для Kanban). Null — без лимита', + ), + + autoTransitionTo: z + .string() + .nullable() + .optional() + .describe('Автоматический переход в другое состояние при достижении лимита или по условию'), + + notifyOnEnter: z + .boolean() + .default(false) + .describe('Отправлять уведомление, когда задача попадает в это состояние'), + + notifyOnExit: z + .boolean() + .default(false) + .describe('Отправлять уведомление, когда задача покидает это состояние'), + + isLocked: z + .boolean() + .default(false) + .describe('Заблокировано для изменений (нельзя перемещать задачи в/из этого состояния)'), + + createdAt: z + .string() + .datetime({ offset: true }) + .describe('Дата и время создания состояния (ISO 8601 с таймзоной)'), + + updatedAt: z + .string() + .datetime({ offset: true }) + .describe('Дата и время последнего обновления состояния'), + + createdBy: z.string().nullable().optional().describe('ID пользователя, создавшего состояние'), + + deletedAt: z + .string() + .datetime({ offset: true }) + .nullable() + .optional() + .describe('Дата мягкого удаления (null — не удалено)'), +}); + +export const CreateProjectStateResponseSchema = ActionResponseSchema.extend({ + id: z.string().describe('ID созданного состояния'), +}); + +export const CreateProjectStateSchema = StateSchema.omit({ + id: true, + createdAt: true, + updatedAt: true, + createdBy: true, + deletedAt: true, +}) + .partial({ + description: true, + color: true, + icon: true, + maxTasksLimit: true, + autoTransitionTo: true, + }) + .describe('Схема для создания нового состояния'); + +export const ReorderStateItemSchema = z.object({ + id: z.string().describe('ID состояния'), + orderIndex: z.number().min(0).describe('Новый порядковый индекс'), +}); + +export const ReorderStatesSchema = z.object({ + items: z.array(ReorderStateItemSchema).min(1).describe('Массив состояний с новыми индексами'), +}); + +export class ProjectStateResponse extends createZodDto(StateSchema) {} + +export class CreateProjectStateDto extends createZodDto(CreateProjectStateSchema) {} + +export class UpdateProjectStateDto extends createZodDto(CreateProjectStateSchema.partial()) {} + +export class CreateProjectStateResponse extends createZodDto(CreateProjectStateResponseSchema) {} + +export class ReorderProjectsStatesDto extends createZodDto(ReorderStatesSchema) {} diff --git a/src/projects/application/projects.facade.ts b/src/projects/application/projects.facade.ts index 3a71ad6..826fd5a 100644 --- a/src/projects/application/projects.facade.ts +++ b/src/projects/application/projects.facade.ts @@ -1,6 +1,13 @@ import { Injectable } from '@nestjs/common'; import { ProjectStatus } from '../domain/entities'; -import type { CreateProjectDto, CreateShareTokenDto, UpdateProjectDto } from './dtos'; +import type { + CreateProjectDto, + CreateProjectStateDto, + CreateShareTokenDto, + ReorderProjectsStatesDto, + UpdateProjectDto, + UpdateProjectStateDto, +} from './dtos'; import { CreateProjectUseCase, DeleteProjectUseCase, @@ -9,6 +16,13 @@ import { UpdateProjectUseCase, FindProjectsByTeamQuery, GetProjectDetailQuery, + CreateStateUseCase, + DeleteStateUseCase, + GetStateQuery, + GetStatesQuery, + UpdateStateUseCase, + RestoreStateUseCase, + ReorderStateUseCase, } from './use-cases'; @Injectable() @@ -21,38 +35,79 @@ export class ProjectsFacade { private readonly generateTokenUC: GenerateShareTokenUseCase, private readonly getDetailQ: GetProjectDetailQuery, private readonly findByTeamQ: FindProjectsByTeamQuery, + + private readonly createStateUC: CreateStateUseCase, + private readonly updateStateUC: UpdateStateUseCase, + private readonly deleteStateUC: DeleteStateUseCase, + private readonly getStateDetailQ: GetStateQuery, + private readonly getStatesQ: GetStatesQuery, + private readonly restoreStateUC: RestoreStateUseCase, + private readonly reorderStateUC: ReorderStateUseCase, ) {} public async create(userId: string, teamId: string, dto: CreateProjectDto) { return this.createProjectUC.execute(userId, teamId, dto); } - public async update(id: string, teamId: string, userId: string, dto: UpdateProjectDto) { - return this.updateProjectUC.execute(id, teamId, userId, dto); + public async update(slug: string, teamId: string, userId: string, dto: UpdateProjectDto) { + return this.updateProjectUC.execute(slug, teamId, userId, dto); } - public async delete(id: string, teamId: string, userId: string) { - return this.deleteProjectUC.execute(id, teamId, userId); + public async delete(slug: string, teamId: string, userId: string) { + return this.deleteProjectUC.execute(slug, teamId, userId); } - public async setStatus(id: string, teamId: string, userId: string, status: ProjectStatus) { - return this.setStatusUC.execute(id, teamId, userId, status); + public async setStatus(slug: string, teamId: string, userId: string, status: ProjectStatus) { + return this.setStatusUC.execute(slug, teamId, userId, status); } public async generateShareToken( - id: string, + slug: string, teamId: string, userId: string, dto: CreateShareTokenDto, ) { - return this.generateTokenUC.execute(id, teamId, userId, dto); + return this.generateTokenUC.execute(slug, teamId, userId, dto); } - public async getDetail(id: string, teamId: string, userId?: string, token?: string) { - return this.getDetailQ.execute(id, teamId, userId, token); + public async getDetail(slug: string, teamId: string, userId?: string, token?: string) { + return this.getDetailQ.execute(slug, teamId, userId, token); } public async getTeamProjects(teamId: string, userId: string) { return this.findByTeamQ.execute(teamId, userId); } + + public async createState(slug: string, dto: CreateProjectStateDto, userId: string) { + return this.createStateUC.execute(slug, dto, userId); + } + + public async deleteState(slug: string, stateId: string, userId: string) { + return this.deleteStateUC.execute(slug, stateId, userId); + } + + public async updateState( + slug: string, + stateId: string, + dto: UpdateProjectStateDto, + userId: string, + ) { + return this.updateStateUC.execute(slug, stateId, dto, userId); + } + + public async getDetailState(slug: string, stateId: string, userId: string) { + return this.getStateDetailQ.execute(slug, stateId, userId); + } + + public async getStates(slug: string, query: unknown, userId: string) { + return this.getStatesQ.execute(slug, query, userId); + } + + public async restoreState(slug: string, stateId: string, userId: string) { + return this.restoreStateUC.execute(slug, stateId, userId); + } + + public async reoderStates(slug: string, dto: ReorderProjectsStatesDto, userId: string) { + return this.reorderStateUC.execute(slug, dto, userId); + } } diff --git a/src/projects/application/use-cases/create-project.use-case.ts b/src/projects/application/use-cases/create-project.use-case.ts index 6147c30..ddfc1f7 100644 --- a/src/projects/application/use-cases/create-project.use-case.ts +++ b/src/projects/application/use-cases/create-project.use-case.ts @@ -23,12 +23,12 @@ export class CreateProjectUseCase { status: ProjectStatus.Active, }; - const { result, id } = await this.projectsRepo.create(data); + const { result, slug } = await this.projectsRepo.create(data); return { success: result, message: `Проект ${dto.name} успешно создан`, - projectId: id, + slug, }; } } diff --git a/src/projects/application/use-cases/delete-project.use-case.ts b/src/projects/application/use-cases/delete-project.use-case.ts index 4957663..72520d0 100644 --- a/src/projects/application/use-cases/delete-project.use-case.ts +++ b/src/projects/application/use-cases/delete-project.use-case.ts @@ -11,8 +11,8 @@ export class DeleteProjectUseCase { private readonly policy: ProjectAccessPolicy, ) {} - public async execute(id: string, teamId: string, userId: string) { - const { project } = await this.policy.validateProjectAccess(id, teamId, userId, 'admin'); + public async execute(slug: string, teamId: string, userId: string) { + const { project } = await this.policy.validateProjectAccess(slug, teamId, userId, 'admin'); const result = await this.projectsRepo.delete(project.id); if (!result) { diff --git a/src/projects/application/use-cases/find-project.query.ts b/src/projects/application/use-cases/find-project.query.ts index da30aac..4aa8da8 100644 --- a/src/projects/application/use-cases/find-project.query.ts +++ b/src/projects/application/use-cases/find-project.query.ts @@ -19,20 +19,20 @@ export class FindProjectQuery { * Точка входа для получения проекта с проверкой прав. */ public async execute( - projectId: string, + slug: string, teamId: string, userId?: string, shareToken?: string, minRole: keyof typeof ROLE_PRIORITY = 'viewer', ) { - const project = await this.projectsRepo.findOne(projectId); + const project = await this.projectsRepo.findOne(slug); if (!project) { throw new BaseException( { code: 'PROJECT_NOT_FOUND', message: 'Проект не найден', - details: [{ target: 'projectId', value: projectId }], + details: [{ target: 'slug', value: slug }], }, HttpStatus.NOT_FOUND, ); @@ -102,7 +102,7 @@ export class FindProjectQuery { } const hashedToken = createHash('sha256').update(token).digest('hex'); - const isValidToken = await this.projectsRepo.hasValidShareToken(project.id, hashedToken); + const isValidToken = await this.projectsRepo.hasValidShareToken(project.slug, hashedToken); if (!isValidToken) { throw new BaseException( diff --git a/src/projects/application/use-cases/find-projects-by-team.query.ts b/src/projects/application/use-cases/find-projects-by-team.query.ts index 80670e8..c27f25e 100644 --- a/src/projects/application/use-cases/find-projects-by-team.query.ts +++ b/src/projects/application/use-cases/find-projects-by-team.query.ts @@ -26,7 +26,7 @@ export class FindProjectsByTeamQuery { items, meta: { total: items.length, - totalPages: items.length ? 1 : 0, + totalPages: items.length ? items.length : 1, page: 1, limit: 10, hasPrevPage: false, diff --git a/src/projects/application/use-cases/generate-share-token.use-case.ts b/src/projects/application/use-cases/generate-share-token.use-case.ts index f4fbf0d..fc75544 100644 --- a/src/projects/application/use-cases/generate-share-token.use-case.ts +++ b/src/projects/application/use-cases/generate-share-token.use-case.ts @@ -13,8 +13,8 @@ export class GenerateShareTokenUseCase { private readonly policy: ProjectAccessPolicy, ) {} - public async execute(id: string, teamId: string, userId: string, dto: CreateShareTokenDto) { - const { project } = await this.policy.validateProjectAccess(id, teamId, userId); + public async execute(slug: string, teamId: string, userId: string, dto: CreateShareTokenDto) { + const { project } = await this.policy.validateProjectAccess(slug, teamId, userId); let expiresAt: Date; diff --git a/src/projects/application/use-cases/get-project-detail.query.ts b/src/projects/application/use-cases/get-project-detail.query.ts index d5e04d4..5ef8b57 100644 --- a/src/projects/application/use-cases/get-project-detail.query.ts +++ b/src/projects/application/use-cases/get-project-detail.query.ts @@ -6,9 +6,9 @@ import { FindProjectQuery } from './find-project.query'; export class GetProjectDetailQuery { constructor(private readonly findProjectQuery: FindProjectQuery) {} - public async execute(id: string, teamId: string, userId?: string, token?: string) { + public async execute(slug: string, teamId: string, userId?: string, token?: string) { const { project, member } = await this.findProjectQuery.execute( - id, + slug, teamId, userId, token, diff --git a/src/projects/application/use-cases/index.ts b/src/projects/application/use-cases/index.ts index e7fb41c..a0749d1 100644 --- a/src/projects/application/use-cases/index.ts +++ b/src/projects/application/use-cases/index.ts @@ -6,15 +6,30 @@ import { UpdateProjectUseCase } from './update-project.use-case'; import { FindProjectsByTeamQuery } from './find-projects-by-team.query'; import { GetProjectDetailQuery } from './get-project-detail.query'; import { FindProjectQuery } from './find-project.query'; +import { CreateStateUseCase } from './states/create-state.use-case'; +import { DeleteStateUseCase } from './states/delete-state.use-case'; +import { UpdateStateUseCase } from './states/update-state.use-case'; +import { GetStateQuery } from './states/get-state.query'; +import { GetStatesQuery } from './states/get-states.query'; +import { RestoreStateUseCase } from './states/restore-state.use-state'; +import { ReorderStateUseCase } from './states/reorder-states.use-case'; +export * from './find-projects-by-team.query'; +export * from './find-project.query'; export * from './create-project.use-case'; export * from './delete-project.use-case'; export * from './generate-share-token.use-case'; +export * from './get-project-detail.query'; export * from './set-project-status.use-case'; export * from './update-project.use-case'; -export * from './find-projects-by-team.query'; -export * from './get-project-detail.query'; -export * from './find-project.query'; +export * from './states/create-state.use-case'; +export * from './states/delete-state.use-case'; +export * from './states/update-state.use-case'; +export * from './states/get-state.query'; +export * from './states/get-states.query'; +export * from './states/restore-state.use-state'; +export * from './states/update-state.use-case'; +export * from './states/reorder-states.use-case'; export const ProjectUseCases = [ CreateProjectUseCase, @@ -24,4 +39,16 @@ export const ProjectUseCases = [ UpdateProjectUseCase, ]; +export const ProjectStatesUseCases = [ + CreateStateUseCase, + RestoreStateUseCase, + DeleteStateUseCase, + UpdateStateUseCase, + GetStateQuery, + GetStatesQuery, + ReorderStateUseCase, +]; + export const ProjectQueries = [FindProjectsByTeamQuery, GetProjectDetailQuery, FindProjectQuery]; + +export const USE_CASES = [...ProjectUseCases, ...ProjectStatesUseCases, ...ProjectQueries]; diff --git a/src/projects/application/use-cases/set-project-status.use-case.ts b/src/projects/application/use-cases/set-project-status.use-case.ts index 479b390..7bc694e 100644 --- a/src/projects/application/use-cases/set-project-status.use-case.ts +++ b/src/projects/application/use-cases/set-project-status.use-case.ts @@ -12,8 +12,8 @@ export class SetProjectStatusUseCase { private readonly policy: ProjectAccessPolicy, ) {} - public async execute(id: string, teamId: string, userId: string, status: ProjectStatus) { - const { project } = await this.policy.validateProjectAccess(id, teamId, userId); + public async execute(slug: string, teamId: string, userId: string, status: ProjectStatus) { + const { project } = await this.policy.validateProjectAccess(slug, teamId, userId); const result = await this.projectsRepo.update(project.id, { status }); if (!result) { diff --git a/src/projects/application/use-cases/states/create-state.use-case.ts b/src/projects/application/use-cases/states/create-state.use-case.ts new file mode 100644 index 0000000..b43cfe8 --- /dev/null +++ b/src/projects/application/use-cases/states/create-state.use-case.ts @@ -0,0 +1,97 @@ +import { IProjectsRepository, IProjectStatesRepository } from '@core/projects/domain/repository'; +import { HttpStatus, Inject, Injectable } from '@nestjs/common'; +import { CreateProjectStateDto } from '../../dtos'; +import { ProjectAccessPolicy } from '@core/projects/domain/policy'; +import { BaseException } from '@shared/error'; +import { + ProjectErrorCodes, + ProjectErrorMessages, + ProjectStateErrorCodes, + ProjectStateErrorMessages, +} from '@core/projects/domain/errors'; + +const MAX_STATES_PER_PROJECT = 20; + +@Injectable() +export class CreateStateUseCase { + constructor( + @Inject('IProjectsRepository') + private readonly projectsRepo: IProjectsRepository, + @Inject('IProjectStatesRepository') + private readonly projectStatesRepo: IProjectStatesRepository, + private readonly policy: ProjectAccessPolicy, + ) {} + + async execute(slug: string, dto: CreateProjectStateDto, userId: string) { + const project = await this.projectsRepo.findOne(slug); + + if (!project) { + throw new BaseException( + { + code: ProjectErrorCodes.NOT_FOUND, + message: ProjectErrorMessages[ProjectErrorCodes.NOT_FOUND], + }, + HttpStatus.NOT_FOUND, + ); + } + + await this.policy.ensureTeamAccess(project.teamId, userId, 'admin'); + + const currentCount = await this.projectStatesRepo.countByProject(project.id); + if (currentCount >= MAX_STATES_PER_PROJECT) { + throw new BaseException( + { + code: ProjectStateErrorCodes.MAX_LIMIT_REACHED, + message: ProjectStateErrorMessages[ProjectStateErrorCodes.MAX_LIMIT_REACHED], + details: [{ current: currentCount, max: MAX_STATES_PER_PROJECT }], + }, + HttpStatus.UNPROCESSABLE_ENTITY, + ); + } + + if (dto.title) { + const existingByTitle = await this.projectStatesRepo.findByTitle(project.id, dto.title); + + if (existingByTitle) { + throw new BaseException( + { + code: ProjectStateErrorCodes.DUPLICATE_TITLE, + message: ProjectStateErrorMessages[ProjectStateErrorCodes.DUPLICATE_TITLE], + details: [{ title: dto.title }], + }, + HttpStatus.CONFLICT, + ); + } + } + + if (dto.stateType && dto.stateType !== 'custom') { + const existingByType = await this.projectStatesRepo.findByStateType( + project.id, + dto.stateType, + ); + + if (existingByType) { + throw new BaseException( + { + code: ProjectStateErrorCodes.DUPLICATE_TYPE, + message: ProjectStateErrorMessages[ProjectStateErrorCodes.DUPLICATE_TYPE], + details: [{ stateType: dto.stateType }], + }, + HttpStatus.CONFLICT, + ); + } + } + + const result = await this.projectStatesRepo.create({ + ...dto, + projectId: project.id, + createdBy: userId, + }); + + return { + success: true, + message: 'Состояние успешно создано', + stateId: result.id, + }; + } +} diff --git a/src/projects/application/use-cases/states/delete-state.use-case.ts b/src/projects/application/use-cases/states/delete-state.use-case.ts new file mode 100644 index 0000000..044ccbd --- /dev/null +++ b/src/projects/application/use-cases/states/delete-state.use-case.ts @@ -0,0 +1,91 @@ +import { + ProjectErrorCodes, + ProjectErrorMessages, + ProjectStateErrorCodes, + ProjectStateErrorMessages, +} from '@core/projects/domain/errors'; +import { ProjectAccessPolicy } from '@core/projects/domain/policy'; +import { IProjectsRepository, IProjectStatesRepository } from '@core/projects/domain/repository'; +import { HttpStatus, Inject, Injectable } from '@nestjs/common'; +import { BaseException } from '@shared/error'; + +@Injectable() +export class DeleteStateUseCase { + constructor( + @Inject('IProjectsRepository') + private readonly projectsRepo: IProjectsRepository, + @Inject('IProjectStatesRepository') + private readonly projectStatesRepo: IProjectStatesRepository, + private readonly policy: ProjectAccessPolicy, + ) {} + + async execute(slug: string, stateId: string, userId: string) { + const project = await this.projectsRepo.findOne(slug); + + if (!project) { + throw new BaseException( + { + code: ProjectErrorCodes.NOT_FOUND, + message: ProjectErrorMessages[ProjectErrorCodes.NOT_FOUND], + }, + HttpStatus.NOT_FOUND, + ); + } + + await this.policy.ensureTeamAccess(project.teamId, userId, 'admin'); + + const state = await this.projectStatesRepo.findOne(slug, stateId); + + if (!state) { + throw new BaseException( + { + code: ProjectStateErrorCodes.NOT_FOUND, + message: ProjectStateErrorMessages[ProjectStateErrorCodes.NOT_FOUND], + }, + HttpStatus.NOT_FOUND, + ); + } + + if (state.stateType !== 'custom') { + throw new BaseException( + { + code: ProjectStateErrorCodes.CANNOT_DELETE_SYSTEM, + message: ProjectStateErrorMessages[ProjectStateErrorCodes.CANNOT_DELETE_SYSTEM], + details: [{ stateType: state.stateType }], + }, + HttpStatus.FORBIDDEN, + ); + } + + if (state.isLocked) { + throw new BaseException( + { + code: ProjectStateErrorCodes.LOCKED, + message: ProjectStateErrorMessages[ProjectStateErrorCodes.LOCKED], + }, + HttpStatus.CONFLICT, + ); + } + + // const taskCount = await this.taskRepo.countByState(state.id); + // if (taskCount > 0) { + // throw new BaseException( + // { + // code: ProjectStateErrorCodes.HAS_ACTIVE_TASKS, + // message: ProjectStateErrorMessages[ProjectStateErrorCodes.HAS_ACTIVE_TASKS], + // details: { taskCount }, + // }, + // HttpStatus.CONFLICT, + // ); + // } + + const result = await this.projectStatesRepo.delete(slug, stateId); + + return { + success: result, + message: result + ? 'Состояние успешно удалено' + : 'Не удалось удалить состояние: запись не найдена или уже удалена', + }; + } +} diff --git a/src/projects/application/use-cases/states/get-state.query.ts b/src/projects/application/use-cases/states/get-state.query.ts new file mode 100644 index 0000000..4a0bb61 --- /dev/null +++ b/src/projects/application/use-cases/states/get-state.query.ts @@ -0,0 +1,51 @@ +import { + ProjectErrorCodes, + ProjectErrorMessages, + ProjectStateErrorCodes, + ProjectStateErrorMessages, +} from '@core/projects/domain/errors'; +import { ProjectAccessPolicy } from '@core/projects/domain/policy'; +import { IProjectsRepository, IProjectStatesRepository } from '@core/projects/domain/repository'; +import { HttpStatus, Inject, Injectable } from '@nestjs/common'; +import { BaseException } from '@shared/error'; + +@Injectable() +export class GetStateQuery { + constructor( + @Inject('IProjectsRepository') + private readonly projectRepo: IProjectsRepository, + @Inject('IProjectStatesRepository') + private readonly projectStatesRepo: IProjectStatesRepository, + private readonly policy: ProjectAccessPolicy, + ) {} + + async execute(slug: string, stateId: string, userId: string) { + const project = await this.projectRepo.findOne(slug); + + if (!project) { + throw new BaseException( + { + code: ProjectErrorCodes.NOT_FOUND, + message: ProjectErrorMessages[ProjectErrorCodes.NOT_FOUND], + }, + HttpStatus.NOT_FOUND, + ); + } + + await this.policy.ensureTeamAccess(project.teamId, userId, 'viewer'); + + const state = await this.projectStatesRepo.findOne(project.id, stateId); + + if (!state) { + throw new BaseException( + { + code: ProjectStateErrorCodes.NOT_FOUND, + message: ProjectStateErrorMessages[ProjectStateErrorCodes.NOT_FOUND], + }, + HttpStatus.NOT_FOUND, + ); + } + + return state; + } +} diff --git a/src/projects/application/use-cases/states/get-states.query.ts b/src/projects/application/use-cases/states/get-states.query.ts new file mode 100644 index 0000000..85a0881 --- /dev/null +++ b/src/projects/application/use-cases/states/get-states.query.ts @@ -0,0 +1,36 @@ +import { ProjectErrorCodes, ProjectErrorMessages } from '@core/projects/domain/errors'; +import { ProjectAccessPolicy } from '@core/projects/domain/policy'; +import { IProjectsRepository, IProjectStatesRepository } from '@core/projects/domain/repository'; +import { HttpStatus, Inject, Injectable } from '@nestjs/common'; +import { BaseException } from '@shared/error'; + +@Injectable() +export class GetStatesQuery { + constructor( + @Inject('IProjectsRepository') + private readonly projectsRepo: IProjectsRepository, + @Inject('IProjectStatesRepository') + private readonly projectStatesRepo: IProjectStatesRepository, + private readonly policy: ProjectAccessPolicy, + ) {} + + async execute(slug: string, query: unknown, userId: string) { + const project = await this.projectsRepo.findOne(slug); + + if (!project) { + throw new BaseException( + { + code: ProjectErrorCodes.NOT_FOUND, + message: ProjectErrorMessages[ProjectErrorCodes.NOT_FOUND], + }, + HttpStatus.NOT_FOUND, + ); + } + + await this.policy.ensureTeamAccess(project.teamId, userId, 'viewer'); + + const states = await this.projectStatesRepo.find(project.id, query); + + return states; + } +} diff --git a/src/projects/application/use-cases/states/reorder-states.use-case.ts b/src/projects/application/use-cases/states/reorder-states.use-case.ts new file mode 100644 index 0000000..6f8b00f --- /dev/null +++ b/src/projects/application/use-cases/states/reorder-states.use-case.ts @@ -0,0 +1,61 @@ +import { + ProjectErrorCodes, + ProjectErrorMessages, + ProjectStateErrorCodes, + ProjectStateErrorMessages, +} from '@core/projects/domain/errors'; +import { ProjectAccessPolicy } from '@core/projects/domain/policy'; +import { IProjectsRepository, IProjectStatesRepository } from '@core/projects/domain/repository'; +import { HttpStatus, Inject, Injectable } from '@nestjs/common'; +import { BaseException } from '@shared/error'; +import { ReorderProjectsStatesDto } from '../../dtos'; + +@Injectable() +export class ReorderStateUseCase { + constructor( + @Inject('IProjectsRepository') + private readonly projectsRepo: IProjectsRepository, + @Inject('IProjectStatesRepository') + private readonly projectStatesRepo: IProjectStatesRepository, + private readonly policy: ProjectAccessPolicy, + ) {} + + async execute(slug: string, dto: ReorderProjectsStatesDto, userId: string) { + const project = await this.projectsRepo.findOne(slug); + + if (!project) { + throw new BaseException( + { + code: ProjectErrorCodes.NOT_FOUND, + message: ProjectErrorMessages[ProjectErrorCodes.NOT_FOUND], + }, + HttpStatus.NOT_FOUND, + ); + } + + await this.policy.ensureTeamAccess(project.teamId, userId, 'admin'); + + const state = await this.projectStatesRepo.find(slug); + + if (!state) { + throw new BaseException( + { + code: ProjectStateErrorCodes.NOT_FOUND, + message: ProjectStateErrorMessages[ProjectStateErrorCodes.NOT_FOUND], + }, + HttpStatus.NOT_FOUND, + ); + } + + // TODO: ADD REODER STATES + void dto; + const result = true; + + return { + success: result, + message: result + ? 'Состояние успешно восстановлено' + : 'Не удалось восстановить состояние: запись не найдена или уже активна', + }; + } +} diff --git a/src/projects/application/use-cases/states/restore-state.use-state.ts b/src/projects/application/use-cases/states/restore-state.use-state.ts new file mode 100644 index 0000000..17a8046 --- /dev/null +++ b/src/projects/application/use-cases/states/restore-state.use-state.ts @@ -0,0 +1,60 @@ +import { + ProjectErrorCodes, + ProjectErrorMessages, + ProjectStateErrorCodes, + ProjectStateErrorMessages, +} from '@core/projects/domain/errors'; +import { ProjectAccessPolicy } from '@core/projects/domain/policy'; +import { IProjectsRepository, IProjectStatesRepository } from '@core/projects/domain/repository'; +import { HttpStatus, Inject, Injectable } from '@nestjs/common'; +import { BaseException } from '@shared/error'; + +@Injectable() +export class RestoreStateUseCase { + constructor( + @Inject('IProjectsRepository') + private readonly projectsRepo: IProjectsRepository, + @Inject('IProjectStatesRepository') + private readonly projectStatesRepo: IProjectStatesRepository, + private readonly policy: ProjectAccessPolicy, + ) {} + + async execute(slug: string, stateId: string, userId: string) { + const project = await this.projectsRepo.findOne(slug); + + if (!project) { + throw new BaseException( + { + code: ProjectErrorCodes.NOT_FOUND, + message: ProjectErrorMessages[ProjectErrorCodes.NOT_FOUND], + }, + HttpStatus.NOT_FOUND, + ); + } + + await this.policy.ensureTeamAccess(project.teamId, userId, 'admin'); + + const state = await this.projectStatesRepo.findOne(slug, stateId, true); + + if (!state) { + throw new BaseException( + { + code: ProjectStateErrorCodes.NOT_FOUND, + message: ProjectStateErrorMessages[ProjectStateErrorCodes.NOT_FOUND], + }, + HttpStatus.NOT_FOUND, + ); + } + + const result = await this.projectStatesRepo.update(project.id, stateId, { + deletedAt: null, + }); + + return { + success: result, + message: result + ? 'Состояние успешно восстановлено' + : 'Не удалось восстановить состояние: запись не найдена или уже активна', + }; + } +} diff --git a/src/projects/application/use-cases/states/update-state.use-case.ts b/src/projects/application/use-cases/states/update-state.use-case.ts new file mode 100644 index 0000000..1b398a5 --- /dev/null +++ b/src/projects/application/use-cases/states/update-state.use-case.ts @@ -0,0 +1,80 @@ +import { ProjectAccessPolicy } from '@core/projects/domain/policy'; +import { IProjectsRepository, IProjectStatesRepository } from '@core/projects/domain/repository'; +import { + ProjectErrorCodes, + ProjectErrorMessages, + ProjectStateErrorCodes, + ProjectStateErrorMessages, +} from '@core/projects/domain/errors'; +import { HttpStatus, Inject, Injectable } from '@nestjs/common'; +import { BaseException } from '@shared/error'; +import { UpdateProjectStateDto } from '../../dtos'; + +@Injectable() +export class UpdateStateUseCase { + constructor( + @Inject('IProjectsRepository') + private readonly projectsRepo: IProjectsRepository, + @Inject('IProjectStatesRepository') + private readonly projectStatesRepo: IProjectStatesRepository, + private readonly policy: ProjectAccessPolicy, + ) {} + + async execute(slug: string, stateId: string, dto: UpdateProjectStateDto, userId: string) { + const project = await this.projectsRepo.findOne(slug); + + if (!project) { + throw new BaseException( + { + code: ProjectErrorCodes.NOT_FOUND, + message: ProjectErrorMessages[ProjectErrorCodes.NOT_FOUND], + }, + HttpStatus.NOT_FOUND, + ); + } + + await this.policy.ensureTeamAccess(project.teamId, userId, 'admin'); + + const state = await this.projectStatesRepo.findOne(slug, stateId); + + if (!state) { + throw new BaseException( + { + code: ProjectStateErrorCodes.NOT_FOUND, + message: ProjectStateErrorMessages[ProjectStateErrorCodes.NOT_FOUND], + }, + HttpStatus.NOT_FOUND, + ); + } + + if (state.isLocked) { + throw new BaseException( + { + code: ProjectStateErrorCodes.LOCKED, + message: ProjectStateErrorMessages[ProjectStateErrorCodes.LOCKED], + }, + HttpStatus.CONFLICT, + ); + } + + if (state.stateType !== 'custom' && dto.stateType === 'custom') { + throw new BaseException( + { + code: ProjectStateErrorCodes.SYSTEM_TYPE_IMMUTABLE, + message: + ProjectStateErrorMessages[ProjectStateErrorCodes.SYSTEM_TYPE_IMMUTABLE], + }, + HttpStatus.UNPROCESSABLE_ENTITY, + ); + } + + const result = await this.projectStatesRepo.update(slug, stateId, dto); + + return { + success: result, + message: result + ? 'Состояние успешно обновлено' + : 'Не удалось обновить состояние: запись не найдена', + }; + } +} diff --git a/src/projects/application/use-cases/update-project.use-case.ts b/src/projects/application/use-cases/update-project.use-case.ts index a41dc44..0e59ea9 100644 --- a/src/projects/application/use-cases/update-project.use-case.ts +++ b/src/projects/application/use-cases/update-project.use-case.ts @@ -12,11 +12,16 @@ export class UpdateProjectUseCase { private readonly policy: ProjectAccessPolicy, ) {} - public async execute(id: string, teamId: string, userId: string, dto: UpdateProjectDto) { - const { project } = await this.policy.validateProjectAccess(id, teamId, userId); + public async execute( + projectSlug: string, + teamId: string, + userId: string, + dto: UpdateProjectDto, + ) { + const { project } = await this.policy.validateProjectAccess(projectSlug, teamId, userId); const { isPublic, slug, ...data } = dto; - const result = await this.projectsRepo.update(project.id, { + const result = await this.projectsRepo.update(project.slug, { ...data, ...(slug && { slug: slug.toUpperCase() }), ...(typeof isPublic === 'boolean' && { diff --git a/src/projects/domain/entities/index.ts b/src/projects/domain/entities/index.ts index 1481834..0b3718b 100644 --- a/src/projects/domain/entities/index.ts +++ b/src/projects/domain/entities/index.ts @@ -1 +1,2 @@ -export * from './entities.domain'; +export * from './project.domain'; +export * from './state.domain'; diff --git a/src/projects/domain/entities/entities.domain.ts b/src/projects/domain/entities/project.domain.ts similarity index 100% rename from src/projects/domain/entities/entities.domain.ts rename to src/projects/domain/entities/project.domain.ts diff --git a/src/projects/domain/entities/state.domain.ts b/src/projects/domain/entities/state.domain.ts new file mode 100644 index 0000000..040fb20 --- /dev/null +++ b/src/projects/domain/entities/state.domain.ts @@ -0,0 +1,5 @@ +import { projectStates } from '@shared/entities'; +import { InferInsertModel, InferSelectModel } from 'drizzle-orm'; + +export type ProjectState = InferSelectModel; +export type NewProjectState = InferInsertModel; diff --git a/src/projects/domain/errors/index.ts b/src/projects/domain/errors/index.ts new file mode 100644 index 0000000..73b4b28 --- /dev/null +++ b/src/projects/domain/errors/index.ts @@ -0,0 +1,2 @@ +export * from './state.errors'; +export * from './project.errors'; diff --git a/src/projects/domain/errors/project.errors.ts b/src/projects/domain/errors/project.errors.ts new file mode 100644 index 0000000..3857bae --- /dev/null +++ b/src/projects/domain/errors/project.errors.ts @@ -0,0 +1,51 @@ +export const ProjectErrorCodes = { + NOT_FOUND: 'PROJECT.NOT_FOUND', + SLUG_DUPLICATE: 'PROJECT.SLUG_DUPLICATE', + NAME_DUPLICATE: 'PROJECT.NAME_DUPLICATE', + SLUG_INVALID: 'PROJECT.SLUG_INVALID', + NAME_INVALID: 'PROJECT.NAME_INVALID', + COLOR_INVALID: 'PROJECT.COLOR_INVALID', + ICON_INVALID: 'PROJECT.ICON_INVALID', + DESCRIPTION_TOO_LONG: 'PROJECT.DESCRIPTION_TOO_LONG', + MAX_PROJECTS_REACHED: 'PROJECT.MAX_PROJECTS_REACHED', + ALREADY_ARCHIVED: 'PROJECT.ALREADY_ARCHIVED', + ALREADY_ACTIVE: 'PROJECT.ALREADY_ACTIVE', + CANNOT_ARCHIVE_WITH_ACTIVE_TASKS: 'PROJECT.CANNOT_ARCHIVE_WITH_ACTIVE_TASKS', + CANNOT_DELETE_NOT_ARCHIVED: 'PROJECT.CANNOT_DELETE_NOT_ARCHIVED', + OWNER_NOT_IN_TEAM: 'PROJECT.OWNER_NOT_IN_TEAM', + INVALID_VISIBILITY: 'PROJECT.INVALID_VISIBILITY', + INVALID_STATUS: 'PROJECT.INVALID_STATUS', + TEAM_REQUIRED: 'PROJECT.TEAM_REQUIRED', + UPDATE_FAILED: 'PROJECT.UPDATE_FAILED', + DELETE_FAILED: 'PROJECT.DELETE_FAILED', + RESTORE_FAILED: 'PROJECT.RESTORE_FAILED', +} as const; + +export type ProjectErrorCode = (typeof ProjectErrorCodes)[keyof typeof ProjectErrorCodes]; + +export const ProjectErrorMessages: Record = { + [ProjectErrorCodes.NOT_FOUND]: 'Проект не найден', + [ProjectErrorCodes.SLUG_DUPLICATE]: 'Проект с таким ключом уже существует в команде', + [ProjectErrorCodes.NAME_DUPLICATE]: 'Проект с таким названием уже существует в команде', + [ProjectErrorCodes.SLUG_INVALID]: + 'Ключ проекта должен содержать только строчные латинские буквы и цифры (2-10 символов)', + [ProjectErrorCodes.NAME_INVALID]: + 'Название проекта не может быть пустым и должно быть не длиннее 100 символов', + [ProjectErrorCodes.COLOR_INVALID]: 'Цвет должен быть в формате HEX (например, #FFFFFF)', + [ProjectErrorCodes.ICON_INVALID]: 'URL иконки слишком длинный (максимум 255 символов)', + [ProjectErrorCodes.DESCRIPTION_TOO_LONG]: 'Описание слишком длинное (максимум 2000 символов)', + [ProjectErrorCodes.MAX_PROJECTS_REACHED]: 'Достигнут лимит проектов в команде', + [ProjectErrorCodes.ALREADY_ARCHIVED]: 'Проект уже находится в архиве', + [ProjectErrorCodes.ALREADY_ACTIVE]: 'Проект уже активен', + [ProjectErrorCodes.CANNOT_ARCHIVE_WITH_ACTIVE_TASKS]: + 'Нельзя архивировать проект с активными задачами', + [ProjectErrorCodes.CANNOT_DELETE_NOT_ARCHIVED]: + 'Перед удалением проект необходимо архивировать', + [ProjectErrorCodes.OWNER_NOT_IN_TEAM]: 'Владелец проекта должен быть участником команды', + [ProjectErrorCodes.INVALID_VISIBILITY]: 'Недопустимый тип видимости проекта', + [ProjectErrorCodes.INVALID_STATUS]: 'Недопустимый статус проекта', + [ProjectErrorCodes.TEAM_REQUIRED]: 'ID команды обязателен', + [ProjectErrorCodes.UPDATE_FAILED]: 'Не удалось обновить проект', + [ProjectErrorCodes.DELETE_FAILED]: 'Не удалось удалить проект', + [ProjectErrorCodes.RESTORE_FAILED]: 'Не удалось восстановить проект', +}; diff --git a/src/projects/domain/errors/state.errors.ts b/src/projects/domain/errors/state.errors.ts new file mode 100644 index 0000000..9a3e2cc --- /dev/null +++ b/src/projects/domain/errors/state.errors.ts @@ -0,0 +1,130 @@ +export const ProjectStateErrorCodes = { + NOT_FOUND: 'STATE.NOT_FOUND', + UPDATE_FAILED: 'STATE.UPDATE_FAILED', + DUPLICATE_TITLE: 'STATE.DUPLICATE_TITLE', + DUPLICATE_TYPE: 'STATE.DUPLICATE_TYPE', + SYSTEM_TYPE_IMMUTABLE: 'STATE.SYSTEM_TYPE_IMMUTABLE', + MAX_LIMIT_REACHED: 'STATE.MAX_LIMIT_REACHED', + INVALID_TRANSITION: 'STATE.INVALID_TRANSITION', + LOCKED: 'STATE.LOCKED', + + CREATE_FAILED: 'STATE.CREATE_FAILED', + TITLE_REQUIRED: 'STATE.TITLE_REQUIRED', + TITLE_TOO_LONG: 'STATE.TITLE_TOO_LONG', + PROJECT_REQUIRED: 'STATE.PROJECT_REQUIRED', + SLUG_INVALID: 'STATE.SLUG_INVALID', + SLUG_DUPLICATE: 'STATE.SLUG_DUPLICATE', + COLOR_INVALID: 'STATE.COLOR_INVALID', + ICON_INVALID: 'STATE.ICON_INVALID', + DESCRIPTION_TOO_LONG: 'STATE.DESCRIPTION_TOO_LONG', + ORDER_INDEX_INVALID: 'STATE.ORDER_INDEX_INVALID', + MAX_TASKS_LIMIT_INVALID: 'STATE.MAX_TASKS_LIMIT_INVALID', + AUTO_TRANSITION_INVALID: 'STATE.AUTO_TRANSITION_INVALID', + SYSTEM_TYPE_REQUIRED: 'STATE.SYSTEM_TYPE_REQUIRED', + + DELETE_FAILED: 'STATE.DELETE_FAILED', + CANNOT_DELETE_SYSTEM: 'STATE.CANNOT_DELETE_SYSTEM', + CANNOT_DELETE_LAST_ACTIVE: 'STATE.CANNOT_DELETE_LAST_ACTIVE', + HAS_ACTIVE_TASKS: 'STATE.HAS_ACTIVE_TASKS', + ALREADY_DELETED: 'STATE.ALREADY_DELETED', + + RESTORE_FAILED: 'STATE.RESTORE_FAILED', + NOT_DELETED: 'STATE.NOT_DELETED', + + REORDER_FAILED: 'STATE.REORDER_FAILED', + CANNOT_REORDER_SYSTEM: 'STATE.CANNOT_REORDER_SYSTEM', + + CATEGORY_IMMUTABLE: 'STATE.CATEGORY_IMMUTABLE', + INVALID_CATEGORY: 'STATE.INVALID_CATEGORY', + + CANNOT_HIDE_SYSTEM: 'STATE.CANNOT_HIDE_SYSTEM', + + NOTIFY_ON_ENTER_INVALID: 'STATE.NOTIFY_ON_ENTER_INVALID', + NOTIFY_ON_EXIT_INVALID: 'STATE.NOTIFY_ON_EXIT_INVALID', + + VERSION_CONFLICT: 'STATE.VERSION_CONFLICT', + VERSION_REQUIRED: 'STATE.VERSION_REQUIRED', + + WIP_LIMIT_EXCEEDED: 'STATE.WIP_LIMIT_EXCEEDED', + WIP_LIMIT_NEGATIVE: 'STATE.WIP_LIMIT_NEGATIVE', + + AUTO_TRANSITION_SELF: 'STATE.AUTO_TRANSITION_SELF', + AUTO_TRANSITION_NOT_FOUND: 'STATE.AUTO_TRANSITION_NOT_FOUND', + + PARENT_STATE_NOT_FOUND: 'STATE.PARENT_STATE_NOT_FOUND', + CIRCULAR_DEPENDENCY: 'STATE.CIRCULAR_DEPENDENCY', +} as const; + +export type ProjectStateErrorCode = + (typeof ProjectStateErrorCodes)[keyof typeof ProjectStateErrorCodes]; + +export const ProjectStateErrorMessages: Record = { + [ProjectStateErrorCodes.NOT_FOUND]: 'Состояние проекта не найдено', + [ProjectStateErrorCodes.UPDATE_FAILED]: 'Не удалось обновить состояние', + [ProjectStateErrorCodes.DUPLICATE_TITLE]: + 'Состояние с таким названием уже существует в проекте', + [ProjectStateErrorCodes.DUPLICATE_TYPE]: 'Системный тип состояния уже используется в проекте', + [ProjectStateErrorCodes.SYSTEM_TYPE_IMMUTABLE]: + 'Нельзя изменить тип системного состояния на custom', + [ProjectStateErrorCodes.MAX_LIMIT_REACHED]: 'Достигнут лимит состояний в проекте', + [ProjectStateErrorCodes.INVALID_TRANSITION]: 'Недопустимый переход между состояниями', + [ProjectStateErrorCodes.LOCKED]: 'Состояние заблокировано и не может быть изменено', + + [ProjectStateErrorCodes.CREATE_FAILED]: 'Не удалось создать состояние', + [ProjectStateErrorCodes.TITLE_REQUIRED]: 'Название состояния не может быть пустым', + [ProjectStateErrorCodes.TITLE_TOO_LONG]: + 'Название состояния слишком длинное (максимум 255 символов)', + [ProjectStateErrorCodes.PROJECT_REQUIRED]: 'ID проекта обязателен', + [ProjectStateErrorCodes.SLUG_INVALID]: + 'Ключ должен содержать только строчные латинские буквы, цифры и _ (до 50 символов)', + [ProjectStateErrorCodes.SLUG_DUPLICATE]: 'Состояние с таким ключом уже существует в проекте', + [ProjectStateErrorCodes.COLOR_INVALID]: 'Цвет должен быть в формате HEX (например, #FFFFFF)', + [ProjectStateErrorCodes.ICON_INVALID]: 'Иконка слишком длинная (максимум 20 символов)', + [ProjectStateErrorCodes.DESCRIPTION_TOO_LONG]: + 'Описание слишком длинное (максимум 2000 символов)', + [ProjectStateErrorCodes.ORDER_INDEX_INVALID]: 'Недопустимый индекс порядка', + [ProjectStateErrorCodes.MAX_TASKS_LIMIT_INVALID]: + 'Лимит задач должен быть положительным числом', + [ProjectStateErrorCodes.AUTO_TRANSITION_INVALID]: 'Недопустимое состояние для автоперехода', + [ProjectStateErrorCodes.SYSTEM_TYPE_REQUIRED]: + 'Для проекта должен быть хотя бы один системный тип каждого вида (todo, in_progress, done)', + + [ProjectStateErrorCodes.DELETE_FAILED]: 'Не удалось удалить состояние', + [ProjectStateErrorCodes.CANNOT_DELETE_SYSTEM]: 'Нельзя удалить системное состояние', + [ProjectStateErrorCodes.CANNOT_DELETE_LAST_ACTIVE]: + 'Нельзя удалить последнее активное состояние проекта', + [ProjectStateErrorCodes.HAS_ACTIVE_TASKS]: 'Нельзя удалить состояние, в котором есть задачи', + [ProjectStateErrorCodes.ALREADY_DELETED]: 'Состояние уже удалено', + + [ProjectStateErrorCodes.RESTORE_FAILED]: 'Не удалось восстановить состояние', + [ProjectStateErrorCodes.NOT_DELETED]: 'Состояние не удалено, восстановление не требуется', + + [ProjectStateErrorCodes.REORDER_FAILED]: 'Не удалось изменить порядок состояний', + [ProjectStateErrorCodes.CANNOT_REORDER_SYSTEM]: 'Нельзя изменить порядок системных состояний', + + [ProjectStateErrorCodes.CATEGORY_IMMUTABLE]: 'Нельзя изменить категорию системного состояния', + [ProjectStateErrorCodes.INVALID_CATEGORY]: 'Недопустимая категория состояния', + + [ProjectStateErrorCodes.CANNOT_HIDE_SYSTEM]: 'Нельзя скрыть системное состояние', + + [ProjectStateErrorCodes.NOTIFY_ON_ENTER_INVALID]: + 'Некорректная настройка уведомления при входе', + [ProjectStateErrorCodes.NOTIFY_ON_EXIT_INVALID]: + 'Некорректная настройка уведомления при выходе', + + [ProjectStateErrorCodes.VERSION_CONFLICT]: + 'Состояние было изменено другим пользователем. Обновите страницу и попробуйте снова', + [ProjectStateErrorCodes.VERSION_REQUIRED]: 'Версия состояния обязательна для обновления', + + [ProjectStateErrorCodes.WIP_LIMIT_EXCEEDED]: 'Достигнут лимит задач в этом состоянии', + [ProjectStateErrorCodes.WIP_LIMIT_NEGATIVE]: 'Лимит задач не может быть отрицательным', + + [ProjectStateErrorCodes.AUTO_TRANSITION_SELF]: + 'Нельзя настроить автопереход состояния на само себя', + [ProjectStateErrorCodes.AUTO_TRANSITION_NOT_FOUND]: + 'Целевое состояние для автоперехода не найдено', + + [ProjectStateErrorCodes.PARENT_STATE_NOT_FOUND]: 'Родительское состояние не найдено', + [ProjectStateErrorCodes.CIRCULAR_DEPENDENCY]: + 'Обнаружена циклическая зависимость между состояниями', +}; diff --git a/src/projects/domain/policy/project-access.policy.ts b/src/projects/domain/policy/project-access.policy.ts index fc92248..a09ca68 100644 --- a/src/projects/domain/policy/project-access.policy.ts +++ b/src/projects/domain/policy/project-access.policy.ts @@ -55,14 +55,14 @@ export class ProjectAccessPolicy { * Полная проверка доступа к конкретному проекту внутри команды */ public async validateProjectAccess( - projectId: string, + slug: string, teamId: string, userId: string, minRole: keyof typeof ROLE_PRIORITY = 'admin', ) { const { team, member } = await this.ensureTeamAccess(teamId, userId, minRole); - const project = await this.projectsRepo.findOne(projectId); + const project = await this.projectsRepo.findOne(slug); if (!project || project.teamId !== team.id) { throw new BaseException( { @@ -77,14 +77,14 @@ export class ProjectAccessPolicy { } /** - * Проверка доступа к проекту по projectId + * Проверка доступа к проекту по slug */ public async validateProjectAccessById( - projectId: string, + slug: string, userId: string, minRole: keyof typeof ROLE_PRIORITY = 'viewer', ) { - const project = await this.projectsRepo.findOne(projectId); + const project = await this.projectsRepo.findOne(slug); if (!project) { throw new BaseException( { code: 'PROJECT_NOT_FOUND', message: 'Проект не найден' }, diff --git a/src/projects/domain/repository/index.ts b/src/projects/domain/repository/index.ts index aea7492..078c432 100644 --- a/src/projects/domain/repository/index.ts +++ b/src/projects/domain/repository/index.ts @@ -1 +1,2 @@ -export { IProjectsRepository } from './projects.repository.interface'; +export * from './projects.repository.interface'; +export * from './project-states.repository.interface'; diff --git a/src/projects/domain/repository/project-states.repository.interface.ts b/src/projects/domain/repository/project-states.repository.interface.ts new file mode 100644 index 0000000..f421af8 --- /dev/null +++ b/src/projects/domain/repository/project-states.repository.interface.ts @@ -0,0 +1,16 @@ +import { NewProjectState, ProjectState } from '../entities'; + +export interface IProjectStatesRepository { + create(dto: NewProjectState): Promise<{ id: string }>; + update(projectId: string, stateId: string, dto: Partial): Promise; + delete(projectId: string, stateId: string): Promise; + findOne(projectId: string, stateId: string, deleted?: boolean): Promise; + find(projectId: string, query?: unknown): Promise; + + findByTitle(projectId: string, title: string): Promise; + + // TODO: FIX that any, to coerce + findByStateType(projectId: string, stateType: any): Promise; + + countByProject(projectId: string): Promise; +} diff --git a/src/projects/domain/repository/projects.repository.interface.ts b/src/projects/domain/repository/projects.repository.interface.ts index 58fc8cf..c40781f 100644 --- a/src/projects/domain/repository/projects.repository.interface.ts +++ b/src/projects/domain/repository/projects.repository.interface.ts @@ -1,12 +1,12 @@ import type { NewProject, NewProjectShare, Project } from '../entities'; export interface IProjectsRepository { - create(data: NewProject): Promise<{ result: boolean; id: string }>; - update(id: string, data: Partial): Promise; - delete(id: string): Promise; - findOne(id: string): Promise; + create(data: NewProject): Promise<{ result: boolean; slug: string }>; + update(slug: string, data: Partial): Promise; + delete(slug: string): Promise; + findOne(slug: string): Promise; findByTeam(teamId: string): Promise; createShare(data: NewProjectShare): Promise; - hasValidShareToken(id: string, token: string): Promise; + hasValidShareToken(slug: string, token: string): Promise; revokeAllShares(projectId: string): Promise; } diff --git a/src/projects/infrastructure/persistence/models/index.ts b/src/projects/infrastructure/persistence/models/index.ts index ed46b14..08b849a 100644 --- a/src/projects/infrastructure/persistence/models/index.ts +++ b/src/projects/infrastructure/persistence/models/index.ts @@ -1,2 +1,2 @@ export { projectStatusEnum, projectVisibilityEnum } from './enums'; -export { projectShares, projects } from './projects.model'; +export { projectShares, projects, projectStates } from './projects.model'; diff --git a/src/projects/infrastructure/persistence/models/projects.model.ts b/src/projects/infrastructure/persistence/models/projects.model.ts index 1bd28df..ae18a92 100644 --- a/src/projects/infrastructure/persistence/models/projects.model.ts +++ b/src/projects/infrastructure/persistence/models/projects.model.ts @@ -10,7 +10,7 @@ import { } from 'drizzle-orm/pg-core'; import { baseSchema, teams, users } from '@shared/entities'; import { createId } from '@paralleldrive/cuid2'; -import { isNull, sql } from 'drizzle-orm'; +import { and, eq, isNotNull, isNull, not } from 'drizzle-orm'; import { projectStatusEnum, projectVisibilityEnum, @@ -27,8 +27,8 @@ export const projects = baseSchema.table( teamId: text('team_id') .references(() => teams.id, { onDelete: 'cascade' }) .notNull(), - slug: varchar('slug', { length: 100 }).notNull(), - name: varchar('name', { length: 100 }).notNull(), + slug: varchar('slug', { length: 100 }).notNull().unique(), + name: varchar('name', { length: 100 }).notNull().unique(), description: text('description'), icon: varchar('icon', { length: 255 }), color: varchar('color', { length: 7 }), @@ -64,47 +64,39 @@ export const projectStates = baseSchema.table( .primaryKey() .$defaultFn(() => createId()), projectId: text('project_id').references(() => projects.id, { onDelete: 'cascade' }), - title: text('title').notNull(), description: text('description'), - - slug: varchar('slug', { length: 50 }), - stateType: stateTypeEnum('state_type').notNull().default('custom'), category: stateCategoryEnum('category').notNull().default('active'), - color: varchar('color', { length: 10 }), icon: varchar('icon', { length: 20 }), - orderIndex: integer('order_index').notNull().default(0), isVisible: boolean('is_visible').notNull().default(true), maxTasksLimit: integer('max_tasks_limit'), autoTransitionTo: text('auto_transition_to'), - notifyOnEnter: boolean('notify_on_enter').default(false), notifyOnExit: boolean('notify_on_exit').default(false), isLocked: boolean('is_locked').default(false), - version: integer('version').default(0), - - createdAt: timestamp('created_at').notNull().defaultNow(), - updatedAt: timestamp('updated_at').notNull().defaultNow(), + createdAt: timestamp('created_at', { withTimezone: true, mode: 'string' }) + .notNull() + .defaultNow(), + updatedAt: timestamp('updated_at', { withTimezone: true, mode: 'string' }) + .notNull() + .defaultNow(), createdBy: text('created_by').references(() => users.id), - deletedAt: timestamp('deleted_at'), + deletedAt: timestamp('deleted_at', { withTimezone: true, mode: 'string' }), }, (t) => ({ projectOrderIdx: index('idx_project_states_project_order').on(t.projectId, t.orderIndex), - uniqueProjectStateType: uniqueIndex('idx_project_states_unique_type') .on(t.projectId, t.stateType) - .where(sql`deleted_at IS NULL AND state_type != 'custom'`), - + .where(and(isNull(t.deletedAt), not(eq(t.stateType, 'custom')))), uniqueProjectStateTitle: uniqueIndex('idx_project_states_unique_title') .on(t.projectId, t.title) - .where(sql`deleted_at IS NULL`), - + .where(isNull(t.deletedAt)), deletedAtIdx: index('idx_project_states_deleted_at') .on(t.deletedAt) - .where(sql`deleted_at IS NOT NULL`), + .where(isNotNull(t.deletedAt)), }), ); @@ -119,7 +111,7 @@ export const projectShares = baseSchema.table( .references(() => projects.id, { onDelete: 'cascade' }), token: text('token').notNull().unique(), expiresAt: timestamp('expires_at', { withTimezone: true, mode: 'string' }), - createdBy: text('created_by').notNull(), + createdBy: text('created_by').references(() => users.id), createdAt: timestamp('created_at', { withTimezone: true, mode: 'string' }) .defaultNow() .notNull(), diff --git a/src/projects/infrastructure/persistence/repositories/index.ts b/src/projects/infrastructure/persistence/repositories/index.ts index 5c64093..3393866 100644 --- a/src/projects/infrastructure/persistence/repositories/index.ts +++ b/src/projects/infrastructure/persistence/repositories/index.ts @@ -1,2 +1,13 @@ -export { ProjectsRepository } from './projects.repository'; -export { IProjectsRepository } from '../../../domain/repository/projects.repository.interface'; +import { ProjectStatesRepository } from './states.repository'; +import { ProjectsRepository } from './projects.repository'; + +export const REPOSITORIES = [ + { + provide: 'IProjectsRepository', + useClass: ProjectsRepository, + }, + { + provide: 'IProjectStatesRepository', + useClass: ProjectStatesRepository, + }, +]; diff --git a/src/projects/infrastructure/persistence/repositories/projects.repository.ts b/src/projects/infrastructure/persistence/repositories/projects.repository.ts index 73fce8e..915f319 100644 --- a/src/projects/infrastructure/persistence/repositories/projects.repository.ts +++ b/src/projects/infrastructure/persistence/repositories/projects.repository.ts @@ -16,9 +16,9 @@ export class ProjectsRepository implements IProjectsRepository { const result = await this.db .insert(schema.projects) .values(data) - .returning({ id: schema.projects.id }); + .returning({ slug: schema.projects.slug }); - return { result: result.length > 0, id: result[0].id }; + return { result: result.length > 0, slug: result[0].slug }; }; public update = async (id: string, data: Partial) => { diff --git a/src/projects/infrastructure/persistence/repositories/states.repository.ts b/src/projects/infrastructure/persistence/repositories/states.repository.ts new file mode 100644 index 0000000..0c8697c --- /dev/null +++ b/src/projects/infrastructure/persistence/repositories/states.repository.ts @@ -0,0 +1,122 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { IProjectStatesRepository } from '@core/projects/domain/repository'; +import { DATABASE_SERVICE, DatabaseService } from '@libs/database'; +import * as schema from '../models'; +import { and, count, eq, isNotNull, isNull } from 'drizzle-orm'; +import type { NewProjectState } from '@core/projects/domain/entities'; + +@Injectable() +export class ProjectStatesRepository implements IProjectStatesRepository { + constructor( + @Inject(DATABASE_SERVICE) + private readonly db: DatabaseService, + ) {} + + public async create(data: NewProjectState) { + const [result] = await this.db + .insert(schema.projectStates) + .values(data) + .returning({ id: schema.projectStates.id }); + + return result; + } + + public async delete(projectId: string, stateId: string) { + const result = await this.db + .delete(schema.projectStates) + .where( + and( + eq(schema.projectStates.id, stateId), + eq(schema.projectStates.projectId, projectId), + isNotNull(schema.projectStates.deletedAt), + ), + ); + + return (result.count ?? 0) > 0; + } + + public async find(query: unknown) { + void query; + return this.db.select().from(schema.projectStates); + } + + public async findOne(projectId: string, stateId: string, deleted?: boolean) { + const [result] = await this.db + .select() + .from(schema.projectStates) + .where( + and( + eq(schema.projectStates.id, stateId), + eq(schema.projectStates.projectId, projectId), + deleted + ? isNotNull(schema.projectStates.deletedAt) + : isNull(schema.projectStates.deletedAt), + ), + ); + + return result ?? null; + } + + public async update(projectId: string, stateId: string, data: Partial) { + const result = await this.db + .update(schema.projectStates) + .set(data) + .where( + and( + eq(schema.projectStates.id, stateId), + eq(schema.projectStates.projectId, projectId), + isNull(schema.projectStates.deletedAt), + ), + ); + + return (result.count ?? 0) > 0; + } + + public async findByStateType( + projectId: string, + // TODO: ADD BASE ENUM TOO + stateType: 'custom' | 'archived' | 'backlog' | 'todo' | 'in_progress' | 'review' | 'done', + ) { + const [result] = await this.db + .select() + .from(schema.projectStates) + .where( + and( + eq(schema.projectStates.projectId, projectId), + eq(schema.projectStates.stateType, stateType), + isNull(schema.projectStates.deletedAt), + ), + ); + + return result ?? null; + } + + public async findByTitle(projectId: string, title: string) { + const [result] = await this.db + .select() + .from(schema.projectStates) + .where( + and( + eq(schema.projectStates.projectId, projectId), + eq(schema.projectStates.title, title), + isNull(schema.projectStates.deletedAt), + ), + ); + + return result ?? null; + } + + public async countByProject(projectId: string) { + const [result] = await this.db + .select({ count: count() }) + .from(schema.projectStates) + .where( + and( + eq(schema.projectStates.projectId, projectId), + isNull(schema.projectStates.deletedAt), + ), + ); + + return result.count; + } +} diff --git a/src/projects/projects.module.ts b/src/projects/projects.module.ts index d0fcb0a..97ca4c3 100644 --- a/src/projects/projects.module.ts +++ b/src/projects/projects.module.ts @@ -1,20 +1,15 @@ import { forwardRef, Module } from '@nestjs/common'; -import { ProjectsRepository } from './infrastructure/persistence/repositories'; import { TeamsModule } from '@core/teams'; import { CONTROLLERS } from './application/controller'; -import { FindProjectQuery, ProjectQueries, ProjectUseCases } from './application/use-cases'; +import { FindProjectQuery, USE_CASES } from './application/use-cases'; import { POLICIES, ProjectAccessPolicy } from './domain/policy'; import { ProjectsFacade } from './application/projects.facade'; - -const REPOSITORY = { - provide: 'IProjectsRepository', - useClass: ProjectsRepository, -}; +import { REPOSITORIES } from './infrastructure/persistence/repositories'; @Module({ imports: [forwardRef(() => TeamsModule)], controllers: CONTROLLERS, - providers: [REPOSITORY, ...POLICIES, ...ProjectUseCases, ...ProjectQueries, ProjectsFacade], + providers: [...REPOSITORIES, ...POLICIES, ...USE_CASES, ProjectsFacade], exports: [FindProjectQuery, ProjectAccessPolicy], }) export class ProjectsModule {} From be440d6bde02efb9c94051b602663c7d9a774cff Mon Sep 17 00:00:00 2001 From: soorq Date: Sat, 13 Jun 2026 03:09:02 +0300 Subject: [PATCH 3/5] feat(projects): add members, areas, share tokens, access policy - Project members CRUD with owner/admin/member/viewer roles - Project access policy with role-based permissions - Area create/update/delete/query use-cases with error codes - Share token generation with SHA-256 hashing - Slug availability check endpoint - Project settings table and DTOs - Member and project response mappers - Pagination for member lists - Fix LF/CRLF line endings --- migrations/0000_stale_sunspot.sql | 2 +- migrations/0006_absent_doctor_doom.sql | 101 +- migrations/0009_true_avengers.sql | 13 +- migrations/0010_yummy_runaways.sql | 4 + migrations/0011_closed_rawhide_kid.sql | 93 + migrations/0012_military_killer_shrike.sql | 45 + migrations/0013_good_pixie.sql | 60 + migrations/meta/0011_snapshot.json | 1528 +++++++++++++ migrations/meta/0012_snapshot.json | 1756 ++++++++++++++ migrations/meta/0013_snapshot.json | 2007 +++++++++++++++++ migrations/meta/_journal.json | 21 + package.json | 1 + pnpm-lock.yaml | 9 + src/app.module.ts | 5 +- src/area/application/area.facade.ts | 91 + .../controllers/area/controller.ts | 67 + .../application/controllers/area/swagger.ts | 274 +++ src/area/application/controllers/index.ts | 4 + .../controllers/state}/controller.ts | 44 +- .../application/controllers/state}/swagger.ts | 108 +- src/area/application/dtos/area.dto.ts | 125 + src/area/application/dtos/index.ts | 2 + .../application/dtos/states.dto.ts | 16 +- .../use-cases/areas/create.use-case.ts | 28 + .../use-cases/areas/delete.use-case.ts | 24 + .../use-cases/areas/get-all.query.ts | 24 + .../use-cases/areas/get-one.query.ts | 25 + src/area/application/use-cases/areas/index.ts | 19 + .../use-cases/areas/update.use-case.ts | 25 + src/area/application/use-cases/index.ts | 2 + .../use-cases/states/create.use-case.ts} | 50 +- .../use-cases/states/delete.use-case.ts} | 38 +- .../use-cases/states/get-all.query.ts | 19 + .../use-cases/states/get-one.query.ts | 32 + .../application/use-cases/states/index.ts | 26 + .../use-cases/states/reorder.use-case.ts | 42 + .../use-cases/states/restore.use-state.ts | 41 + .../use-cases/states/update.use-case.ts} | 41 +- src/area/area.module.ts | 14 + src/area/domain/entities/area.domain.ts | 5 + src/area/domain/entities/index.ts | 2 + src/area/domain/entities/state.domain.ts | 5 + src/area/domain/errors/area.errors.ts | 74 + src/area/domain/errors/index.ts | 2 + .../domain/errors/state.errors.ts | 0 .../repository/area.repository.interface.ts | 10 + src/area/domain/repository/index.ts | 2 + .../repository/states.repository.interface.ts | 15 + src/area/index.ts | 1 + .../persistence/models/area.model.ts | 88 + .../persistence/models/enum.ts} | 12 - .../persistence/models/index.ts | 2 + .../repositories/area.repository.ts | 100 + .../persistence/repositories/index.ts | 13 + .../repositories/state.repository.ts | 115 + .../controller/oauth/controller.ts | 6 +- src/projects/application/controller/index.ts | 4 +- .../controller/members/controller.ts | 64 + .../application/controller/members/swagger.ts | 180 ++ .../controller/projects/controller.ts | 14 +- .../controller/projects/swagger.ts | 329 ++- src/projects/application/dtos/index.ts | 5 +- src/projects/application/dtos/member.dto.ts | 66 + src/projects/application/dtos/project.dto.ts | 226 ++ src/projects/application/dtos/projects.dto.ts | 164 -- src/projects/application/dtos/settings.dto.ts | 89 + src/projects/application/mappers/index.ts | 2 +- .../application/mappers/member.mapper.ts | 34 + .../{projects.mapper.ts => project.mapper.ts} | 31 +- .../{projects.facade.ts => project.facade.ts} | 73 +- .../use-cases/create-project.use-case.ts | 34 - .../use-cases/delete-project.use-case.ts | 35 - .../generate-share-token.use-case.ts | 82 - src/projects/application/use-cases/index.ts | 60 +- .../use-cases/member/add.use-case.ts | 90 + .../use-cases/member/delete.use-case.ts | 87 + .../use-cases/member/find-all.query.ts | 30 + .../use-cases/member/get-available.query.ts | 16 + .../application/use-cases/member/index.ts | 19 + .../use-cases/member/update.use-case.ts | 78 + .../use-cases/project/check-slug.use-case.ts | 19 + .../use-cases/project/create.use-case.ts | 80 + .../use-cases/project/delete.use-case.ts | 48 + .../find-by-team.query.ts} | 21 +- .../find-one.query.ts} | 22 +- .../project/generate-share-token.use-case.ts | 113 + .../get-detail.query.ts} | 10 +- .../application/use-cases/project/index.ts | 34 + .../use-cases/project/set-status.use-case.ts | 88 + .../use-cases/project/update.use-case.ts | 96 + .../use-cases/set-project-status.use-case.ts | 41 - .../use-cases/states/get-state.query.ts | 51 - .../use-cases/states/get-states.query.ts | 36 - .../states/reorder-states.use-case.ts | 61 - .../states/restore-state.use-state.ts | 60 - .../use-cases/update-project.use-case.ts | 48 - src/projects/domain/entities/enum.ts | 15 + src/projects/domain/entities/index.ts | 3 +- src/projects/domain/entities/member.domain.ts | 15 + .../domain/entities/project.domain.ts | 14 +- src/projects/domain/entities/state.domain.ts | 5 - src/projects/domain/errors/index.ts | 1 - src/projects/domain/errors/member.errors.ts | 54 + src/projects/domain/errors/project.errors.ts | 46 +- .../domain/policy/project-access.policy.ts | 60 +- src/projects/domain/repository/index.ts | 4 +- .../repository/member.repository.interface.ts | 16 + .../project-states.repository.interface.ts | 16 - .../project.repository.interface.ts | 17 + .../projects.repository.interface.ts | 12 - src/projects/index.ts | 1 + .../infrastructure/persistence/models/enum.ts | 6 + .../persistence/models/index.ts | 5 +- .../persistence/models/member.model.ts | 28 + .../{projects.model.ts => project.model.ts} | 84 +- .../persistence/repositories/index.ts | 12 +- .../repositories/member.repository.ts | 113 + .../repositories/project.repository.ts | 168 ++ .../repositories/projects.repository.ts | 105 - .../repositories/states.repository.ts | 122 - src/projects/projects.module.ts | 7 +- src/shared/constants/roles.constant.ts | 8 + .../skip-zod-validation.decorator.ts | 4 +- src/shared/entities/index.ts | 1 + .../zod-validation.interceptor.ts | 4 +- .../use-cases/find-by-ids.query.ts | 11 + src/user/application/use-cases/index.ts | 11 +- .../repository/user.repository.interface.ts | 1 + .../persistence/models/user.entity.ts | 1 + .../repositories/user.repository.ts | 8 +- 130 files changed, 9517 insertions(+), 1379 deletions(-) create mode 100644 migrations/0011_closed_rawhide_kid.sql create mode 100644 migrations/0012_military_killer_shrike.sql create mode 100644 migrations/0013_good_pixie.sql create mode 100644 migrations/meta/0011_snapshot.json create mode 100644 migrations/meta/0012_snapshot.json create mode 100644 migrations/meta/0013_snapshot.json create mode 100644 src/area/application/area.facade.ts create mode 100644 src/area/application/controllers/area/controller.ts create mode 100644 src/area/application/controllers/area/swagger.ts create mode 100644 src/area/application/controllers/index.ts rename src/{projects/application/controller/states => area/application/controllers/state}/controller.ts (69%) rename src/{projects/application/controller/states => area/application/controllers/state}/swagger.ts (56%) create mode 100644 src/area/application/dtos/area.dto.ts create mode 100644 src/area/application/dtos/index.ts rename src/{projects => area}/application/dtos/states.dto.ts (91%) create mode 100644 src/area/application/use-cases/areas/create.use-case.ts create mode 100644 src/area/application/use-cases/areas/delete.use-case.ts create mode 100644 src/area/application/use-cases/areas/get-all.query.ts create mode 100644 src/area/application/use-cases/areas/get-one.query.ts create mode 100644 src/area/application/use-cases/areas/index.ts create mode 100644 src/area/application/use-cases/areas/update.use-case.ts create mode 100644 src/area/application/use-cases/index.ts rename src/{projects/application/use-cases/states/create-state.use-case.ts => area/application/use-cases/states/create.use-case.ts} (53%) rename src/{projects/application/use-cases/states/delete-state.use-case.ts => area/application/use-cases/states/delete.use-case.ts} (63%) create mode 100644 src/area/application/use-cases/states/get-all.query.ts create mode 100644 src/area/application/use-cases/states/get-one.query.ts create mode 100644 src/area/application/use-cases/states/index.ts create mode 100644 src/area/application/use-cases/states/reorder.use-case.ts create mode 100644 src/area/application/use-cases/states/restore.use-state.ts rename src/{projects/application/use-cases/states/update-state.use-case.ts => area/application/use-cases/states/update.use-case.ts} (53%) create mode 100644 src/area/area.module.ts create mode 100644 src/area/domain/entities/area.domain.ts create mode 100644 src/area/domain/entities/index.ts create mode 100644 src/area/domain/entities/state.domain.ts create mode 100644 src/area/domain/errors/area.errors.ts create mode 100644 src/area/domain/errors/index.ts rename src/{projects => area}/domain/errors/state.errors.ts (100%) create mode 100644 src/area/domain/repository/area.repository.interface.ts create mode 100644 src/area/domain/repository/index.ts create mode 100644 src/area/domain/repository/states.repository.interface.ts create mode 100644 src/area/index.ts create mode 100644 src/area/infrastructure/persistence/models/area.model.ts rename src/{projects/infrastructure/persistence/models/enums.ts => area/infrastructure/persistence/models/enum.ts} (59%) create mode 100644 src/area/infrastructure/persistence/models/index.ts create mode 100644 src/area/infrastructure/persistence/repositories/area.repository.ts create mode 100644 src/area/infrastructure/persistence/repositories/index.ts create mode 100644 src/area/infrastructure/persistence/repositories/state.repository.ts create mode 100644 src/projects/application/controller/members/controller.ts create mode 100644 src/projects/application/controller/members/swagger.ts create mode 100644 src/projects/application/dtos/member.dto.ts create mode 100644 src/projects/application/dtos/project.dto.ts delete mode 100644 src/projects/application/dtos/projects.dto.ts create mode 100644 src/projects/application/dtos/settings.dto.ts create mode 100644 src/projects/application/mappers/member.mapper.ts rename src/projects/application/mappers/{projects.mapper.ts => project.mapper.ts} (56%) rename src/projects/application/{projects.facade.ts => project.facade.ts} (54%) delete mode 100644 src/projects/application/use-cases/create-project.use-case.ts delete mode 100644 src/projects/application/use-cases/delete-project.use-case.ts delete mode 100644 src/projects/application/use-cases/generate-share-token.use-case.ts create mode 100644 src/projects/application/use-cases/member/add.use-case.ts create mode 100644 src/projects/application/use-cases/member/delete.use-case.ts create mode 100644 src/projects/application/use-cases/member/find-all.query.ts create mode 100644 src/projects/application/use-cases/member/get-available.query.ts create mode 100644 src/projects/application/use-cases/member/index.ts create mode 100644 src/projects/application/use-cases/member/update.use-case.ts create mode 100644 src/projects/application/use-cases/project/check-slug.use-case.ts create mode 100644 src/projects/application/use-cases/project/create.use-case.ts create mode 100644 src/projects/application/use-cases/project/delete.use-case.ts rename src/projects/application/use-cases/{find-projects-by-team.query.ts => project/find-by-team.query.ts} (60%) rename src/projects/application/use-cases/{find-project.query.ts => project/find-one.query.ts} (87%) create mode 100644 src/projects/application/use-cases/project/generate-share-token.use-case.ts rename src/projects/application/use-cases/{get-project-detail.query.ts => project/get-detail.query.ts} (73%) create mode 100644 src/projects/application/use-cases/project/index.ts create mode 100644 src/projects/application/use-cases/project/set-status.use-case.ts create mode 100644 src/projects/application/use-cases/project/update.use-case.ts delete mode 100644 src/projects/application/use-cases/set-project-status.use-case.ts delete mode 100644 src/projects/application/use-cases/states/get-state.query.ts delete mode 100644 src/projects/application/use-cases/states/get-states.query.ts delete mode 100644 src/projects/application/use-cases/states/reorder-states.use-case.ts delete mode 100644 src/projects/application/use-cases/states/restore-state.use-state.ts delete mode 100644 src/projects/application/use-cases/update-project.use-case.ts create mode 100644 src/projects/domain/entities/enum.ts create mode 100644 src/projects/domain/entities/member.domain.ts delete mode 100644 src/projects/domain/entities/state.domain.ts create mode 100644 src/projects/domain/errors/member.errors.ts create mode 100644 src/projects/domain/repository/member.repository.interface.ts delete mode 100644 src/projects/domain/repository/project-states.repository.interface.ts create mode 100644 src/projects/domain/repository/project.repository.interface.ts delete mode 100644 src/projects/domain/repository/projects.repository.interface.ts create mode 100644 src/projects/infrastructure/persistence/models/enum.ts create mode 100644 src/projects/infrastructure/persistence/models/member.model.ts rename src/projects/infrastructure/persistence/models/{projects.model.ts => project.model.ts} (51%) create mode 100644 src/projects/infrastructure/persistence/repositories/member.repository.ts create mode 100644 src/projects/infrastructure/persistence/repositories/project.repository.ts delete mode 100644 src/projects/infrastructure/persistence/repositories/projects.repository.ts delete mode 100644 src/projects/infrastructure/persistence/repositories/states.repository.ts create mode 100644 src/user/application/use-cases/find-by-ids.query.ts diff --git a/migrations/0000_stale_sunspot.sql b/migrations/0000_stale_sunspot.sql index a615183..926a676 100644 --- a/migrations/0000_stale_sunspot.sql +++ b/migrations/0000_stale_sunspot.sql @@ -1 +1 @@ -CREATE SCHEMA "base"; +CREATE SCHEMA "base"; \ No newline at end of file diff --git a/migrations/0006_absent_doctor_doom.sql b/migrations/0006_absent_doctor_doom.sql index cd49075..ae89b8e 100644 --- a/migrations/0006_absent_doctor_doom.sql +++ b/migrations/0006_absent_doctor_doom.sql @@ -1,15 +1,86 @@ -ALTER TABLE "base"."team_members" ALTER COLUMN "joined_at" SET DATA TYPE timestamp with time zone; -ALTER TABLE "base"."team_members" ALTER COLUMN "created_at" SET DATA TYPE timestamp with time zone; -ALTER TABLE "base"."team_members" ALTER COLUMN "created_at" SET DEFAULT now(); -ALTER TABLE "base"."teams" ALTER COLUMN "created_at" SET DATA TYPE timestamp with time zone; -ALTER TABLE "base"."teams" ALTER COLUMN "created_at" SET DEFAULT now(); -ALTER TABLE "base"."teams" ALTER COLUMN "updated_at" SET DATA TYPE timestamp with time zone; -ALTER TABLE "base"."teams" ALTER COLUMN "updated_at" SET DEFAULT now(); -ALTER TABLE "base"."teams" ALTER COLUMN "deleted_at" SET DATA TYPE timestamp with time zone; -ALTER TABLE "base"."project_shares" ALTER COLUMN "created_at" SET DATA TYPE timestamp with time zone; -ALTER TABLE "base"."project_shares" ALTER COLUMN "created_at" SET DEFAULT now(); -ALTER TABLE "base"."projects" ALTER COLUMN "created_at" SET DATA TYPE timestamp with time zone; -ALTER TABLE "base"."projects" ALTER COLUMN "created_at" SET DEFAULT now(); -ALTER TABLE "base"."projects" ALTER COLUMN "updated_at" SET DATA TYPE timestamp with time zone; -ALTER TABLE "base"."projects" ALTER COLUMN "updated_at" SET DEFAULT now(); -ALTER TABLE "base"."projects" ALTER COLUMN "deleted_at" SET DATA TYPE timestamp with time zone; \ No newline at end of file +ALTER TABLE "base"."team_members" +ALTER COLUMN "joined_at" +SET + DATA TYPE timestamp +with + time zone; + +ALTER TABLE "base"."team_members" +ALTER COLUMN "created_at" +SET + DATA TYPE timestamp +with + time zone; + +ALTER TABLE "base"."team_members" +ALTER COLUMN "created_at" +SET DEFAULT now (); + +ALTER TABLE "base"."teams" +ALTER COLUMN "created_at" +SET + DATA TYPE timestamp +with + time zone; + +ALTER TABLE "base"."teams" +ALTER COLUMN "created_at" +SET DEFAULT now (); + +ALTER TABLE "base"."teams" +ALTER COLUMN "updated_at" +SET + DATA TYPE timestamp +with + time zone; + +ALTER TABLE "base"."teams" +ALTER COLUMN "updated_at" +SET DEFAULT now (); + +ALTER TABLE "base"."teams" +ALTER COLUMN "deleted_at" +SET + DATA TYPE timestamp +with + time zone; + +ALTER TABLE "base"."project_shares" +ALTER COLUMN "created_at" +SET + DATA TYPE timestamp +with + time zone; + +ALTER TABLE "base"."project_shares" +ALTER COLUMN "created_at" +SET DEFAULT now (); + +ALTER TABLE "base"."projects" +ALTER COLUMN "created_at" +SET + DATA TYPE timestamp +with + time zone; + +ALTER TABLE "base"."projects" +ALTER COLUMN "created_at" +SET DEFAULT now (); + +ALTER TABLE "base"."projects" +ALTER COLUMN "updated_at" +SET + DATA TYPE timestamp +with + time zone; + +ALTER TABLE "base"."projects" +ALTER COLUMN "updated_at" +SET DEFAULT now (); + +ALTER TABLE "base"."projects" +ALTER COLUMN "deleted_at" +SET + DATA TYPE timestamp +with + time zone; \ No newline at end of file diff --git a/migrations/0009_true_avengers.sql b/migrations/0009_true_avengers.sql index a9b2aa6..ca78652 100644 --- a/migrations/0009_true_avengers.sql +++ b/migrations/0009_true_avengers.sql @@ -1,8 +1,17 @@ ALTER TABLE "base"."tags" DISABLE ROW LEVEL SECURITY; + ALTER TABLE "base"."teams_to_tags" DISABLE ROW LEVEL SECURITY; + DROP TABLE "base"."tags" CASCADE; + DROP TABLE "base"."teams_to_tags" CASCADE; -ALTER TABLE "base"."teams" DROP CONSTRAINT "teams_slug_unique"; + +ALTER TABLE "base"."teams" +DROP CONSTRAINT "teams_slug_unique"; + DROP INDEX "base"."team_active_slug_idx"; + DROP INDEX "base"."team_slug_idx"; -ALTER TABLE "base"."teams" DROP COLUMN "slug"; \ No newline at end of file + +ALTER TABLE "base"."teams" +DROP COLUMN "slug"; \ No newline at end of file diff --git a/migrations/0010_yummy_runaways.sql b/migrations/0010_yummy_runaways.sql index af64384..e5eab56 100644 --- a/migrations/0010_yummy_runaways.sql +++ b/migrations/0010_yummy_runaways.sql @@ -1,5 +1,9 @@ DROP TABLE "base"."board_columns" CASCADE; + DROP TABLE "base"."boards_views" CASCADE; + DROP TABLE "base"."boards" CASCADE; + DROP TYPE "base"."board_type"; + DROP TYPE "base"."column_status"; \ No newline at end of file diff --git a/migrations/0011_closed_rawhide_kid.sql b/migrations/0011_closed_rawhide_kid.sql new file mode 100644 index 0000000..6335098 --- /dev/null +++ b/migrations/0011_closed_rawhide_kid.sql @@ -0,0 +1,93 @@ +CREATE TYPE "base"."layout_type" AS ENUM ('kanban', 'list', 'calendar', 'gantt'); + +ALTER TYPE "base"."project_status" ADD VALUE 'deleted'; + +CREATE TABLE + "base"."project_members" ( + "id" text PRIMARY KEY NOT NULL, + "project_id" text NOT NULL, + "user_id" text NOT NULL, + "role" varchar(20) DEFAULT 'member' NOT NULL, + "added_by" text, + "created_at" timestamp + with + time zone DEFAULT now () NOT NULL + ); + +CREATE TABLE + "base"."project_settings" ( + "id" text PRIMARY KEY NOT NULL, + "project_id" text NOT NULL, + "default_view" "base"."layout_type" DEFAULT 'kanban' NOT NULL, + "task_prefix" varchar(10), + "auto_close_days" integer, + "max_tasks_per_area" integer, + "max_members" integer, + "max_areas" integer, + "allow_guests" boolean DEFAULT false, + "time_tracking" boolean DEFAULT false, + "time_tracking_mode" varchar(20) DEFAULT 'optional', + "default_assignee_id" text, + "created_at" timestamp + with + time zone DEFAULT now () NOT NULL, + "updated_at" timestamp + with + time zone DEFAULT now () NOT NULL, + CONSTRAINT "project_settings_project_id_unique" UNIQUE ("project_id") + ); + +DROP INDEX "base"."project_team_key_idx"; + +DROP INDEX "base"."project_team_name_idx"; + +ALTER TABLE "base"."project_shares" +ALTER COLUMN "created_by" +DROP NOT NULL; + +ALTER TABLE "base"."users" +ADD COLUMN "last_team_id" text; + +ALTER TABLE "base"."projects" +ADD COLUMN "slug" varchar(100) NOT NULL; + +ALTER TABLE "base"."projects" +ADD COLUMN "descriptionHtml" text; + +ALTER TABLE "base"."projects" +ADD COLUMN "sequence" integer DEFAULT 0; + +ALTER TABLE "base"."project_members" ADD CONSTRAINT "project_members_project_id_projects_id_fk" FOREIGN KEY ("project_id") REFERENCES "base"."projects" ("id") ON DELETE cascade ON UPDATE no action; + +ALTER TABLE "base"."project_members" ADD CONSTRAINT "project_members_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "base"."users" ("id") ON DELETE cascade ON UPDATE no action; + +ALTER TABLE "base"."project_members" ADD CONSTRAINT "project_members_added_by_users_id_fk" FOREIGN KEY ("added_by") REFERENCES "base"."users" ("id") ON DELETE no action ON UPDATE no action; + +ALTER TABLE "base"."project_settings" ADD CONSTRAINT "project_settings_project_id_projects_id_fk" FOREIGN KEY ("project_id") REFERENCES "base"."projects" ("id") ON DELETE cascade ON UPDATE no action; + +ALTER TABLE "base"."project_settings" ADD CONSTRAINT "project_settings_default_assignee_id_users_id_fk" FOREIGN KEY ("default_assignee_id") REFERENCES "base"."users" ("id") ON DELETE set null ON UPDATE no action; + +CREATE UNIQUE INDEX "project_member_unique_idx" ON "base"."project_members" USING btree ("project_id", "user_id"); + +CREATE INDEX "project_member_user_idx" ON "base"."project_members" USING btree ("user_id"); + +CREATE INDEX "project_member_project_idx" ON "base"."project_members" USING btree ("project_id"); + +CREATE UNIQUE INDEX "project_settings_project_idx" ON "base"."project_settings" USING btree ("project_id"); + +ALTER TABLE "base"."project_shares" ADD CONSTRAINT "project_shares_created_by_users_id_fk" FOREIGN KEY ("created_by") REFERENCES "base"."users" ("id") ON DELETE no action ON UPDATE no action; + +CREATE UNIQUE INDEX "project_team_slug_idx" ON "base"."projects" USING btree ("team_id", "slug") +WHERE + "base"."projects"."deleted_at" is null; + +ALTER TABLE "base"."projects" +DROP COLUMN "key"; + +ALTER TABLE "base"."projects" +DROP COLUMN "task_sequence"; + +ALTER TABLE "base"."projects" +DROP COLUMN "settings"; + +ALTER TABLE "base"."projects" ADD CONSTRAINT "projects_slug_unique" UNIQUE ("slug"); \ No newline at end of file diff --git a/migrations/0012_military_killer_shrike.sql b/migrations/0012_military_killer_shrike.sql new file mode 100644 index 0000000..0a8c043 --- /dev/null +++ b/migrations/0012_military_killer_shrike.sql @@ -0,0 +1,45 @@ +CREATE TABLE + "base"."areas" ( + "id" text PRIMARY KEY NOT NULL, + "project_id" text, + "title" text NOT NULL, + "slug" varchar(100) NOT NULL, + "description" text, + "description_html" text, + "color" varchar(10), + "tasks_count" integer DEFAULT 0 NOT NULL, + "default_view" varchar(20) DEFAULT 'kanban' NOT NULL, + "icon" varchar(20), + "position" integer DEFAULT 0 NOT NULL, + "max_tasks_limit" integer, + "is_locked" boolean DEFAULT false, + "created_at" timestamp + with + time zone DEFAULT now () NOT NULL, + "updated_at" timestamp + with + time zone DEFAULT now () NOT NULL, + "created_by" text, + "deleted_at" timestamp + with + time zone, + CONSTRAINT "areas_slug_unique" UNIQUE ("slug") + ); + +ALTER TABLE "base"."areas" ADD CONSTRAINT "areas_project_id_projects_id_fk" FOREIGN KEY ("project_id") REFERENCES "base"."projects" ("id") ON DELETE cascade ON UPDATE no action; + +ALTER TABLE "base"."areas" ADD CONSTRAINT "areas_created_by_users_id_fk" FOREIGN KEY ("created_by") REFERENCES "base"."users" ("id") ON DELETE no action ON UPDATE no action; + +CREATE INDEX "idx_areas_slug" ON "base"."areas" USING btree ("slug"); + +CREATE INDEX "idx_areas_project_active" ON "base"."areas" USING btree ("project_id", "position") +WHERE + "base"."areas"."deleted_at" is null; + +CREATE INDEX "idx_areas_created_by" ON "base"."areas" USING btree ("created_by") +WHERE + "base"."areas"."deleted_at" is null; + +CREATE INDEX "idx_areas_deleted_at" ON "base"."areas" USING btree ("deleted_at") +WHERE + "base"."areas"."deleted_at" is not null; \ No newline at end of file diff --git a/migrations/0013_good_pixie.sql b/migrations/0013_good_pixie.sql new file mode 100644 index 0000000..f92acca --- /dev/null +++ b/migrations/0013_good_pixie.sql @@ -0,0 +1,60 @@ +CREATE TYPE "base"."state_category" AS ENUM ( + 'backlog', + 'active', + 'review', + 'completed', + 'archived' +); + +CREATE TYPE "base"."state_type" AS ENUM ( + 'backlog', + 'todo', + 'in_progress', + 'review', + 'done', + 'archived', + 'custom' +); + +CREATE TABLE + "base"."states" ( + "id" text PRIMARY KEY NOT NULL, + "area_id" text, + "title" text NOT NULL, + "description" text, + "state_type" "base"."state_type" DEFAULT 'custom' NOT NULL, + "category" "base"."state_category" DEFAULT 'active' NOT NULL, + "color" varchar(10), + "icon" varchar(20), + "position" integer DEFAULT 0 NOT NULL, + "is_visible" boolean DEFAULT true NOT NULL, + "max_tasks_limit" integer, + "auto_transition_to" text, + "notify_on_enter" boolean DEFAULT false, + "notify_on_exit" boolean DEFAULT false, + "is_locked" boolean DEFAULT false, + "created_at" timestamp + with + time zone DEFAULT now () NOT NULL, + "updated_at" timestamp + with + time zone DEFAULT now () NOT NULL, + "created_by" text, + "deleted_at" timestamp + with + time zone + ); + +ALTER TABLE "base"."states" ADD CONSTRAINT "states_area_id_areas_id_fk" FOREIGN KEY ("area_id") REFERENCES "base"."areas" ("id") ON DELETE cascade ON UPDATE no action; + +ALTER TABLE "base"."states" ADD CONSTRAINT "states_created_by_users_id_fk" FOREIGN KEY ("created_by") REFERENCES "base"."users" ("id") ON DELETE no action ON UPDATE no action; + +CREATE INDEX "idx_states_position" ON "base"."states" USING btree ("area_id", "position"); + +CREATE UNIQUE INDEX "idx_states_unique_title" ON "base"."states" USING btree ("area_id", "title") +WHERE + "base"."states"."deleted_at" is null; + +CREATE INDEX "idx_states_deleted_at" ON "base"."states" USING btree ("deleted_at") +WHERE + "base"."states"."deleted_at" is not null; \ No newline at end of file diff --git a/migrations/meta/0011_snapshot.json b/migrations/meta/0011_snapshot.json new file mode 100644 index 0000000..66c464c --- /dev/null +++ b/migrations/meta/0011_snapshot.json @@ -0,0 +1,1528 @@ +{ + "id": "cb4bcb9a-13e5-40ae-8289-fec4bd18a440", + "prevId": "538a6952-f990-41f5-8300-a030d99d738d", + "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_preferences": { + "name": "user_preferences", + "schema": "base", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "theme": { + "name": "theme", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'system'" + }, + "timezone": { + "name": "timezone", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true, + "default": "'UTC'" + }, + "language": { + "name": "language", + "type": "varchar(5)", + "primaryKey": false, + "notNull": true, + "default": "'ru'" + } + }, + "indexes": {}, + "foreignKeys": { + "user_preferences_user_id_users_id_fk": { + "name": "user_preferences_user_id_users_id_fk", + "tableFrom": "user_preferences", + "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": false + }, + "recovery_email": { + "name": "recovery_email", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "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_login_at": { + "name": "last_login_at", + "type": "timestamp with time zone", + "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 + }, + "username": { + "name": "username", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + }, + "headline": { + "name": "headline", + "type": "varchar(200)", + "primaryKey": false, + "notNull": false + }, + "location": { + "name": "location", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "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 + }, + "phone": { + "name": "phone", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "vacation_start": { + "name": "vacation_start", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "vacation_end": { + "name": "vacation_end", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "vacation_message": { + "name": "vacation_message", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "gender": { + "name": "gender", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'none'" + }, + "pronouns": { + "name": "pronouns", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'none'" + }, + "pronouns_custom": { + "name": "pronouns_custom", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + }, + "avatar_url": { + "name": "avatar_url", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "email_verified_at": { + "name": "email_verified_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_team_id": { + "name": "last_team_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "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()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users_username_unique": { + "name": "users_username_unique", + "nullsNotDistinct": false, + "columns": [ + "username" + ] + }, + "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.user_identities": { + "name": "user_identities", + "schema": "base", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "provider_user_id": { + "name": "provider_user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "avatar_url": { + "name": "avatar_url", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "user_identities_user_id_users_id_fk": { + "name": "user_identities_user_id_users_id_fk", + "tableFrom": "user_identities", + "tableTo": "users", + "schemaTo": "base", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "provider_user_id_idx": { + "name": "provider_user_id_idx", + "nullsNotDistinct": false, + "columns": [ + "provider", + "provider_user_id" + ] + } + }, + "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 with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "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 + }, + "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 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()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "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": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "base.project_members": { + "name": "project_members", + "schema": "base", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'member'" + }, + "added_by": { + "name": "added_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "project_member_unique_idx": { + "name": "project_member_unique_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "project_member_user_idx": { + "name": "project_member_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "project_member_project_idx": { + "name": "project_member_project_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "project_members_project_id_projects_id_fk": { + "name": "project_members_project_id_projects_id_fk", + "tableFrom": "project_members", + "tableTo": "projects", + "schemaTo": "base", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "project_members_user_id_users_id_fk": { + "name": "project_members_user_id_users_id_fk", + "tableFrom": "project_members", + "tableTo": "users", + "schemaTo": "base", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "project_members_added_by_users_id_fk": { + "name": "project_members_added_by_users_id_fk", + "tableFrom": "project_members", + "tableTo": "users", + "schemaTo": "base", + "columnsFrom": [ + "added_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "base.project_settings": { + "name": "project_settings", + "schema": "base", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "default_view": { + "name": "default_view", + "type": "layout_type", + "typeSchema": "base", + "primaryKey": false, + "notNull": true, + "default": "'kanban'" + }, + "task_prefix": { + "name": "task_prefix", + "type": "varchar(10)", + "primaryKey": false, + "notNull": false + }, + "auto_close_days": { + "name": "auto_close_days", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "max_tasks_per_area": { + "name": "max_tasks_per_area", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "max_members": { + "name": "max_members", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "max_areas": { + "name": "max_areas", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "allow_guests": { + "name": "allow_guests", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "time_tracking": { + "name": "time_tracking", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "time_tracking_mode": { + "name": "time_tracking_mode", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false, + "default": "'optional'" + }, + "default_assignee_id": { + "name": "default_assignee_id", + "type": "text", + "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()" + } + }, + "indexes": { + "project_settings_project_idx": { + "name": "project_settings_project_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "project_settings_project_id_projects_id_fk": { + "name": "project_settings_project_id_projects_id_fk", + "tableFrom": "project_settings", + "tableTo": "projects", + "schemaTo": "base", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "project_settings_default_assignee_id_users_id_fk": { + "name": "project_settings_default_assignee_id_users_id_fk", + "tableFrom": "project_settings", + "tableTo": "users", + "schemaTo": "base", + "columnsFrom": [ + "default_assignee_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "project_settings_project_id_unique": { + "name": "project_settings_project_id_unique", + "nullsNotDistinct": false, + "columns": [ + "project_id" + ] + } + }, + "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": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "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" + }, + "project_shares_created_by_users_id_fk": { + "name": "project_shares_created_by_users_id_fk", + "tableFrom": "project_shares", + "tableTo": "users", + "schemaTo": "base", + "columnsFrom": [ + "created_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "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 + }, + "slug": { + "name": "slug", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "descriptionHtml": { + "name": "descriptionHtml", + "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'" + }, + "sequence": { + "name": "sequence", + "type": "integer", + "primaryKey": false, + "notNull": false, + "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'" + }, + "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()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "project_team_slug_idx": { + "name": "project_team_slug_idx", + "columns": [ + { + "expression": "team_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "slug", + "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": { + "projects_slug_unique": { + "name": "projects_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "slug" + ] + } + }, + "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.layout_type": { + "name": "layout_type", + "schema": "base", + "values": [ + "kanban", + "list", + "calendar", + "gantt" + ] + }, + "base.project_status": { + "name": "project_status", + "schema": "base", + "values": [ + "active", + "archived", + "template", + "deleted" + ] + }, + "base.project_visibility": { + "name": "project_visibility", + "schema": "base", + "values": [ + "public", + "private" + ] + } + }, + "schemas": { + "base": "base" + }, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/migrations/meta/0012_snapshot.json b/migrations/meta/0012_snapshot.json new file mode 100644 index 0000000..d6e4ef3 --- /dev/null +++ b/migrations/meta/0012_snapshot.json @@ -0,0 +1,1756 @@ +{ + "id": "b9544b3c-75a2-47dd-ad39-d76008c7f340", + "prevId": "cb4bcb9a-13e5-40ae-8289-fec4bd18a440", + "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_preferences": { + "name": "user_preferences", + "schema": "base", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "theme": { + "name": "theme", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'system'" + }, + "timezone": { + "name": "timezone", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true, + "default": "'UTC'" + }, + "language": { + "name": "language", + "type": "varchar(5)", + "primaryKey": false, + "notNull": true, + "default": "'ru'" + } + }, + "indexes": {}, + "foreignKeys": { + "user_preferences_user_id_users_id_fk": { + "name": "user_preferences_user_id_users_id_fk", + "tableFrom": "user_preferences", + "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": false + }, + "recovery_email": { + "name": "recovery_email", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "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_login_at": { + "name": "last_login_at", + "type": "timestamp with time zone", + "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 + }, + "username": { + "name": "username", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + }, + "headline": { + "name": "headline", + "type": "varchar(200)", + "primaryKey": false, + "notNull": false + }, + "location": { + "name": "location", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "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 + }, + "phone": { + "name": "phone", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "vacation_start": { + "name": "vacation_start", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "vacation_end": { + "name": "vacation_end", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "vacation_message": { + "name": "vacation_message", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "gender": { + "name": "gender", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'none'" + }, + "pronouns": { + "name": "pronouns", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'none'" + }, + "pronouns_custom": { + "name": "pronouns_custom", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + }, + "avatar_url": { + "name": "avatar_url", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "email_verified_at": { + "name": "email_verified_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_team_id": { + "name": "last_team_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "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()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users_username_unique": { + "name": "users_username_unique", + "nullsNotDistinct": false, + "columns": [ + "username" + ] + }, + "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.user_identities": { + "name": "user_identities", + "schema": "base", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "provider_user_id": { + "name": "provider_user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "avatar_url": { + "name": "avatar_url", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "user_identities_user_id_users_id_fk": { + "name": "user_identities_user_id_users_id_fk", + "tableFrom": "user_identities", + "tableTo": "users", + "schemaTo": "base", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "provider_user_id_idx": { + "name": "provider_user_id_idx", + "nullsNotDistinct": false, + "columns": [ + "provider", + "provider_user_id" + ] + } + }, + "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 with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "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 + }, + "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 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()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "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": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "base.project_members": { + "name": "project_members", + "schema": "base", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'member'" + }, + "added_by": { + "name": "added_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "project_member_unique_idx": { + "name": "project_member_unique_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "project_member_user_idx": { + "name": "project_member_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "project_member_project_idx": { + "name": "project_member_project_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "project_members_project_id_projects_id_fk": { + "name": "project_members_project_id_projects_id_fk", + "tableFrom": "project_members", + "tableTo": "projects", + "schemaTo": "base", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "project_members_user_id_users_id_fk": { + "name": "project_members_user_id_users_id_fk", + "tableFrom": "project_members", + "tableTo": "users", + "schemaTo": "base", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "project_members_added_by_users_id_fk": { + "name": "project_members_added_by_users_id_fk", + "tableFrom": "project_members", + "tableTo": "users", + "schemaTo": "base", + "columnsFrom": [ + "added_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "base.project_settings": { + "name": "project_settings", + "schema": "base", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "default_view": { + "name": "default_view", + "type": "layout_type", + "typeSchema": "base", + "primaryKey": false, + "notNull": true, + "default": "'kanban'" + }, + "task_prefix": { + "name": "task_prefix", + "type": "varchar(10)", + "primaryKey": false, + "notNull": false + }, + "auto_close_days": { + "name": "auto_close_days", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "max_tasks_per_area": { + "name": "max_tasks_per_area", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "max_members": { + "name": "max_members", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "max_areas": { + "name": "max_areas", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "allow_guests": { + "name": "allow_guests", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "time_tracking": { + "name": "time_tracking", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "time_tracking_mode": { + "name": "time_tracking_mode", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false, + "default": "'optional'" + }, + "default_assignee_id": { + "name": "default_assignee_id", + "type": "text", + "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()" + } + }, + "indexes": { + "project_settings_project_idx": { + "name": "project_settings_project_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "project_settings_project_id_projects_id_fk": { + "name": "project_settings_project_id_projects_id_fk", + "tableFrom": "project_settings", + "tableTo": "projects", + "schemaTo": "base", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "project_settings_default_assignee_id_users_id_fk": { + "name": "project_settings_default_assignee_id_users_id_fk", + "tableFrom": "project_settings", + "tableTo": "users", + "schemaTo": "base", + "columnsFrom": [ + "default_assignee_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "project_settings_project_id_unique": { + "name": "project_settings_project_id_unique", + "nullsNotDistinct": false, + "columns": [ + "project_id" + ] + } + }, + "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": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "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" + }, + "project_shares_created_by_users_id_fk": { + "name": "project_shares_created_by_users_id_fk", + "tableFrom": "project_shares", + "tableTo": "users", + "schemaTo": "base", + "columnsFrom": [ + "created_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "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 + }, + "slug": { + "name": "slug", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "descriptionHtml": { + "name": "descriptionHtml", + "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'" + }, + "sequence": { + "name": "sequence", + "type": "integer", + "primaryKey": false, + "notNull": false, + "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'" + }, + "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()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "project_team_slug_idx": { + "name": "project_team_slug_idx", + "columns": [ + { + "expression": "team_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "slug", + "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": { + "projects_slug_unique": { + "name": "projects_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "slug" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "base.areas": { + "name": "areas", + "schema": "base", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description_html": { + "name": "description_html", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "color": { + "name": "color", + "type": "varchar(10)", + "primaryKey": false, + "notNull": false + }, + "tasks_count": { + "name": "tasks_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "default_view": { + "name": "default_view", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'kanban'" + }, + "icon": { + "name": "icon", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "max_tasks_limit": { + "name": "max_tasks_limit", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "is_locked": { + "name": "is_locked", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": 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()" + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_areas_slug": { + "name": "idx_areas_slug", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_areas_project_active": { + "name": "idx_areas_project_active", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "position", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"base\".\"areas\".\"deleted_at\" is null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_areas_created_by": { + "name": "idx_areas_created_by", + "columns": [ + { + "expression": "created_by", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"base\".\"areas\".\"deleted_at\" is null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_areas_deleted_at": { + "name": "idx_areas_deleted_at", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"base\".\"areas\".\"deleted_at\" is not null", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "areas_project_id_projects_id_fk": { + "name": "areas_project_id_projects_id_fk", + "tableFrom": "areas", + "tableTo": "projects", + "schemaTo": "base", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "areas_created_by_users_id_fk": { + "name": "areas_created_by_users_id_fk", + "tableFrom": "areas", + "tableTo": "users", + "schemaTo": "base", + "columnsFrom": [ + "created_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "areas_slug_unique": { + "name": "areas_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "slug" + ] + } + }, + "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.layout_type": { + "name": "layout_type", + "schema": "base", + "values": [ + "kanban", + "list", + "calendar", + "gantt" + ] + }, + "base.project_status": { + "name": "project_status", + "schema": "base", + "values": [ + "active", + "archived", + "template", + "deleted" + ] + }, + "base.project_visibility": { + "name": "project_visibility", + "schema": "base", + "values": [ + "public", + "private" + ] + } + }, + "schemas": { + "base": "base" + }, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/migrations/meta/0013_snapshot.json b/migrations/meta/0013_snapshot.json new file mode 100644 index 0000000..ecf383d --- /dev/null +++ b/migrations/meta/0013_snapshot.json @@ -0,0 +1,2007 @@ +{ + "id": "1721bc7b-29b1-4693-8d17-655402229992", + "prevId": "b9544b3c-75a2-47dd-ad39-d76008c7f340", + "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_preferences": { + "name": "user_preferences", + "schema": "base", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "theme": { + "name": "theme", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'system'" + }, + "timezone": { + "name": "timezone", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true, + "default": "'UTC'" + }, + "language": { + "name": "language", + "type": "varchar(5)", + "primaryKey": false, + "notNull": true, + "default": "'ru'" + } + }, + "indexes": {}, + "foreignKeys": { + "user_preferences_user_id_users_id_fk": { + "name": "user_preferences_user_id_users_id_fk", + "tableFrom": "user_preferences", + "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": false + }, + "recovery_email": { + "name": "recovery_email", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "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_login_at": { + "name": "last_login_at", + "type": "timestamp with time zone", + "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 + }, + "username": { + "name": "username", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + }, + "headline": { + "name": "headline", + "type": "varchar(200)", + "primaryKey": false, + "notNull": false + }, + "location": { + "name": "location", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "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 + }, + "phone": { + "name": "phone", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "vacation_start": { + "name": "vacation_start", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "vacation_end": { + "name": "vacation_end", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "vacation_message": { + "name": "vacation_message", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "gender": { + "name": "gender", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'none'" + }, + "pronouns": { + "name": "pronouns", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'none'" + }, + "pronouns_custom": { + "name": "pronouns_custom", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + }, + "avatar_url": { + "name": "avatar_url", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "email_verified_at": { + "name": "email_verified_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_team_id": { + "name": "last_team_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "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()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users_username_unique": { + "name": "users_username_unique", + "nullsNotDistinct": false, + "columns": [ + "username" + ] + }, + "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.user_identities": { + "name": "user_identities", + "schema": "base", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "provider_user_id": { + "name": "provider_user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "avatar_url": { + "name": "avatar_url", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "user_identities_user_id_users_id_fk": { + "name": "user_identities_user_id_users_id_fk", + "tableFrom": "user_identities", + "tableTo": "users", + "schemaTo": "base", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "provider_user_id_idx": { + "name": "provider_user_id_idx", + "nullsNotDistinct": false, + "columns": [ + "provider", + "provider_user_id" + ] + } + }, + "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 with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "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 + }, + "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 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()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "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": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "base.project_members": { + "name": "project_members", + "schema": "base", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'member'" + }, + "added_by": { + "name": "added_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "project_member_unique_idx": { + "name": "project_member_unique_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "project_member_user_idx": { + "name": "project_member_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "project_member_project_idx": { + "name": "project_member_project_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "project_members_project_id_projects_id_fk": { + "name": "project_members_project_id_projects_id_fk", + "tableFrom": "project_members", + "tableTo": "projects", + "schemaTo": "base", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "project_members_user_id_users_id_fk": { + "name": "project_members_user_id_users_id_fk", + "tableFrom": "project_members", + "tableTo": "users", + "schemaTo": "base", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "project_members_added_by_users_id_fk": { + "name": "project_members_added_by_users_id_fk", + "tableFrom": "project_members", + "tableTo": "users", + "schemaTo": "base", + "columnsFrom": [ + "added_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "base.project_settings": { + "name": "project_settings", + "schema": "base", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "default_view": { + "name": "default_view", + "type": "layout_type", + "typeSchema": "base", + "primaryKey": false, + "notNull": true, + "default": "'kanban'" + }, + "task_prefix": { + "name": "task_prefix", + "type": "varchar(10)", + "primaryKey": false, + "notNull": false + }, + "auto_close_days": { + "name": "auto_close_days", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "max_tasks_per_area": { + "name": "max_tasks_per_area", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "max_members": { + "name": "max_members", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "max_areas": { + "name": "max_areas", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "allow_guests": { + "name": "allow_guests", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "time_tracking": { + "name": "time_tracking", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "time_tracking_mode": { + "name": "time_tracking_mode", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false, + "default": "'optional'" + }, + "default_assignee_id": { + "name": "default_assignee_id", + "type": "text", + "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()" + } + }, + "indexes": { + "project_settings_project_idx": { + "name": "project_settings_project_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "project_settings_project_id_projects_id_fk": { + "name": "project_settings_project_id_projects_id_fk", + "tableFrom": "project_settings", + "tableTo": "projects", + "schemaTo": "base", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "project_settings_default_assignee_id_users_id_fk": { + "name": "project_settings_default_assignee_id_users_id_fk", + "tableFrom": "project_settings", + "tableTo": "users", + "schemaTo": "base", + "columnsFrom": [ + "default_assignee_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "project_settings_project_id_unique": { + "name": "project_settings_project_id_unique", + "nullsNotDistinct": false, + "columns": [ + "project_id" + ] + } + }, + "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": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "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" + }, + "project_shares_created_by_users_id_fk": { + "name": "project_shares_created_by_users_id_fk", + "tableFrom": "project_shares", + "tableTo": "users", + "schemaTo": "base", + "columnsFrom": [ + "created_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "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 + }, + "slug": { + "name": "slug", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "descriptionHtml": { + "name": "descriptionHtml", + "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'" + }, + "sequence": { + "name": "sequence", + "type": "integer", + "primaryKey": false, + "notNull": false, + "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'" + }, + "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()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "project_team_slug_idx": { + "name": "project_team_slug_idx", + "columns": [ + { + "expression": "team_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "slug", + "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": { + "projects_slug_unique": { + "name": "projects_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "slug" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "base.areas": { + "name": "areas", + "schema": "base", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description_html": { + "name": "description_html", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "color": { + "name": "color", + "type": "varchar(10)", + "primaryKey": false, + "notNull": false + }, + "tasks_count": { + "name": "tasks_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "default_view": { + "name": "default_view", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'kanban'" + }, + "icon": { + "name": "icon", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "max_tasks_limit": { + "name": "max_tasks_limit", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "is_locked": { + "name": "is_locked", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": 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()" + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_areas_slug": { + "name": "idx_areas_slug", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_areas_project_active": { + "name": "idx_areas_project_active", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "position", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"base\".\"areas\".\"deleted_at\" is null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_areas_created_by": { + "name": "idx_areas_created_by", + "columns": [ + { + "expression": "created_by", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"base\".\"areas\".\"deleted_at\" is null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_areas_deleted_at": { + "name": "idx_areas_deleted_at", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"base\".\"areas\".\"deleted_at\" is not null", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "areas_project_id_projects_id_fk": { + "name": "areas_project_id_projects_id_fk", + "tableFrom": "areas", + "tableTo": "projects", + "schemaTo": "base", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "areas_created_by_users_id_fk": { + "name": "areas_created_by_users_id_fk", + "tableFrom": "areas", + "tableTo": "users", + "schemaTo": "base", + "columnsFrom": [ + "created_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "areas_slug_unique": { + "name": "areas_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "slug" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "base.states": { + "name": "states", + "schema": "base", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "area_id": { + "name": "area_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "state_type": { + "name": "state_type", + "type": "state_type", + "typeSchema": "base", + "primaryKey": false, + "notNull": true, + "default": "'custom'" + }, + "category": { + "name": "category", + "type": "state_category", + "typeSchema": "base", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "color": { + "name": "color", + "type": "varchar(10)", + "primaryKey": false, + "notNull": false + }, + "icon": { + "name": "icon", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "is_visible": { + "name": "is_visible", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "max_tasks_limit": { + "name": "max_tasks_limit", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "auto_transition_to": { + "name": "auto_transition_to", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "notify_on_enter": { + "name": "notify_on_enter", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "notify_on_exit": { + "name": "notify_on_exit", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "is_locked": { + "name": "is_locked", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": 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()" + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_states_position": { + "name": "idx_states_position", + "columns": [ + { + "expression": "area_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "position", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_states_unique_title": { + "name": "idx_states_unique_title", + "columns": [ + { + "expression": "area_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "title", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"base\".\"states\".\"deleted_at\" is null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_states_deleted_at": { + "name": "idx_states_deleted_at", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"base\".\"states\".\"deleted_at\" is not null", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "states_area_id_areas_id_fk": { + "name": "states_area_id_areas_id_fk", + "tableFrom": "states", + "tableTo": "areas", + "schemaTo": "base", + "columnsFrom": [ + "area_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "states_created_by_users_id_fk": { + "name": "states_created_by_users_id_fk", + "tableFrom": "states", + "tableTo": "users", + "schemaTo": "base", + "columnsFrom": [ + "created_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "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.layout_type": { + "name": "layout_type", + "schema": "base", + "values": [ + "kanban", + "list", + "calendar", + "gantt" + ] + }, + "base.project_status": { + "name": "project_status", + "schema": "base", + "values": [ + "active", + "archived", + "template", + "deleted" + ] + }, + "base.project_visibility": { + "name": "project_visibility", + "schema": "base", + "values": [ + "public", + "private" + ] + }, + "base.state_category": { + "name": "state_category", + "schema": "base", + "values": [ + "backlog", + "active", + "review", + "completed", + "archived" + ] + }, + "base.state_type": { + "name": "state_type", + "schema": "base", + "values": [ + "backlog", + "todo", + "in_progress", + "review", + "done", + "archived", + "custom" + ] + } + }, + "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 9b86138..d4e318e 100644 --- a/migrations/meta/_journal.json +++ b/migrations/meta/_journal.json @@ -78,6 +78,27 @@ "when": 1780857935273, "tag": "0010_yummy_runaways", "breakpoints": false + }, + { + "idx": 11, + "version": "7", + "when": 1781308288700, + "tag": "0011_closed_rawhide_kid", + "breakpoints": false + }, + { + "idx": 12, + "version": "7", + "when": 1781308305488, + "tag": "0012_military_killer_shrike", + "breakpoints": false + }, + { + "idx": 13, + "version": "7", + "when": 1781308313721, + "tag": "0013_good_pixie", + "breakpoints": false } ] } \ No newline at end of file diff --git a/package.json b/package.json index d0d6d99..959c251 100644 --- a/package.json +++ b/package.json @@ -79,6 +79,7 @@ "prom-client": "^15.1.3", "reflect-metadata": "^0.2.0", "rxjs": "^7.8.1", + "slugify": "^1.6.9", "ua-parser-js": "^2.0.9", "winston": "^3.19.0", "zod": "^4.3.6" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 70e4ac5..cb8f343 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -140,6 +140,9 @@ importers: rxjs: specifier: ^7.8.1 version: 7.8.2 + slugify: + specifier: ^1.6.9 + version: 1.6.9 ua-parser-js: specifier: ^2.0.9 version: 2.0.9 @@ -4091,6 +4094,10 @@ packages: resolution: {integrity: sha512-stxByr12oeeOyY2BlviTNQlYV5xOj47GirPr4yA1hE9JCtxfQN0+tVbkxwCtYDQWhEKWFHsEK48ORg5jrouCAg==} engines: {node: '>=20'} + slugify@1.6.9: + resolution: {integrity: sha512-vZ7rfeehZui7wQs438JXBckYLkIIdfHOXsaVEUMyS5fHo1483l1bMdo0EDSWYclY0yZKFOipDy4KHuKs6ssvdg==} + engines: {node: '>=8.0.0'} + sonic-boom@4.2.1: resolution: {integrity: sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==} @@ -8671,6 +8678,8 @@ snapshots: ansi-styles: 6.2.3 is-fullwidth-code-point: 5.1.0 + slugify@1.6.9: {} + sonic-boom@4.2.1: dependencies: atomic-sleep: 1.0.0 diff --git a/src/app.module.ts b/src/app.module.ts index 3a82ac7..518cd25 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -21,7 +21,7 @@ import { CACHE_SERVICE } from '@shared/adapters/cache/constants'; import { ICacheService } from '@shared/adapters/cache/ports'; import { DatabaseHealthService } from '@libs/database'; import { ZodValidationInterceptor } from '@shared/interceptors'; -import { MetricsModule } from '@libs/metrics'; +import { AreaModule } from './area'; @Module({ imports: [ @@ -34,6 +34,7 @@ import { MetricsModule } from '@libs/metrics'; schema, schemaName: cfg.getOrThrow('DB_SCHEMA'), logging: true, + runMigrations: false, }; }, }), @@ -55,7 +56,7 @@ import { MetricsModule } from '@libs/metrics'; UserModule, TeamsModule, ProjectsModule, - MetricsModule, + AreaModule, HealthModule.registerAsync({ inject: [DatabaseHealthService, S3Service, CACHE_SERVICE], useFactory: (db: DatabaseHealthService, s3: S3Service, cache: ICacheService) => { diff --git a/src/area/application/area.facade.ts b/src/area/application/area.facade.ts new file mode 100644 index 0000000..969c470 --- /dev/null +++ b/src/area/application/area.facade.ts @@ -0,0 +1,91 @@ +import { Injectable } from '@nestjs/common'; +import { + CreateStateUseCase, + DeleteStateUseCase, + GetStateQuery, + GetStatesQuery, + ReorderStateUseCase, + RestoreStateUseCase, + UpdateStateUseCase, +} from './use-cases/states'; +import { + CreateStateDto, + UpdateStateDto, + ReordersStatesDto, + CreateAreaDto, + UpdateAreaDto, +} from './dtos'; +import { + CreateAreaUseCase, + DeleteAreaUseCase, + GetAreaQuery, + GetAreasQuery, + UpdateAreaUseCase, +} from './use-cases'; + +@Injectable() +export class AreaFacade { + constructor( + private readonly createAreaUC: CreateAreaUseCase, + private readonly updateAreaUC: UpdateAreaUseCase, + private readonly deleteAreaUC: DeleteAreaUseCase, + private readonly getAreasQ: GetAreasQuery, + private readonly getAreaQ: GetAreaQuery, + + private readonly getStatesQ: GetStatesQuery, + private readonly getStateDetailQ: GetStateQuery, + private readonly createStateUC: CreateStateUseCase, + private readonly updateStateUC: UpdateStateUseCase, + private readonly deleteStateUC: DeleteStateUseCase, + private readonly restoreStateUC: RestoreStateUseCase, + private readonly reorderStateUC: ReorderStateUseCase, + ) {} + + public async createArea(slug: string, dto: CreateAreaDto, userId: string) { + return this.createAreaUC.execute(slug, dto, userId); + } + + public async updateArea(slug: string, key: string, dto: UpdateAreaDto, userId: string) { + return this.updateAreaUC.execute(slug, key, dto, userId); + } + + public async deleteArea(slug: string, key: string, userId: string) { + return this.deleteAreaUC.execute(slug, key, userId); + } + + public async getAreas(slug: string, deleted: boolean, userId: string) { + return this.getAreasQ.execute(slug, deleted, userId); + } + + public async getArea(slug: string, key: string, userId: string) { + return this.getAreaQ.execute(slug, key, userId); + } + + public async createState(slug: string, dto: CreateStateDto, userId: string) { + return this.createStateUC.execute(slug, dto, userId); + } + + public async deleteState(slug: string, stateId: string, userId: string) { + return this.deleteStateUC.execute(slug, stateId, userId); + } + + public async updateState(slug: string, stateId: string, dto: UpdateStateDto, userId: string) { + return this.updateStateUC.execute(slug, stateId, dto, userId); + } + + public async getDetailState(slug: string, stateId: string, userId: string) { + return this.getStateDetailQ.execute(slug, stateId, userId); + } + + public async getStates(slug: string, query: unknown, userId: string) { + return this.getStatesQ.execute(slug, userId, query); + } + + public async restoreState(slug: string, stateId: string, userId: string) { + return this.restoreStateUC.execute(slug, stateId, userId); + } + + public async reoderStates(slug: string, dto: ReordersStatesDto, userId: string) { + return this.reorderStateUC.execute(slug, dto, userId); + } +} diff --git a/src/area/application/controllers/area/controller.ts b/src/area/application/controllers/area/controller.ts new file mode 100644 index 0000000..6145cd4 --- /dev/null +++ b/src/area/application/controllers/area/controller.ts @@ -0,0 +1,67 @@ +import { ApiBaseController, GetUserId } from '@shared/decorators'; +import { AreaFacade } from '../../area.facade'; +import { Post, Body, Get, Query, Param, Delete, Put } from '@nestjs/common'; +import { CreateAreaDto, UpdateAreaDto } from '../../dtos'; +import { + CreateAreaSwagger, + DeleteAreaSwagger, + FindAllAreasSwagger, + FindOneAreaSwagger, + UpdateAreaSwagger, +} from './swagger'; + +@ApiBaseController('projects/:slug/area', 'Project Areas', true) +export class AreaController { + constructor(private readonly facade: AreaFacade) {} + + @Post() + @CreateAreaSwagger() + async create( + @Param('slug') slug: string, + @Body() dto: CreateAreaDto, + @GetUserId() userId: string, + ) { + return this.facade.createArea(slug, dto, userId); + } + + @Get() + @FindAllAreasSwagger() + async findAll( + @Param('slug') slug: string, + @GetUserId() userId: string, + @Query('deleted') deleted?: string, + ) { + return this.facade.getAreas(slug, deleted === 'true', userId); + } + + @Get(':slug') + @FindOneAreaSwagger() + async findOne( + @Param('slug') slug: string, + @Param('key') key: string, + @GetUserId() userId: string, + ) { + return this.facade.getArea(slug, key, userId); + } + + @Delete(':slug') + @DeleteAreaSwagger() + async delete( + @Param('slug') slug: string, + @Param('key') key: string, + @GetUserId() userId: string, + ) { + return this.facade.deleteArea(slug, key, userId); + } + + @Put(':key') + @UpdateAreaSwagger() + async updateArea( + @Param('slug') slug: string, + @Param('key') key: string, + @Body() dto: UpdateAreaDto, + @GetUserId('id') userId: string, + ) { + return this.facade.updateArea(slug, key, dto, userId); + } +} diff --git a/src/area/application/controllers/area/swagger.ts b/src/area/application/controllers/area/swagger.ts new file mode 100644 index 0000000..42f5cc5 --- /dev/null +++ b/src/area/application/controllers/area/swagger.ts @@ -0,0 +1,274 @@ +import { applyDecorators, SetMetadata } from '@nestjs/common'; +import { ApiOperation, ApiParam, ApiQuery, ApiResponse, ApiBody } from '@nestjs/swagger'; +import { ActionResponse } from '@shared/dtos'; +import { + ApiUnauthorized, + ApiNotFound, + ApiValidationError, + ApiForbidden, + ApiConflict, +} from '@shared/error'; +import { ZOD_RESPONSE_TOKEN } from '@shared/interceptors'; +import { CreateAreaDto, UpdateAreaDto, AreaResponse } from '../../dtos'; + +export const CreateAreaSwagger = () => + applyDecorators( + ApiOperation({ + summary: 'Создать область (пространство) внутри проекта', + description: [ + 'Создаёт новую область — это как отдельная доска или пространство внутри вашего проекта.', + '', + 'Пример: в проекте разработки ПО можно создать области:', + '- «Бэкенд»', + '- «Фронтенд»', + '- «Дизайн»', + '- «Документация»', + '', + 'У каждой области свой workflow (свои колонки-статусы), настройки и права доступа.', + 'Области позволяют изолировать разные направления работы внутри одного проекта.', + ].join('\n'), + }), + ApiParam({ + name: 'slug', + type: 'string', + description: 'Slug проекта', + example: 'super-project', + }), + ApiBody({ + type: CreateAreaDto.Output, + description: 'Данные для создания области', + }), + ApiResponse({ + status: 201, + description: 'Область успешно создана', + type: ActionResponse.Output, + }), + ApiValidationError(), + ApiUnauthorized(), + ApiForbidden('Нет прав для создания области в этом проекте'), + ApiConflict('Область с таким названием или slug уже существует'), + + SetMetadata(ZOD_RESPONSE_TOKEN, ActionResponse), + ); + +export const FindAllAreasSwagger = () => + applyDecorators( + ApiOperation({ + summary: 'Получить список всех областей в проекте', + description: [ + 'Возвращает все области (пространства) внутри проекта с их настройками.', + 'Это как посмотреть все доски в проекте.', + '', + 'Доступные фильтры:', + '- `deleted=true` — показать мягко удалённые области', + '- `includeCounts=true` — добавить количество задач в каждой области', + '', + 'Полезно для навигации по проекту и отображения сводки по всем направлениям работы.', + ].join('\n'), + }), + ApiParam({ + name: 'slug', + type: 'string', + description: 'Slug проекта', + example: 'super-project', + }), + ApiQuery({ + name: 'deleted', + required: false, + type: Boolean, + description: 'Показать мягко удалённые области', + example: 'false', + }), + ApiQuery({ + name: 'includeCounts', + required: false, + type: Boolean, + description: 'Добавить количество задач в каждой области (tasksCount)', + example: 'true', + }), + ApiResponse({ + status: 200, + description: 'Список областей получен', + type: [AreaResponse.Output], + }), + ApiUnauthorized(), + ApiNotFound('Проект не найден'), + + SetMetadata(ZOD_RESPONSE_TOKEN, AreaResponse), + ); + +export const FindOneAreaSwagger = () => + applyDecorators( + ApiOperation({ + summary: 'Получить детали конкретной области', + description: [ + 'Возвращает полную информацию об одной области по её slug или ID.', + 'Вы можете обратиться как по человекопонятному slug, так и по внутреннему ID.', + '', + 'Информация включает:', + '- название', + '- описание', + '- цвет и иконку', + '- настройки workflow', + '- количество задач и метрики по области', + '', + 'По сути, это получение всех настроек конкретной доски.', + ].join('\n'), + }), + ApiParam({ + name: 'slug', + type: 'string', + description: 'Slug проекта', + example: 'super-project', + }), + ApiParam({ + name: 'key', + type: 'string', + description: 'Slug или ID области', + example: 'development', + }), + ApiResponse({ + status: 200, + description: 'Информация об области получена', + type: AreaResponse.Output, + }), + ApiNotFound('Область не найдена'), + ApiUnauthorized(), + + SetMetadata(ZOD_RESPONSE_TOKEN, AreaResponse), + ); + +export const UpdateAreaSwagger = () => + applyDecorators( + ApiOperation({ + summary: 'Обновить настройки области', + description: [ + 'Изменяет параметры существующей области.', + '', + 'Что можно изменить:', + '- название', + '- описание', + '- цветовую метку', + '- иконку', + '- лимит задач (максимум задач в этой области)', + '- другие настройки', + '', + 'Пример: переименовать область «Дизайн» в «Дизайн и прототипирование»', + 'или изменить её цвет в интерфейсе.', + ].join('\n'), + }), + ApiParam({ + name: 'slug', + type: 'string', + description: 'Slug проекта', + example: 'super-project', + }), + ApiParam({ + name: 'key', + type: 'string', + description: 'Slug или ID области', + example: 'development', + }), + ApiBody({ + type: UpdateAreaDto.Output, + description: 'Обновляемые поля', + }), + ApiResponse({ + status: 200, + description: 'Область обновлена', + type: ActionResponse.Output, + }), + ApiValidationError(), + ApiNotFound('Область не найдена'), + ApiUnauthorized(), + ApiForbidden('Нет прав для обновления этой области'), + ApiConflict('Область с таким названием или slug уже существует'), + + SetMetadata(ZOD_RESPONSE_TOKEN, ActionResponse), + ); + +export const DeleteAreaSwagger = () => + applyDecorators( + ApiOperation({ + summary: 'Удалить область', + description: [ + 'Мягкое удаление области — она перестаёт отображаться, но данные сохраняются.', + '', + 'Важные ограничения:', + '- удалить область можно только если в ней НЕТ задач', + '- это защита от случайной потери данных', + '', + 'Если в области есть задачи:', + '- их нужно сначала переместить в другую область', + '- или удалить', + '', + 'Удалённую область можно потом восстановить через метод восстановления.', + ].join('\n'), + }), + ApiParam({ + name: 'slug', + type: 'string', + description: 'Slug проекта', + example: 'super-project', + }), + ApiParam({ + name: 'key', + type: 'string', + description: 'Slug или ID области', + example: 'development', + }), + ApiResponse({ + status: 200, + description: 'Область удалена', + type: ActionResponse.Output, + }), + ApiNotFound('Область не найдена'), + ApiUnauthorized(), + ApiForbidden('Нет прав для удаления этой области'), + ApiConflict('Нельзя удалить область, в которой есть задачи'), + + SetMetadata(ZOD_RESPONSE_TOKEN, ActionResponse), + ); + +export const RestoreAreaSwagger = () => + applyDecorators( + ApiOperation({ + summary: 'Восстановить удалённую область', + description: [ + 'Восстанавливает мягко удалённую область.', + '', + 'Что восстанавливается:', + '- сама область', + '- все её состояния (колонки на доске)', + '', + 'Что НЕ восстанавливается автоматически:', + '- задачи, которые были в области', + '', + 'Полезно, если:', + '- область удалили по ошибке', + '- решили вернуть архивное направление работы', + ].join('\n'), + }), + ApiParam({ + name: 'slug', + type: 'string', + description: 'Slug проекта', + example: 'super-project', + }), + ApiParam({ + name: 'key', + type: 'string', + description: 'Slug или ID области', + example: 'development', + }), + ApiResponse({ + status: 200, + description: 'Область восстановлена', + type: ActionResponse.Output, + }), + ApiNotFound('Удалённая область не найдена'), + ApiUnauthorized(), + ApiForbidden('Нет прав для восстановления'), + + SetMetadata(ZOD_RESPONSE_TOKEN, ActionResponse), + ); diff --git a/src/area/application/controllers/index.ts b/src/area/application/controllers/index.ts new file mode 100644 index 0000000..e67b9bf --- /dev/null +++ b/src/area/application/controllers/index.ts @@ -0,0 +1,4 @@ +import { AreaController } from './area/controller'; +import { StateController } from './state/controller'; + +export const CONTROLLERS = [AreaController, StateController]; diff --git a/src/projects/application/controller/states/controller.ts b/src/area/application/controllers/state/controller.ts similarity index 69% rename from src/projects/application/controller/states/controller.ts rename to src/area/application/controllers/state/controller.ts index 417554b..753a56c 100644 --- a/src/projects/application/controller/states/controller.ts +++ b/src/area/application/controllers/state/controller.ts @@ -1,24 +1,24 @@ import { Body, Delete, Get, Param, Patch, Post, Query } from '@nestjs/common'; import { ApiBaseController, GetUserId, Public } from '@shared/decorators'; import { - CreateProjectStateSwagger, - FindAllProjectStatesSwagger, - FindOneProjectStateSwagger, - RemoveProjectStateSwagger, - ReorderProjectStatesSwagger, - RestoreProjectStateSwagger, - UpdateProjectStateSwagger, + CreateStateSwagger, + FindAllStatesSwagger, + FindOneStateSwagger, + RemoveStateSwagger, + ReorderStatesSwagger, + RestoreStateSwagger, + UpdateStateSwagger, } from './swagger'; -import { ProjectsFacade } from '../../projects.facade'; -import { CreateProjectStateDto, ReorderProjectsStatesDto, UpdateProjectStateDto } from '../../dtos'; +import { CreateStateDto, ReordersStatesDto, UpdateStateDto } from '../../dtos'; +import { AreaFacade } from '../../area.facade'; -@ApiBaseController('projects/:slug/states', 'Project States', true) -export class ProjectsStatesController { - constructor(private readonly facade: ProjectsFacade) {} +@ApiBaseController('area/:slug/states', 'Area States', true) +export class StateController { + constructor(private readonly facade: AreaFacade) {} @Get() @Public() - @FindAllProjectStatesSwagger() + @FindAllStatesSwagger() async getAll( @Param('slug') slug: string, @GetUserId() userId: string, @@ -40,7 +40,7 @@ export class ProjectsStatesController { } @Get(':stateId') - @FindOneProjectStateSwagger() + @FindOneStateSwagger() async findOne( @Param('slug') slug: string, @Param('stateId') stateId: string, @@ -50,17 +50,17 @@ export class ProjectsStatesController { } @Post() - @CreateProjectStateSwagger() + @CreateStateSwagger() async create( @Param('slug') slug: string, - @Body() dto: CreateProjectStateDto, + @Body() dto: CreateStateDto, @GetUserId() userId: string, ) { return this.facade.createState(slug, dto, userId); } @Delete(':stateId') - @RemoveProjectStateSwagger() + @RemoveStateSwagger() async delete( @Param('slug') slug: string, @Param('stateId') stateId: string, @@ -70,28 +70,28 @@ export class ProjectsStatesController { } @Patch('reorder') - @ReorderProjectStatesSwagger() + @ReorderStatesSwagger() async reorder( @Param('slug') slug: string, - @Body() dto: ReorderProjectsStatesDto, + @Body() dto: ReordersStatesDto, @GetUserId() userId: string, ) { return this.facade.reoderStates(slug, dto, userId); } @Patch(':stateId') - @UpdateProjectStateSwagger() + @UpdateStateSwagger() async update( @Param('slug') slug: string, @Param('stateId') stateId: string, - @Body() dto: UpdateProjectStateDto, + @Body() dto: UpdateStateDto, @GetUserId() userId: string, ) { return this.facade.updateState(slug, stateId, dto, userId); } @Post(':stateId/restore') - @RestoreProjectStateSwagger() + @RestoreStateSwagger() async restore( @Param('slug') slug: string, @Param('stateId') stateId: string, diff --git a/src/projects/application/controller/states/swagger.ts b/src/area/application/controllers/state/swagger.ts similarity index 56% rename from src/projects/application/controller/states/swagger.ts rename to src/area/application/controllers/state/swagger.ts index b4ad804..e436a7d 100644 --- a/src/projects/application/controller/states/swagger.ts +++ b/src/area/application/controllers/state/swagger.ts @@ -10,18 +10,24 @@ import { } from '@shared/error'; import { ZOD_RESPONSE_TOKEN } from '@shared/interceptors'; import { - CreateProjectStateDto, - CreateProjectStateResponse, - ProjectStateResponse, - ReorderProjectsStatesDto, - UpdateProjectStateDto, + CreateStateDto, + UpdateStateDto, + ReordersStatesDto, + CreateStateResponse, + StateResponse, } from '../../dtos'; -export const FindAllProjectStatesSwagger = () => +export const FindAllStatesSwagger = () => applyDecorators( ApiOperation({ - summary: 'Получить все состояния проекта', - description: 'Возвращает список всех статусов (колонок) проекта с их настройками', + summary: 'Получить список всех колонок (статусов) на доске проекта', + description: [ + 'Возвращает все статусы, которые есть в проекте.', + 'В канбан-доске это колонки вроде «К выполнению», «В работе», «На ревью», «Готово».', + 'Метод позволяет фильтровать по категориям (бэклог, активные, завершённые),', + 'а также опционально подгружать количество задач в каждой колонке,', + 'сколько из них просрочено и сколько задач назначено на текущего пользователя.', + ].join('\n'), }), ApiParam({ name: 'slug', @@ -68,19 +74,24 @@ export const FindAllProjectStatesSwagger = () => ApiResponse({ status: 200, description: 'Список состояний получен', - type: [ProjectStateResponse.Output], + type: [StateResponse.Output], }), ApiUnauthorized(), ApiNotFound('Проект не найден'), - SetMetadata(ZOD_RESPONSE_TOKEN, ProjectStateResponse), + SetMetadata(ZOD_RESPONSE_TOKEN, StateResponse), ); -export const FindOneProjectStateSwagger = () => +export const FindOneStateSwagger = () => applyDecorators( ApiOperation({ - summary: 'Получить детальную информацию о состоянии', - description: 'Возвращает полную информацию о статусе проекта', + summary: 'Получить детали одной колонки (статуса)', + description: [ + 'Возвращает полную информацию о конкретной колонке на доске:', + 'её название, цвет, иконку, тип (системная / кастомная),', + 'WIP-лимит (ограничение на число задач), а также порядок отображения.', + 'Полезно, например, при редактировании настроек колонки.', + ].join('\n'), }), ApiParam({ name: 'slug', @@ -97,20 +108,24 @@ export const FindOneProjectStateSwagger = () => ApiResponse({ status: 200, description: 'Информация о состоянии получена', - type: ProjectStateResponse.Output, + type: StateResponse.Output, }), ApiNotFound('Состояние не найдено'), ApiUnauthorized(), - SetMetadata(ZOD_RESPONSE_TOKEN, ProjectStateResponse), + SetMetadata(ZOD_RESPONSE_TOKEN, StateResponse), ); -export const CreateProjectStateSwagger = () => +export const CreateStateSwagger = () => applyDecorators( ApiOperation({ - summary: 'Создать новое состояние', - description: - 'Создаёт новый статус (колонку) для проекта. Можно указать тип, иконку, цвет и WIP лимит.', + summary: 'Создать новую колонку на доске', + description: [ + 'Добавляет новый статус (колонку) в проект.', + 'Например, вы хотите создать этап «Дизайн» или «Тестирование» между «В работе» и «Готово».', + 'Можно задать название, иконку, цвет, категорию, а также WIP-лимит —', + 'максимальное количество задач, которые могут одновременно находиться в этой колонке.', + ].join('\n'), }), ApiParam({ name: 'slug', @@ -119,28 +134,31 @@ export const CreateProjectStateSwagger = () => example: 'super-project', }), ApiBody({ - type: CreateProjectStateDto.Output, + type: CreateStateDto.Output, description: 'Данные для создания состояния', }), ApiResponse({ status: 201, description: 'Состояние успешно создано', - type: CreateProjectStateResponse.Output, + type: CreateStateResponse.Output, }), ApiValidationError(), ApiUnauthorized(), ApiForbidden('Нет прав для создания состояния в этом проекте'), ApiConflict('Состояние с таким названием или типом уже существует'), - SetMetadata(ZOD_RESPONSE_TOKEN, CreateProjectStateResponse), + SetMetadata(ZOD_RESPONSE_TOKEN, CreateStateDto), ); -export const UpdateProjectStateSwagger = () => +export const UpdateStateSwagger = () => applyDecorators( ApiOperation({ - summary: 'Обновить состояние', - description: - 'Обновляет параметры статуса. Системные статусы (isLocked=true) нельзя переименовать, но можно изменить визуал.', + summary: 'Обновить настройки колонки', + description: [ + 'Изменяет параметры существующей колонки: название, цвет, иконку, WIP-лимит.', + 'Системные (защищённые) статусы, например «Архив», нельзя переименовать или удалить,', + 'но можно поменять их внешний вид (цвет/иконку), чтобы они вписывались в дизайн вашей доски.', + ].join('\n'), }), ApiParam({ name: 'slug', @@ -155,7 +173,7 @@ export const UpdateProjectStateSwagger = () => example: 'clv123456', }), ApiBody({ - type: UpdateProjectStateDto.Output, + type: UpdateStateDto.Output, description: 'Обновляемые поля', }), ApiResponse({ @@ -172,12 +190,16 @@ export const UpdateProjectStateSwagger = () => SetMetadata(ZOD_RESPONSE_TOKEN, ActionResponse), ); -export const ReorderProjectStatesSwagger = () => +export const ReorderStatesSwagger = () => applyDecorators( ApiOperation({ - summary: 'Переупорядочить состояния', - description: - 'Меняет порядок колонок на доске. Принимает массив ID состояний в новом порядке.', + summary: 'Изменить порядок колонок на доске', + description: [ + 'Позволяет переставить колонки на канбан-доске так, как вам удобно.', + 'Вы просто передаёте массив ID колонок в нужном порядке —', + 'сервер сохранит эту последовательность.', + 'Например, вы хотите, чтобы колонка «Готово» была не последней, а перед «Архивом».', + ].join('\n'), }), ApiParam({ name: 'slug', @@ -186,7 +208,7 @@ export const ReorderProjectStatesSwagger = () => example: 'super-project', }), ApiBody({ - type: ReorderProjectsStatesDto.Output, + type: ReordersStatesDto.Output, description: 'Массив ID состояний в правильном порядке', }), ApiResponse({ @@ -202,12 +224,16 @@ export const ReorderProjectStatesSwagger = () => SetMetadata(ZOD_RESPONSE_TOKEN, ActionResponse), ); -export const RemoveProjectStateSwagger = () => +export const RemoveStateSwagger = () => applyDecorators( ApiOperation({ - summary: 'Удалить состояние', - description: - 'Мягкое удаление статуса. Статус можно удалить только если в нём нет задач.', + summary: 'Удалить колонку (если в ней нет задач)', + description: [ + 'Мягкое удаление статуса (колонка перестаёт отображаться на доске).', + 'Важное ограничение: удалить колонку можно только тогда,', + 'когда в ней не находится ни одной задачи.', + 'Системные статусы удалять нельзя — это защита от случайной поломки логики проекта.', + ].join('\n'), }), ApiParam({ name: 'slug', @@ -234,11 +260,15 @@ export const RemoveProjectStateSwagger = () => SetMetadata(ZOD_RESPONSE_TOKEN, ActionResponse), ); -export const RestoreProjectStateSwagger = () => +export const RestoreStateSwagger = () => applyDecorators( ApiOperation({ - summary: 'Восстановить удалённое состояние', - description: 'Восстанавливает мягко удалённый статус', + summary: 'Вернуть удалённую колонку обратно на доску', + description: [ + 'Восстанавливает ранее мягко удалённый статус.', + 'Все настройки колонки (название, цвет, порядок) возвращаются как были.', + 'Полезно, если колонку удалили по ошибке или она снова понадобилась.', + ].join('\n'), }), ApiParam({ name: 'slug', diff --git a/src/area/application/dtos/area.dto.ts b/src/area/application/dtos/area.dto.ts new file mode 100644 index 0000000..0680008 --- /dev/null +++ b/src/area/application/dtos/area.dto.ts @@ -0,0 +1,125 @@ +import { z } from 'zod/v4'; +import { createZodDto } from 'nestjs-zod'; + +export const DefaultViewSchema = z + .enum(['kanban', 'list', 'calendar', 'gantt']) + .default('kanban') + .describe('Тип отображения по умолчанию для области'); + +export const AreaSchema = z.object({ + id: z.string().min(1, 'ID не может быть пустым').describe('Уникальный идентификатор области'), + projectId: z + .string() + .min(1, 'ID проекта обязателен') + .describe('ID проекта, к которому принадлежит область'), + title: z + .string() + .min(1, 'Название области обязательно') + .max(255, 'Название не должно превышать 255 символов') + .describe('Отображаемое название области (например: "Разработка", "Согласование")'), + slug: z + .string() + .min(1, 'Slug обязателен') + .max(100, 'Slug не должен превышать 100 символов') + .regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/, 'Slug должен быть в формате kebab-case') + .describe('URL-дружественный идентификатор (например: "development", "contract-approval")'), + description: z + .string() + .nullable() + .optional() + .describe('Markdown-описание области, её цели и правила работы'), + descriptionHtml: z + .string() + .nullable() + .optional() + .describe('Сгенерированный HTML из Markdown описания'), + color: z + .string() + .regex( + /^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$/, + 'Цвет должен быть в HEX формате (#RRGGBB или #RGB)', + ) + .nullable() + .optional() + .describe('HEX-код цвета для визуального выделения области'), + icon: z + .string() + .max(20, 'Иконка должна быть не длиннее 20 символов') + .nullable() + .optional() + .describe('Emoji или иконка для визуального обозначения (например: "💻", "📝", "🎨")'), + tasksCount: z + .number() + .int('Количество задач должно быть целым числом') + .min(0, 'Количество задач не может быть отрицательным') + .default(0) + .describe('Общее количество задач в этой области (денормализованное поле)'), + defaultView: DefaultViewSchema.describe('Представление по умолчанию для области'), + position: z + .number() + .int('Позиция должна быть целым числом') + .min(0, 'Позиция не может быть отрицательной') + .default(0) + .describe('Порядок отображения области в списке (меньше число — выше)'), + maxTasksLimit: z + .number() + .int('Лимит задач должен быть целым числом') + .positive('Лимит задач должен быть положительным числом') + .nullable() + .optional() + .describe('Максимальное количество задач во всей области. Null — без лимита'), + isLocked: z + .boolean() + .default(false) + .describe('Заблокирована для изменений (нельзя добавлять/удалять задачи)'), + createdAt: z + .string() + .datetime({ offset: true }) + .describe('Дата и время создания области (ISO 8601 с таймзоной)'), + updatedAt: z + .string() + .datetime({ offset: true }) + .describe('Дата и время последнего обновления области'), + createdBy: z.string().nullable().optional().describe('ID пользователя, создавшего область'), + deletedAt: z + .string() + .datetime({ offset: true }) + .nullable() + .optional() + .describe('Дата мягкого удаления (null — не удалено)'), +}); + +export const CreateAreaSchema = AreaSchema.omit({ + id: true, + projectId: true, + tasksCount: true, + createdAt: true, + updatedAt: true, + createdBy: true, + deletedAt: true, + descriptionHtml: true, +}) + .partial({ + description: true, + color: true, + icon: true, + position: true, + maxTasksLimit: true, + defaultView: true, + }) + .extend({ + slug: z + .string() + .regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/, 'Slug должен быть в формате kebab-case') + .optional() + .describe('Опциональный slug. Если не указан — генерируется из title'), + }) + .describe('Схема для создания новой области'); + +export const UpdateAreaSchema = CreateAreaSchema.partial().describe('Схема для обновления области'); + +export class AreaResponse extends createZodDto(AreaSchema) {} + +export class CreateAreaDto extends createZodDto(CreateAreaSchema) {} + +export class UpdateAreaDto extends createZodDto(UpdateAreaSchema) {} diff --git a/src/area/application/dtos/index.ts b/src/area/application/dtos/index.ts new file mode 100644 index 0000000..e929082 --- /dev/null +++ b/src/area/application/dtos/index.ts @@ -0,0 +1,2 @@ +export * from './states.dto'; +export * from './area.dto'; diff --git a/src/projects/application/dtos/states.dto.ts b/src/area/application/dtos/states.dto.ts similarity index 91% rename from src/projects/application/dtos/states.dto.ts rename to src/area/application/dtos/states.dto.ts index 8c45f4e..a285a5d 100644 --- a/src/projects/application/dtos/states.dto.ts +++ b/src/area/application/dtos/states.dto.ts @@ -16,7 +16,7 @@ export const StateSchema = z.object({ .min(1, 'ID не может быть пустым') .describe('Уникальный идентификатор состояния (UUID или наноид)'), - projectId: z + Id: z .string() .min(1, 'ID проекта обязателен') .describe('ID проекта, к которому принадлежит состояние'), @@ -121,11 +121,11 @@ export const StateSchema = z.object({ .describe('Дата мягкого удаления (null — не удалено)'), }); -export const CreateProjectStateResponseSchema = ActionResponseSchema.extend({ +export const CreateStateResponseSchema = ActionResponseSchema.extend({ id: z.string().describe('ID созданного состояния'), }); -export const CreateProjectStateSchema = StateSchema.omit({ +export const CreateStateSchema = StateSchema.omit({ id: true, createdAt: true, updatedAt: true, @@ -150,12 +150,12 @@ export const ReorderStatesSchema = z.object({ items: z.array(ReorderStateItemSchema).min(1).describe('Массив состояний с новыми индексами'), }); -export class ProjectStateResponse extends createZodDto(StateSchema) {} +export class StateResponse extends createZodDto(StateSchema) {} -export class CreateProjectStateDto extends createZodDto(CreateProjectStateSchema) {} +export class CreateStateDto extends createZodDto(CreateStateSchema) {} -export class UpdateProjectStateDto extends createZodDto(CreateProjectStateSchema.partial()) {} +export class UpdateStateDto extends createZodDto(CreateStateSchema.partial()) {} -export class CreateProjectStateResponse extends createZodDto(CreateProjectStateResponseSchema) {} +export class CreateStateResponse extends createZodDto(CreateStateResponseSchema) {} -export class ReorderProjectsStatesDto extends createZodDto(ReorderStatesSchema) {} +export class ReordersStatesDto extends createZodDto(ReorderStatesSchema) {} diff --git a/src/area/application/use-cases/areas/create.use-case.ts b/src/area/application/use-cases/areas/create.use-case.ts new file mode 100644 index 0000000..6645e54 --- /dev/null +++ b/src/area/application/use-cases/areas/create.use-case.ts @@ -0,0 +1,28 @@ +import { IAreaRepository } from '@core/area/domain/repository'; +import { Inject, Injectable } from '@nestjs/common'; +import { CreateAreaDto } from '../../dtos'; +import { ProjectAccessPolicy } from '@core/projects/domain/policy'; + +@Injectable() +export class CreateAreaUseCase { + constructor( + @Inject('IAreaRepository') + private readonly areaRepo: IAreaRepository, + private readonly projectPolicy: ProjectAccessPolicy, + ) {} + + async execute(slug: string, dto: CreateAreaDto, userId: string) { + const { member, project } = await this.projectPolicy.ensureProjectAccess(slug, userId, [ + 'admin', + 'owner', + ]); + + (void member, project, dto); + void this.areaRepo; + + return { + success: true, + message: '', + }; + } +} diff --git a/src/area/application/use-cases/areas/delete.use-case.ts b/src/area/application/use-cases/areas/delete.use-case.ts new file mode 100644 index 0000000..a36d433 --- /dev/null +++ b/src/area/application/use-cases/areas/delete.use-case.ts @@ -0,0 +1,24 @@ +import { IAreaRepository } from '@core/area/domain/repository'; +import { ProjectAccessPolicy } from '@core/projects/domain/policy'; +import { Inject, Injectable } from '@nestjs/common'; + +@Injectable() +export class DeleteAreaUseCase { + constructor( + @Inject('IAreaRepository') + private readonly areaRepo: IAreaRepository, + private readonly projectPolicy: ProjectAccessPolicy, + ) {} + + async execute(slug: string, key: string, userId: string) { + (void this.areaRepo, this.projectPolicy); + + return { + success: true, + message: '', + slug, + key, + userId, + }; + } +} diff --git a/src/area/application/use-cases/areas/get-all.query.ts b/src/area/application/use-cases/areas/get-all.query.ts new file mode 100644 index 0000000..478edfd --- /dev/null +++ b/src/area/application/use-cases/areas/get-all.query.ts @@ -0,0 +1,24 @@ +import { IAreaRepository } from '@core/area/domain/repository'; +import { ProjectAccessPolicy } from '@core/projects/domain/policy'; +import { Inject, Injectable } from '@nestjs/common'; + +@Injectable() +export class GetAreasQuery { + constructor( + @Inject('IAreaRepository') + private readonly areaRepo: IAreaRepository, + private readonly projectPolicy: ProjectAccessPolicy, + ) {} + + async execute(slug: string, dto: unknown, userId: string) { + (void this.areaRepo, this.projectPolicy); + + return { + success: true, + message: '', + slug, + dto, + userId, + }; + } +} diff --git a/src/area/application/use-cases/areas/get-one.query.ts b/src/area/application/use-cases/areas/get-one.query.ts new file mode 100644 index 0000000..ae6f5f9 --- /dev/null +++ b/src/area/application/use-cases/areas/get-one.query.ts @@ -0,0 +1,25 @@ +import { IAreaRepository } from '@core/area/domain/repository'; +import { ProjectAccessPolicy } from '@core/projects/domain/policy'; +import { HttpStatus, Inject, Injectable } from '@nestjs/common'; +import { BaseException } from '@shared/error'; + +@Injectable() +export class GetAreaQuery { + constructor( + @Inject('IAreaRepository') + private readonly areaRepo: IAreaRepository, + private readonly projectPolicy: ProjectAccessPolicy, + ) {} + + async execute(slug: string, key: string, userId: string) { + const { project } = await this.projectPolicy.ensureProjectAccess(slug, userId); + + const area = await this.areaRepo.findBySlug(project.slug, key); + + if (!area) { + throw new BaseException({ code: '', message: '' }, HttpStatus.NOT_FOUND); + } + + return area; + } +} diff --git a/src/area/application/use-cases/areas/index.ts b/src/area/application/use-cases/areas/index.ts new file mode 100644 index 0000000..c372488 --- /dev/null +++ b/src/area/application/use-cases/areas/index.ts @@ -0,0 +1,19 @@ +import { CreateAreaUseCase } from './create.use-case'; +import { DeleteAreaUseCase } from './delete.use-case'; +import { GetAreasQuery } from './get-all.query'; +import { GetAreaQuery } from './get-one.query'; +import { UpdateAreaUseCase } from './update.use-case'; + +export * from './create.use-case'; +export * from './delete.use-case'; +export * from './get-all.query'; +export * from './get-one.query'; +export * from './update.use-case'; + +export const AreasUseCases = [ + CreateAreaUseCase, + DeleteAreaUseCase, + UpdateAreaUseCase, + GetAreaQuery, + GetAreasQuery, +]; diff --git a/src/area/application/use-cases/areas/update.use-case.ts b/src/area/application/use-cases/areas/update.use-case.ts new file mode 100644 index 0000000..028b79f --- /dev/null +++ b/src/area/application/use-cases/areas/update.use-case.ts @@ -0,0 +1,25 @@ +import { IAreaRepository } from '@core/area/domain/repository'; +import { Inject, Injectable } from '@nestjs/common'; +import { UpdateAreaDto } from '../../dtos'; +import { ProjectAccessPolicy } from '@core/projects/domain/policy'; + +@Injectable() +export class UpdateAreaUseCase { + constructor( + @Inject('IAreaRepository') + private readonly areaRepo: IAreaRepository, + private readonly projectPolicy: ProjectAccessPolicy, + ) {} + + async execute(slug: string, key: string, dto: UpdateAreaDto, userId: string) { + (void this.areaRepo, this.projectPolicy, dto); + + return { + success: true, + message: '', + slug, + key, + userId, + }; + } +} diff --git a/src/area/application/use-cases/index.ts b/src/area/application/use-cases/index.ts new file mode 100644 index 0000000..69b00cf --- /dev/null +++ b/src/area/application/use-cases/index.ts @@ -0,0 +1,2 @@ +export * from './states'; +export * from './areas'; diff --git a/src/projects/application/use-cases/states/create-state.use-case.ts b/src/area/application/use-cases/states/create.use-case.ts similarity index 53% rename from src/projects/application/use-cases/states/create-state.use-case.ts rename to src/area/application/use-cases/states/create.use-case.ts index b43cfe8..c07b479 100644 --- a/src/projects/application/use-cases/states/create-state.use-case.ts +++ b/src/area/application/use-cases/states/create.use-case.ts @@ -1,43 +1,24 @@ -import { IProjectsRepository, IProjectStatesRepository } from '@core/projects/domain/repository'; import { HttpStatus, Inject, Injectable } from '@nestjs/common'; -import { CreateProjectStateDto } from '../../dtos'; -import { ProjectAccessPolicy } from '@core/projects/domain/policy'; import { BaseException } from '@shared/error'; -import { - ProjectErrorCodes, - ProjectErrorMessages, - ProjectStateErrorCodes, - ProjectStateErrorMessages, -} from '@core/projects/domain/errors'; +import { IStateRepository } from '@core/area/domain/repository'; +import { CreateStateDto } from '../../dtos'; +import { ProjectStateErrorCodes, ProjectStateErrorMessages } from '@core/area/domain/errors'; +import { GetAreaQuery } from '../areas'; const MAX_STATES_PER_PROJECT = 20; @Injectable() export class CreateStateUseCase { constructor( - @Inject('IProjectsRepository') - private readonly projectsRepo: IProjectsRepository, - @Inject('IProjectStatesRepository') - private readonly projectStatesRepo: IProjectStatesRepository, - private readonly policy: ProjectAccessPolicy, + @Inject('IStateRepository') + private readonly stateRepo: IStateRepository, + private readonly getAreaQ: GetAreaQuery, ) {} - async execute(slug: string, dto: CreateProjectStateDto, userId: string) { - const project = await this.projectsRepo.findOne(slug); + async execute(slug: string, dto: CreateStateDto, userId: string) { + const area = await this.getAreaQ.execute('projectSlug', slug, userId); - if (!project) { - throw new BaseException( - { - code: ProjectErrorCodes.NOT_FOUND, - message: ProjectErrorMessages[ProjectErrorCodes.NOT_FOUND], - }, - HttpStatus.NOT_FOUND, - ); - } - - await this.policy.ensureTeamAccess(project.teamId, userId, 'admin'); - - const currentCount = await this.projectStatesRepo.countByProject(project.id); + const currentCount = await this.stateRepo.countByArea(area.id); if (currentCount >= MAX_STATES_PER_PROJECT) { throw new BaseException( { @@ -50,7 +31,7 @@ export class CreateStateUseCase { } if (dto.title) { - const existingByTitle = await this.projectStatesRepo.findByTitle(project.id, dto.title); + const existingByTitle = await this.stateRepo.findByTitle(area.id, dto.title); if (existingByTitle) { throw new BaseException( @@ -65,10 +46,7 @@ export class CreateStateUseCase { } if (dto.stateType && dto.stateType !== 'custom') { - const existingByType = await this.projectStatesRepo.findByStateType( - project.id, - dto.stateType, - ); + const existingByType = await this.stateRepo.findByType(area.id, dto.stateType); if (existingByType) { throw new BaseException( @@ -82,9 +60,9 @@ export class CreateStateUseCase { } } - const result = await this.projectStatesRepo.create({ + const result = await this.stateRepo.create({ ...dto, - projectId: project.id, + areaId: area.id, createdBy: userId, }); diff --git a/src/projects/application/use-cases/states/delete-state.use-case.ts b/src/area/application/use-cases/states/delete.use-case.ts similarity index 63% rename from src/projects/application/use-cases/states/delete-state.use-case.ts rename to src/area/application/use-cases/states/delete.use-case.ts index 044ccbd..be6ce49 100644 --- a/src/projects/application/use-cases/states/delete-state.use-case.ts +++ b/src/area/application/use-cases/states/delete.use-case.ts @@ -1,41 +1,21 @@ -import { - ProjectErrorCodes, - ProjectErrorMessages, - ProjectStateErrorCodes, - ProjectStateErrorMessages, -} from '@core/projects/domain/errors'; -import { ProjectAccessPolicy } from '@core/projects/domain/policy'; -import { IProjectsRepository, IProjectStatesRepository } from '@core/projects/domain/repository'; +import { ProjectStateErrorCodes, ProjectStateErrorMessages } from '@core/area/domain/errors'; +import { IStateRepository } from '@core/area/domain/repository'; import { HttpStatus, Inject, Injectable } from '@nestjs/common'; import { BaseException } from '@shared/error'; +import { GetAreaQuery } from '../areas'; @Injectable() export class DeleteStateUseCase { constructor( - @Inject('IProjectsRepository') - private readonly projectsRepo: IProjectsRepository, - @Inject('IProjectStatesRepository') - private readonly projectStatesRepo: IProjectStatesRepository, - private readonly policy: ProjectAccessPolicy, + @Inject('IStateRepository') + private readonly stateRepo: IStateRepository, + private readonly getAreaQ: GetAreaQuery, ) {} async execute(slug: string, stateId: string, userId: string) { - const project = await this.projectsRepo.findOne(slug); - - if (!project) { - throw new BaseException( - { - code: ProjectErrorCodes.NOT_FOUND, - message: ProjectErrorMessages[ProjectErrorCodes.NOT_FOUND], - }, - HttpStatus.NOT_FOUND, - ); - } - - await this.policy.ensureTeamAccess(project.teamId, userId, 'admin'); - - const state = await this.projectStatesRepo.findOne(slug, stateId); + const area = await this.getAreaQ.execute('projectSlug', slug, userId); + const state = await this.stateRepo.findOne(area.id, stateId); if (!state) { throw new BaseException( { @@ -79,7 +59,7 @@ export class DeleteStateUseCase { // ); // } - const result = await this.projectStatesRepo.delete(slug, stateId); + const result = await this.stateRepo.delete(slug, stateId); return { success: result, diff --git a/src/area/application/use-cases/states/get-all.query.ts b/src/area/application/use-cases/states/get-all.query.ts new file mode 100644 index 0000000..fec73db --- /dev/null +++ b/src/area/application/use-cases/states/get-all.query.ts @@ -0,0 +1,19 @@ +import { IStateRepository } from '@core/area/domain/repository'; +import { Inject, Injectable } from '@nestjs/common'; +import { GetAreaQuery } from '../areas'; + +@Injectable() +export class GetStatesQuery { + constructor( + @Inject('IStateRepository') + private readonly stateRepo: IStateRepository, + private readonly getAreaQ: GetAreaQuery, + ) {} + + async execute(slug: string, userId: string, query: unknown) { + const area = await this.getAreaQ.execute(slug, userId, 'viewer'); + const states = await this.stateRepo.find(area.id, query); + + return states; + } +} diff --git a/src/area/application/use-cases/states/get-one.query.ts b/src/area/application/use-cases/states/get-one.query.ts new file mode 100644 index 0000000..a5b1526 --- /dev/null +++ b/src/area/application/use-cases/states/get-one.query.ts @@ -0,0 +1,32 @@ +import { ProjectStateErrorCodes, ProjectStateErrorMessages } from '@core/area/domain/errors'; +import { IStateRepository } from '@core/area/domain/repository'; +import { FindProjectQuery } from '@core/projects'; +import { HttpStatus, Inject, Injectable } from '@nestjs/common'; +import { BaseException } from '@shared/error'; + +@Injectable() +export class GetStateQuery { + constructor( + @Inject('IStateRepository') + private readonly stateRepo: IStateRepository, + private readonly findProjectQ: FindProjectQuery, + ) {} + + async execute(slug: string, stateId: string, userId: string) { + const { project } = await this.findProjectQ.execute(slug, 'teamId??', 'viewer', userId); + + const state = await this.stateRepo.findOne(project.id, stateId); + + if (!state) { + throw new BaseException( + { + code: ProjectStateErrorCodes.NOT_FOUND, + message: ProjectStateErrorMessages[ProjectStateErrorCodes.NOT_FOUND], + }, + HttpStatus.NOT_FOUND, + ); + } + + return state; + } +} diff --git a/src/area/application/use-cases/states/index.ts b/src/area/application/use-cases/states/index.ts new file mode 100644 index 0000000..584da7a --- /dev/null +++ b/src/area/application/use-cases/states/index.ts @@ -0,0 +1,26 @@ +import { CreateStateUseCase } from './create.use-case'; +import { DeleteStateUseCase } from './delete.use-case'; +import { GetStateQuery } from './get-one.query'; +import { GetStatesQuery } from './get-all.query'; +import { ReorderStateUseCase } from './reorder.use-case'; +import { RestoreStateUseCase } from './restore.use-state'; +import { UpdateStateUseCase } from './update.use-case'; + +export * from './create.use-case'; +export * from './delete.use-case'; +export * from './update.use-case'; +export * from './get-one.query'; +export * from './get-all.query'; +export * from './restore.use-state'; +export * from './update.use-case'; +export * from './reorder.use-case'; + +export const StatesUseCases = [ + CreateStateUseCase, + RestoreStateUseCase, + DeleteStateUseCase, + UpdateStateUseCase, + GetStateQuery, + GetStatesQuery, + ReorderStateUseCase, +]; diff --git a/src/area/application/use-cases/states/reorder.use-case.ts b/src/area/application/use-cases/states/reorder.use-case.ts new file mode 100644 index 0000000..85e361c --- /dev/null +++ b/src/area/application/use-cases/states/reorder.use-case.ts @@ -0,0 +1,42 @@ +import { HttpStatus, Inject, Injectable } from '@nestjs/common'; +import { BaseException } from '@shared/error'; +import { ReordersStatesDto } from '../../dtos'; +import { IStateRepository } from '@core/area/domain/repository'; +import { ProjectStateErrorCodes, ProjectStateErrorMessages } from '@core/area/domain/errors'; +import { GetAreaQuery } from '../areas'; + +@Injectable() +export class ReorderStateUseCase { + constructor( + @Inject('IStateRepository') + private readonly stateRepo: IStateRepository, + private readonly getAreaQ: GetAreaQuery, + ) {} + + async execute(slug: string, dto: ReordersStatesDto, userId: string) { + const area = await this.getAreaQ.execute('projectSlug', slug, userId); + + const state = await this.stateRepo.find(slug); + + if (!state) { + throw new BaseException( + { + code: ProjectStateErrorCodes.NOT_FOUND, + message: ProjectStateErrorMessages[ProjectStateErrorCodes.NOT_FOUND], + }, + HttpStatus.NOT_FOUND, + ); + } + + // TODO: ADD REODER STATES + (void dto, area); + const result = true; + + return { + success: result, + message: result + ? 'Состояние успешно восстановлено' + : 'Не удалось восстановить состояние: запись не найдена или уже активна', + }; + } +} diff --git a/src/area/application/use-cases/states/restore.use-state.ts b/src/area/application/use-cases/states/restore.use-state.ts new file mode 100644 index 0000000..9eabdbe --- /dev/null +++ b/src/area/application/use-cases/states/restore.use-state.ts @@ -0,0 +1,41 @@ +import { ProjectStateErrorCodes, ProjectStateErrorMessages } from '@core/area/domain/errors'; +import { IStateRepository } from '@core/area/domain/repository'; +import { FindProjectQuery } from '@core/projects'; +import { HttpStatus, Inject, Injectable } from '@nestjs/common'; +import { BaseException } from '@shared/error'; + +@Injectable() +export class RestoreStateUseCase { + constructor( + @Inject('IStateRepository') + private readonly stateRepo: IStateRepository, + private readonly findProjectQ: FindProjectQuery, + ) {} + + async execute(slug: string, stateId: string, userId: string) { + const { project } = await this.findProjectQ.execute(slug, 'teamId??', 'admin', userId); + + const state = await this.stateRepo.findOne(slug, stateId, true); + + if (!state) { + throw new BaseException( + { + code: ProjectStateErrorCodes.NOT_FOUND, + message: ProjectStateErrorMessages[ProjectStateErrorCodes.NOT_FOUND], + }, + HttpStatus.NOT_FOUND, + ); + } + + const result = await this.stateRepo.update(project.id, stateId, { + deletedAt: null, + }); + + return { + success: result, + message: result + ? 'Состояние успешно восстановлено' + : 'Не удалось восстановить состояние: запись не найдена или уже активна', + }; + } +} diff --git a/src/projects/application/use-cases/states/update-state.use-case.ts b/src/area/application/use-cases/states/update.use-case.ts similarity index 53% rename from src/projects/application/use-cases/states/update-state.use-case.ts rename to src/area/application/use-cases/states/update.use-case.ts index 1b398a5..42cd934 100644 --- a/src/projects/application/use-cases/states/update-state.use-case.ts +++ b/src/area/application/use-cases/states/update.use-case.ts @@ -1,41 +1,22 @@ -import { ProjectAccessPolicy } from '@core/projects/domain/policy'; -import { IProjectsRepository, IProjectStatesRepository } from '@core/projects/domain/repository'; -import { - ProjectErrorCodes, - ProjectErrorMessages, - ProjectStateErrorCodes, - ProjectStateErrorMessages, -} from '@core/projects/domain/errors'; import { HttpStatus, Inject, Injectable } from '@nestjs/common'; import { BaseException } from '@shared/error'; -import { UpdateProjectStateDto } from '../../dtos'; +import { IStateRepository } from '@core/area/domain/repository'; +import { UpdateStateDto } from '../../dtos'; +import { FindProjectQuery } from '@core/projects'; +import { ProjectStateErrorCodes, ProjectStateErrorMessages } from '@core/area/domain/errors'; @Injectable() export class UpdateStateUseCase { constructor( - @Inject('IProjectsRepository') - private readonly projectsRepo: IProjectsRepository, - @Inject('IProjectStatesRepository') - private readonly projectStatesRepo: IProjectStatesRepository, - private readonly policy: ProjectAccessPolicy, + @Inject('IStateRepository') + private readonly stateRepo: IStateRepository, + private readonly findProjectQ: FindProjectQuery, ) {} - async execute(slug: string, stateId: string, dto: UpdateProjectStateDto, userId: string) { - const project = await this.projectsRepo.findOne(slug); + async execute(slug: string, stateId: string, dto: UpdateStateDto, userId: string) { + await this.findProjectQ.execute(slug, 'teamId??', 'admin', userId); - if (!project) { - throw new BaseException( - { - code: ProjectErrorCodes.NOT_FOUND, - message: ProjectErrorMessages[ProjectErrorCodes.NOT_FOUND], - }, - HttpStatus.NOT_FOUND, - ); - } - - await this.policy.ensureTeamAccess(project.teamId, userId, 'admin'); - - const state = await this.projectStatesRepo.findOne(slug, stateId); + const state = await this.stateRepo.findOne(slug, stateId); if (!state) { throw new BaseException( @@ -68,7 +49,7 @@ export class UpdateStateUseCase { ); } - const result = await this.projectStatesRepo.update(slug, stateId, dto); + const result = await this.stateRepo.update(slug, stateId, dto); return { success: result, diff --git a/src/area/area.module.ts b/src/area/area.module.ts new file mode 100644 index 0000000..184f1a6 --- /dev/null +++ b/src/area/area.module.ts @@ -0,0 +1,14 @@ +import { forwardRef, Module } from '@nestjs/common'; +import { REPOSITORIES } from './infrastructure/persistence/repositories'; +import { AreaFacade } from './application/area.facade'; +import { AreasUseCases, StatesUseCases } from './application/use-cases'; +import { CONTROLLERS } from './application/controllers'; +import { ProjectsModule } from '@core/projects'; + +@Module({ + imports: [forwardRef(() => ProjectsModule)], + controllers: [...CONTROLLERS], + providers: [...REPOSITORIES, ...StatesUseCases, ...AreasUseCases, AreaFacade], + exports: [], +}) +export class AreaModule {} diff --git a/src/area/domain/entities/area.domain.ts b/src/area/domain/entities/area.domain.ts new file mode 100644 index 0000000..cb00441 --- /dev/null +++ b/src/area/domain/entities/area.domain.ts @@ -0,0 +1,5 @@ +import { areas } from '@core/area/infrastructure/persistence/models'; +import type { InferInsertModel, InferSelectModel } from 'drizzle-orm'; + +export type Area = InferSelectModel; +export type NewArea = InferInsertModel; diff --git a/src/area/domain/entities/index.ts b/src/area/domain/entities/index.ts new file mode 100644 index 0000000..ad1baca --- /dev/null +++ b/src/area/domain/entities/index.ts @@ -0,0 +1,2 @@ +export * from './area.domain'; +export * from './state.domain'; diff --git a/src/area/domain/entities/state.domain.ts b/src/area/domain/entities/state.domain.ts new file mode 100644 index 0000000..7f95302 --- /dev/null +++ b/src/area/domain/entities/state.domain.ts @@ -0,0 +1,5 @@ +import { states } from '@core/area/infrastructure/persistence/models'; +import type { InferInsertModel, InferSelectModel } from 'drizzle-orm'; + +export type State = InferSelectModel; +export type NewState = InferInsertModel; diff --git a/src/area/domain/errors/area.errors.ts b/src/area/domain/errors/area.errors.ts new file mode 100644 index 0000000..da2f33f --- /dev/null +++ b/src/area/domain/errors/area.errors.ts @@ -0,0 +1,74 @@ +export const AreaErrorCodes = { + // 404 + NOT_FOUND: 'AREA.NOT_FOUND', + + // 409 — Conflict + SLUG_DUPLICATE: 'AREA.SLUG_DUPLICATE', + TITLE_DUPLICATE: 'AREA.TITLE_DUPLICATE', + ALREADY_LOCKED: 'AREA.ALREADY_LOCKED', + ALREADY_UNLOCKED: 'AREA.ALREADY_UNLOCKED', + + // 400 — Bad Request + TITLE_REQUIRED: 'AREA.TITLE_REQUIRED', + TITLE_TOO_LONG: 'AREA.TITLE_TOO_LONG', + SLUG_INVALID: 'AREA.SLUG_INVALID', + COLOR_INVALID: 'AREA.COLOR_INVALID', + ICON_INVALID: 'AREA.ICON_INVALID', + DESCRIPTION_TOO_LONG: 'AREA.DESCRIPTION_TOO_LONG', + PROJECT_REQUIRED: 'AREA.PROJECT_REQUIRED', + DEFAULT_VIEW_INVALID: 'AREA.DEFAULT_VIEW_INVALID', + POSITION_INVALID: 'AREA.POSITION_INVALID', + MAX_TASKS_LIMIT_INVALID: 'AREA.MAX_TASKS_LIMIT_INVALID', + + // 403 — Forbidden + MAX_LIMIT_REACHED: 'AREA.MAX_LIMIT_REACHED', + LOCKED: 'AREA.LOCKED', + ACCESS_DENIED: 'AREA.ACCESS_DENIED', + + // 422 — Unprocessable + HAS_ACTIVE_TASKS: 'AREA.HAS_ACTIVE_TASKS', + CANNOT_DELETE_LAST_AREA: 'AREA.CANNOT_DELETE_LAST_AREA', + + // 500 — Internal + CREATE_FAILED: 'AREA.CREATE_FAILED', + UPDATE_FAILED: 'AREA.UPDATE_FAILED', + DELETE_FAILED: 'AREA.DELETE_FAILED', + RESTORE_FAILED: 'AREA.RESTORE_FAILED', + REORDER_FAILED: 'AREA.REORDER_FAILED', +} as const; + +export type AreaErrorCode = (typeof AreaErrorCodes)[keyof typeof AreaErrorCodes]; + +export const AreaErrorMessages: Record = { + [AreaErrorCodes.NOT_FOUND]: 'Область не найдена', + + [AreaErrorCodes.SLUG_DUPLICATE]: 'Область с таким ключом уже существует в проекте', + [AreaErrorCodes.TITLE_DUPLICATE]: 'Область с таким названием уже существует в проекте', + [AreaErrorCodes.ALREADY_LOCKED]: 'Область уже заблокирована', + [AreaErrorCodes.ALREADY_UNLOCKED]: 'Область уже разблокирована', + + [AreaErrorCodes.TITLE_REQUIRED]: 'Название области не может быть пустым', + [AreaErrorCodes.TITLE_TOO_LONG]: 'Название области слишком длинное (максимум 255 символов)', + [AreaErrorCodes.SLUG_INVALID]: + 'Ключ области должен быть в формате kebab-case: строчные латинские буквы, цифры и дефисы', + [AreaErrorCodes.COLOR_INVALID]: 'Цвет должен быть в формате HEX (например, #3b82f6)', + [AreaErrorCodes.ICON_INVALID]: 'Иконка слишком длинная (максимум 20 символов)', + [AreaErrorCodes.DESCRIPTION_TOO_LONG]: 'Описание слишком длинное (максимум 5000 символов)', + [AreaErrorCodes.PROJECT_REQUIRED]: 'ID проекта обязателен', + [AreaErrorCodes.DEFAULT_VIEW_INVALID]: 'Недопустимый вид отображения по умолчанию', + [AreaErrorCodes.POSITION_INVALID]: 'Позиция должна быть неотрицательным целым числом', + [AreaErrorCodes.MAX_TASKS_LIMIT_INVALID]: 'Лимит задач должен быть положительным целым числом', + + [AreaErrorCodes.MAX_LIMIT_REACHED]: 'Достигнут лимит областей в проекте', + [AreaErrorCodes.LOCKED]: 'Область заблокирована и не может быть изменена', + [AreaErrorCodes.ACCESS_DENIED]: 'У вас нет доступа к управлению областями этого проекта', + + [AreaErrorCodes.HAS_ACTIVE_TASKS]: 'Нельзя удалить область, в которой есть задачи', + [AreaErrorCodes.CANNOT_DELETE_LAST_AREA]: 'Нельзя удалить последнюю область проекта', + + [AreaErrorCodes.CREATE_FAILED]: 'Не удалось создать область', + [AreaErrorCodes.UPDATE_FAILED]: 'Не удалось обновить область', + [AreaErrorCodes.DELETE_FAILED]: 'Не удалось удалить область', + [AreaErrorCodes.RESTORE_FAILED]: 'Не удалось восстановить область', + [AreaErrorCodes.REORDER_FAILED]: 'Не удалось изменить порядок областей', +} as const; diff --git a/src/area/domain/errors/index.ts b/src/area/domain/errors/index.ts new file mode 100644 index 0000000..265a6c2 --- /dev/null +++ b/src/area/domain/errors/index.ts @@ -0,0 +1,2 @@ +export * from './state.errors'; +export * from './area.errors'; diff --git a/src/projects/domain/errors/state.errors.ts b/src/area/domain/errors/state.errors.ts similarity index 100% rename from src/projects/domain/errors/state.errors.ts rename to src/area/domain/errors/state.errors.ts diff --git a/src/area/domain/repository/area.repository.interface.ts b/src/area/domain/repository/area.repository.interface.ts new file mode 100644 index 0000000..8ba9233 --- /dev/null +++ b/src/area/domain/repository/area.repository.interface.ts @@ -0,0 +1,10 @@ +import type { Area, NewArea } from '../entities'; + +export interface IAreaRepository { + create(dto: NewArea): Promise<{ id: string }>; + update(projectId: string, areaId: string, dto: Partial): Promise; + delete(projectId: string, areaId: string): Promise; + findOne(projectId: string, areaId: string, includeDeleted?: boolean): Promise; + findAll(projectId: string, includeDeleted?: boolean): Promise; + findBySlug(projectId: string, slug: string): Promise; +} diff --git a/src/area/domain/repository/index.ts b/src/area/domain/repository/index.ts new file mode 100644 index 0000000..dcd31bd --- /dev/null +++ b/src/area/domain/repository/index.ts @@ -0,0 +1,2 @@ +export * from './states.repository.interface'; +export * from './area.repository.interface'; diff --git a/src/area/domain/repository/states.repository.interface.ts b/src/area/domain/repository/states.repository.interface.ts new file mode 100644 index 0000000..798b944 --- /dev/null +++ b/src/area/domain/repository/states.repository.interface.ts @@ -0,0 +1,15 @@ +import type { NewState, State } from '../entities'; + +export interface IStateRepository { + create(dto: NewState): Promise<{ id: string }>; + update(areaId: string, stateId: string, dto: Partial): Promise; + delete(areaId: string, stateId: string): Promise; + findOne(areaId: string, stateId: string, deleted?: boolean): Promise; + find(areaId: string, query?: unknown): Promise; + findByTitle(areaId: string, title: string): Promise; + findByType( + areaId: string, + type: 'custom' | 'archived' | 'backlog' | 'todo' | 'in_progress' | 'review' | 'done', + ): Promise; + countByArea(areaId: string): Promise; +} diff --git a/src/area/index.ts b/src/area/index.ts new file mode 100644 index 0000000..6f77033 --- /dev/null +++ b/src/area/index.ts @@ -0,0 +1 @@ +export * from './area.module'; diff --git a/src/area/infrastructure/persistence/models/area.model.ts b/src/area/infrastructure/persistence/models/area.model.ts new file mode 100644 index 0000000..4f2faf2 --- /dev/null +++ b/src/area/infrastructure/persistence/models/area.model.ts @@ -0,0 +1,88 @@ +import { + text, + boolean, + varchar, + timestamp, + integer, + uniqueIndex, + index, +} from 'drizzle-orm/pg-core'; +import { createId } from '@paralleldrive/cuid2'; +import { isNotNull, isNull } from 'drizzle-orm'; +import { stateCategoryEnum, stateTypeEnum } from './enum'; +import { baseSchema, projects, users } from '@shared/entities'; + +export const areas = baseSchema.table( + 'areas', + { + id: text('id') + .primaryKey() + .$defaultFn(() => createId()), + projectId: text('project_id').references(() => projects.id, { onDelete: 'cascade' }), + title: text('title').notNull(), + slug: varchar('slug', { length: 100 }).notNull().unique(), + description: text('description'), + descriptionHtml: text('description_html'), + color: varchar('color', { length: 10 }), + tasksCount: integer('tasks_count').notNull().default(0), + defaultView: varchar('default_view', { length: 20 }).notNull().default('kanban'), + icon: varchar('icon', { length: 20 }), + position: integer('position').notNull().default(0), + maxTasksLimit: integer('max_tasks_limit'), + isLocked: boolean('is_locked').default(false), + createdAt: timestamp('created_at', { withTimezone: true, mode: 'string' }) + .notNull() + .defaultNow(), + updatedAt: timestamp('updated_at', { withTimezone: true, mode: 'string' }) + .notNull() + .defaultNow(), + createdBy: text('created_by').references(() => users.id), + deletedAt: timestamp('deleted_at', { withTimezone: true, mode: 'string' }), + }, + (t) => ({ + slugIdx: index('idx_areas_slug').on(t.slug), + projectActiveIdx: index('idx_areas_project_active') + .on(t.projectId, t.position) + .where(isNull(t.deletedAt)), + createdByIdx: index('idx_areas_created_by').on(t.createdBy).where(isNull(t.deletedAt)), + deletedAtIdx: index('idx_areas_deleted_at').on(t.deletedAt).where(isNotNull(t.deletedAt)), + }), +); + +export const states = baseSchema.table( + 'states', + { + id: text('id') + .primaryKey() + .$defaultFn(() => createId()), + areaId: text('area_id').references(() => areas.id, { onDelete: 'cascade' }), + title: text('title').notNull(), + description: text('description'), + stateType: stateTypeEnum('state_type').notNull().default('custom'), + category: stateCategoryEnum('category').notNull().default('active'), + color: varchar('color', { length: 10 }), + icon: varchar('icon', { length: 20 }), + position: integer('position').notNull().default(0), + isVisible: boolean('is_visible').notNull().default(true), + maxTasksLimit: integer('max_tasks_limit'), + autoTransitionTo: text('auto_transition_to'), + notifyOnEnter: boolean('notify_on_enter').default(false), + notifyOnExit: boolean('notify_on_exit').default(false), + isLocked: boolean('is_locked').default(false), + createdAt: timestamp('created_at', { withTimezone: true, mode: 'string' }) + .notNull() + .defaultNow(), + updatedAt: timestamp('updated_at', { withTimezone: true, mode: 'string' }) + .notNull() + .defaultNow(), + createdBy: text('created_by').references(() => users.id), + deletedAt: timestamp('deleted_at', { withTimezone: true, mode: 'string' }), + }, + (t) => ({ + statePositionIdx: index('idx_states_position').on(t.areaId, t.position), + uniqueStateTitle: uniqueIndex('idx_states_unique_title') + .on(t.areaId, t.title) + .where(isNull(t.deletedAt)), + deletedAtIdx: index('idx_states_deleted_at').on(t.deletedAt).where(isNotNull(t.deletedAt)), + }), +); diff --git a/src/projects/infrastructure/persistence/models/enums.ts b/src/area/infrastructure/persistence/models/enum.ts similarity index 59% rename from src/projects/infrastructure/persistence/models/enums.ts rename to src/area/infrastructure/persistence/models/enum.ts index e4987c8..ba3e9d9 100644 --- a/src/projects/infrastructure/persistence/models/enums.ts +++ b/src/area/infrastructure/persistence/models/enum.ts @@ -17,15 +17,3 @@ export const stateCategoryEnum = baseSchema.enum('state_category', [ 'completed', 'archived', ]); - -export const projectStatusEnum = baseSchema.enum('project_status', [ - 'active', - 'archived', - 'template', -]); - -export const projectVisibilityEnum = baseSchema.enum('project_visibility', [ - 'public', - 'private', - 'team_only', -]); diff --git a/src/area/infrastructure/persistence/models/index.ts b/src/area/infrastructure/persistence/models/index.ts new file mode 100644 index 0000000..f8325d4 --- /dev/null +++ b/src/area/infrastructure/persistence/models/index.ts @@ -0,0 +1,2 @@ +export { areas, states } from './area.model'; +export { stateCategoryEnum, stateTypeEnum } from './enum'; diff --git a/src/area/infrastructure/persistence/repositories/area.repository.ts b/src/area/infrastructure/persistence/repositories/area.repository.ts new file mode 100644 index 0000000..31debbb --- /dev/null +++ b/src/area/infrastructure/persistence/repositories/area.repository.ts @@ -0,0 +1,100 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { DATABASE_SERVICE, DatabaseService } from '@libs/database'; +import * as schema from '../models'; +import { and, eq, isNotNull, isNull } from 'drizzle-orm'; +import { IAreaRepository } from '@core/area/domain/repository'; +import type { NewArea } from '@core/area/domain/entities'; + +@Injectable() +export class AreaRepository implements IAreaRepository { + constructor( + @Inject(DATABASE_SERVICE) + private readonly db: DatabaseService, + ) {} + + public async create(data: NewArea) { + const [result] = await this.db + .insert(schema.areas) + .values(data) + .returning({ id: schema.areas.id }); + + return result; + } + + public async update(projectId: string, areaId: string, data: Partial) { + const result = await this.db + .update(schema.areas) + .set({ ...data, updatedAt: new Date().toISOString() }) + .where( + and( + eq(schema.areas.id, areaId), + eq(schema.areas.projectId, projectId), + isNull(schema.areas.deletedAt), + ), + ); + + return (result.count ?? 0) > 0; + } + + public async delete(projectId: string, areaId: string) { + const result = await this.db + .update(schema.areas) + .set({ deletedAt: new Date().toISOString() }) + .where( + and( + eq(schema.areas.id, areaId), + eq(schema.areas.projectId, projectId), + isNull(schema.areas.deletedAt), + ), + ); + + return (result.count ?? 0) > 0; + } + + public async findOne(projectId: string, areaId: string, includeDeleted = false) { + const [result] = await this.db + .select() + .from(schema.areas) + .where( + and( + eq(schema.areas.id, areaId), + eq(schema.areas.projectId, projectId), + includeDeleted + ? isNotNull(schema.areas.deletedAt) + : isNull(schema.areas.deletedAt), + ), + ); + + return result ?? null; + } + + public async findAll(projectId: string, includeDeleted = false) { + return this.db + .select() + .from(schema.areas) + .where( + and( + eq(schema.areas.projectId, projectId), + includeDeleted + ? isNotNull(schema.areas.deletedAt) + : isNull(schema.areas.deletedAt), + ), + ) + .orderBy(schema.areas.position); + } + + public async findBySlug(projectId: string, slug: string) { + const [result] = await this.db + .select() + .from(schema.areas) + .where( + and( + eq(schema.areas.projectId, projectId), + eq(schema.areas.slug, slug), + isNull(schema.areas.deletedAt), + ), + ); + + return result ?? null; + } +} diff --git a/src/area/infrastructure/persistence/repositories/index.ts b/src/area/infrastructure/persistence/repositories/index.ts new file mode 100644 index 0000000..fb5a38f --- /dev/null +++ b/src/area/infrastructure/persistence/repositories/index.ts @@ -0,0 +1,13 @@ +import { StateRepository } from './state.repository'; +import { AreaRepository } from './area.repository'; + +export const REPOSITORIES = [ + { + provide: 'IAreaRepository', + useClass: AreaRepository, + }, + { + provide: 'IStateRepository', + useClass: StateRepository, + }, +]; diff --git a/src/area/infrastructure/persistence/repositories/state.repository.ts b/src/area/infrastructure/persistence/repositories/state.repository.ts new file mode 100644 index 0000000..103c240 --- /dev/null +++ b/src/area/infrastructure/persistence/repositories/state.repository.ts @@ -0,0 +1,115 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { DATABASE_SERVICE, DatabaseService } from '@libs/database'; +import * as schema from '../models'; +import { and, count, eq, isNotNull, isNull } from 'drizzle-orm'; +import { IStateRepository } from '@core/area/domain/repository'; +import type { NewState } from '@core/area/domain/entities'; + +@Injectable() +export class StateRepository implements IStateRepository { + constructor( + @Inject(DATABASE_SERVICE) + private readonly db: DatabaseService, + ) {} + + public async create(data: NewState) { + const [result] = await this.db + .insert(schema.states) + .values(data) + .returning({ id: schema.states.id }); + + return result; + } + + public async delete(areaId: string, stateId: string) { + const result = await this.db + .delete(schema.states) + .where( + and( + eq(schema.states.id, stateId), + eq(schema.states.areaId, areaId), + isNotNull(schema.states.deletedAt), + ), + ); + + return (result.count ?? 0) > 0; + } + + public async find(query: unknown) { + void query; + return this.db.select().from(schema.states); + } + + public async findOne(areaId: string, stateId: string, deleted?: boolean) { + const [result] = await this.db + .select() + .from(schema.states) + .where( + and( + eq(schema.states.id, stateId), + eq(schema.states.areaId, areaId), + deleted ? isNotNull(schema.states.deletedAt) : isNull(schema.states.deletedAt), + ), + ); + + return result ?? null; + } + + public async update(areaId: string, stateId: string, data: Partial) { + const result = await this.db + .update(schema.states) + .set(data) + .where( + and( + eq(schema.states.id, stateId), + eq(schema.states.areaId, areaId), + isNull(schema.states.deletedAt), + ), + ); + + return (result.count ?? 0) > 0; + } + + public async findByType( + areaId: string, + // TODO: ADD BASE ENUM TOO + stateType: 'custom' | 'archived' | 'backlog' | 'todo' | 'in_progress' | 'review' | 'done', + ) { + const [result] = await this.db + .select() + .from(schema.states) + .where( + and( + eq(schema.states.areaId, areaId), + eq(schema.states.stateType, stateType), + isNull(schema.states.deletedAt), + ), + ); + + return result ?? null; + } + + public async findByTitle(areaId: string, title: string) { + const [result] = await this.db + .select() + .from(schema.states) + .where( + and( + eq(schema.states.areaId, areaId), + eq(schema.states.title, title), + isNull(schema.states.deletedAt), + ), + ); + + return result ?? null; + } + + public async countByArea(areaId: string) { + const [result] = await this.db + .select({ count: count() }) + .from(schema.states) + .where(and(eq(schema.states.areaId, areaId), isNull(schema.states.deletedAt))); + + return result.count; + } +} diff --git a/src/auth/application/controller/oauth/controller.ts b/src/auth/application/controller/oauth/controller.ts index 0c50e77..a7064d0 100644 --- a/src/auth/application/controller/oauth/controller.ts +++ b/src/auth/application/controller/oauth/controller.ts @@ -12,7 +12,7 @@ import type { FastifyReply, FastifyRequest } from 'fastify'; import { BearerAuthGuard, OAuthGuard } from '@shared/guards'; import { AuthFacade } from '../../auth.facade'; import { getDeviceMeta } from '@core/auth/infrastructure/utils'; -import { ApiBaseController, GetUserId, SkipZodValidation } from '@shared/decorators'; +import { ApiBaseController, GetUserId, SkipContractHandle } from '@shared/decorators'; import { ConfigService } from '@nestjs/config'; @ApiBaseController('auth/oauth', 'OAuth') @@ -31,13 +31,13 @@ export class OAuthController { @Get(':provider') @OAuthLoginSwagger() @UseGuards(OAuthGuard) - @SkipZodValidation() + @SkipContractHandle() async oauthLogin() {} @Get(':provider/callback') @OAuthCallbackSwagger() @UseGuards(OAuthGuard) - @SkipZodValidation() + @SkipContractHandle() async oauthCallback( @Query() query: { code?: string; state?: string }, @Param('provider') provider: 'google' | 'yandex' | 'github' | 'vkontakte', diff --git a/src/projects/application/controller/index.ts b/src/projects/application/controller/index.ts index 8df61cd..ab51196 100644 --- a/src/projects/application/controller/index.ts +++ b/src/projects/application/controller/index.ts @@ -1,4 +1,4 @@ -import { ProjectsStatesController } from './states/controller'; import { ProjectsController } from './projects/controller'; +import { ProjectMembersController } from './members/controller'; -export const CONTROLLERS = [ProjectsController, ProjectsStatesController]; +export const CONTROLLERS = [ProjectsController, ProjectMembersController]; diff --git a/src/projects/application/controller/members/controller.ts b/src/projects/application/controller/members/controller.ts new file mode 100644 index 0000000..654eb81 --- /dev/null +++ b/src/projects/application/controller/members/controller.ts @@ -0,0 +1,64 @@ +import { Body, Delete, Get, Param, Post, Put, Query } from '@nestjs/common'; +import { ApiBaseController, GetUserId, SkipContractHandle } from '@shared/decorators'; +import { ProjectFacade } from '../../project.facade'; +import { AddProjectMemberDto, UpdateProjectMemberDto } from '../../dtos'; +import { + AddMemberSwagger, + FindAllMembersSwagger, + FindAvailableUsersSwagger, + RemoveMemberSwagger, + UpdateMemberSwagger, +} from './swagger'; + +@ApiBaseController('projects/:slug/members', 'Project Members', true) +export class ProjectMembersController { + constructor(private readonly facade: ProjectFacade) {} + + @Get() + @FindAllMembersSwagger() + async findAll(@Param('slug') slug: string, @GetUserId() userId: string) { + return this.facade.getMembers(slug, userId); + } + + @Post() + @AddMemberSwagger() + async addMember( + @Param('slug') slug: string, + @GetUserId() userId: string, + @Body() dto: AddProjectMemberDto, + ) { + return this.facade.addMember(slug, userId, dto); + } + + @Put(':memberId') + @UpdateMemberSwagger() + async updateMember( + @Param('slug') slug: string, + @Param('memberId') memberId: string, + @GetUserId() userId: string, + @Body() dto: UpdateProjectMemberDto, + ) { + return this.facade.updateMemberRole(slug, memberId, userId, dto); + } + + @Delete(':memberId') + @RemoveMemberSwagger() + async removeMember( + @Param('slug') slug: string, + @Param('memberId') memberId: string, + @GetUserId() userId: string, + ) { + return this.facade.removeMember(slug, memberId, userId); + } + + @Get('available') + @SkipContractHandle() + @FindAvailableUsersSwagger() + async getAvailableUsers( + @Param('slug') slug: string, + @GetUserId() userId: string, + @Query('search') search?: string, + ) { + return this.facade.getAvailableTeamMembers(slug, userId, search); + } +} diff --git a/src/projects/application/controller/members/swagger.ts b/src/projects/application/controller/members/swagger.ts new file mode 100644 index 0000000..eff5736 --- /dev/null +++ b/src/projects/application/controller/members/swagger.ts @@ -0,0 +1,180 @@ +import { applyDecorators, SetMetadata } from '@nestjs/common'; +import { ApiBody, ApiOperation, ApiParam, ApiQuery, ApiResponse } from '@nestjs/swagger'; +import { ActionResponse } from '@shared/dtos'; +import { ApiForbidden, ApiNotFound, ApiUnauthorized, ApiValidationError } from '@shared/error'; +import { ZOD_RESPONSE_TOKEN } from '@shared/interceptors'; +import { AddProjectMemberDto, ListMembersResponse, UpdateProjectMemberDto } from '../../dtos'; + +export const FindAllMembersSwagger = () => + applyDecorators( + ApiOperation({ + summary: 'Получить список участников проекта', + description: [ + 'Возвращает всех активных участников проекта с их ролями.', + 'Доступно участникам с любой ролью, включая viewer.', + ].join('\n\n'), + }), + ApiParam({ + name: 'slug', + description: 'Slug проекта', + type: 'string', + example: 'my-project', + }), + ApiResponse({ + status: 200, + description: 'Список участников получен', + type: ListMembersResponse.Output, + }), + ApiNotFound('Проект не найден'), + ApiForbidden('У вас нет доступа к этому проекту'), + ApiUnauthorized(), + + SetMetadata(ZOD_RESPONSE_TOKEN, ListMembersResponse), + ); + +export const FindAvailableUsersSwagger = () => + applyDecorators( + ApiOperation({ + deprecated: true, + summary: 'Получить список пользователей, доступных для добавления', + description: [ + 'Возвращает членов команды, которых еще нет в проекте.', + 'Полезно для поиска при добавлении новых участников.', + 'Поддерживает поиск по email и имени.', + 'Требуется роль owner или admin.', + ].join('\n\n'), + }), + ApiParam({ + name: 'slug', + description: 'Slug проекта', + type: 'string', + example: 'my-project', + }), + ApiQuery({ + name: 'search', + description: 'Поиск по email или имени пользователя', + type: 'string', + required: false, + example: 'ivan', + }), + ApiResponse({ + status: 200, + description: 'Список доступных пользователей', + type: class B {}, + // type: AvailableUsersResponse.Output, + }), + ApiNotFound('Проект не найден'), + ApiForbidden('Недостаточно прав (требуется owner или admin)'), + ApiUnauthorized(), + + SetMetadata(ZOD_RESPONSE_TOKEN, null), + ); + +export const AddMemberSwagger = () => + applyDecorators( + ApiOperation({ + summary: 'Добавить участника в проект', + description: [ + 'Добавляет пользователя из команды в проект с указанной ролью.', + 'Нельзя добавить пользователя, который уже является участником.', + 'Нельзя назначить роль owner через этот метод.', + 'Требуется роль owner или admin.', + ].join('\n\n'), + }), + ApiParam({ + name: 'slug', + description: 'Slug проекта', + type: 'string', + example: 'my-project', + }), + ApiBody({ + type: AddProjectMemberDto.Output, + description: 'Данные для добавления участника', + }), + ApiResponse({ + status: 201, + description: 'Участник успешно добавлен', + type: ActionResponse.Output, + }), + ApiValidationError('Некорректные данные (несуществующий userId или роль)'), + ApiNotFound('Проект не найден'), + ApiForbidden('Недостаточно прав (требуется owner или admin)'), + ApiUnauthorized(), + + SetMetadata(ZOD_RESPONSE_TOKEN, ActionResponse), + ); + +export const UpdateMemberSwagger = () => + applyDecorators( + ApiOperation({ + summary: 'Изменить роль участника', + description: [ + 'Изменяет роль существующего участника проекта.', + 'Нельзя изменить роль владельца (owner).', + 'Нельзя назначить роль owner через этот метод.', + 'Требуется роль owner или admin.', + ].join('\n\n'), + }), + ApiParam({ + name: 'slug', + description: 'Slug проекта', + type: 'string', + example: 'my-project', + }), + ApiParam({ + name: 'memberId', + description: 'ID записи участника (не userId!)', + type: 'string', + example: 'clv123456', + }), + ApiBody({ + type: UpdateProjectMemberDto.Output, + description: 'Новая роль участника', + }), + ApiResponse({ + status: 200, + description: 'Роль успешно обновлена', + type: ActionResponse.Output, + }), + ApiValidationError('Некорректная роль'), + ApiNotFound('Участник не найден в проекте'), + ApiForbidden('Недостаточно прав или попытка изменить владельца'), + ApiUnauthorized(), + + SetMetadata(ZOD_RESPONSE_TOKEN, ActionResponse), + ); + +export const RemoveMemberSwagger = () => + applyDecorators( + ApiOperation({ + summary: 'Удалить участника из проекта', + description: [ + 'Удаляет участника из проекта.', + 'Нельзя удалить владельца (owner).', + 'Участник может удалить себя сам (покинуть проект), даже если у него нет роли admin.', + 'Требуется роль owner/admin, либо это действие над собой.', + ].join('\n\n'), + }), + ApiParam({ + name: 'slug', + description: 'Slug проекта', + type: 'string', + example: 'my-project', + }), + ApiParam({ + name: 'memberId', + description: 'ID записи участника (не userId!)', + type: 'string', + example: 'clv123456', + }), + ApiResponse({ + status: 200, + description: 'Участник удален из проекта', + type: ActionResponse.Output, + }), + ApiNotFound('Участник не найден в проекте'), + ApiForbidden('Недостаточно прав или попытка удалить владельца'), + ApiUnauthorized(), + + SetMetadata(ZOD_RESPONSE_TOKEN, ActionResponse), + ); diff --git a/src/projects/application/controller/projects/controller.ts b/src/projects/application/controller/projects/controller.ts index d24bafb..0410059 100644 --- a/src/projects/application/controller/projects/controller.ts +++ b/src/projects/application/controller/projects/controller.ts @@ -2,6 +2,7 @@ import { ApiBaseController, GetUserId, Public } from '@shared/decorators'; import { Body, Delete, Get, Param, Patch, Post, Query } from '@nestjs/common'; import { ArchiveProjectSwagger, + CheckSlugSwagger, CreateProjectSwagger, CreateShareTokenSwagger, FindAllProjectsSwagger, @@ -10,12 +11,11 @@ import { UpdateProjectSwagger, } from './swagger'; import { CreateProjectDto, CreateShareTokenDto, UpdateProjectDto } from '../../dtos'; -import { ProjectStatus } from '@core/projects/domain/entities'; -import { ProjectsFacade } from '../../projects.facade'; +import { ProjectFacade } from '../../project.facade'; @ApiBaseController('teams/:teamId/projects', 'Projects', true) export class ProjectsController { - constructor(private readonly facade: ProjectsFacade) {} + constructor(private readonly facade: ProjectFacade) {} @Get() @FindAllProjectsSwagger() @@ -46,6 +46,12 @@ export class ProjectsController { return this.facade.generateShareToken(slug, teamId, userId, dto); } + @Get('check-slug') + @CheckSlugSwagger() + async checkSlug(@Param('teamId') teamId: string, @Query('q') slug: string) { + return this.facade.checkSlugAvailability(teamId, slug); + } + @Post(':slug/archive') @ArchiveProjectSwagger() async archive( @@ -53,7 +59,7 @@ export class ProjectsController { @Param('teamId') teamId: string, @GetUserId() userId: string, ) { - return this.facade.setStatus(slug, teamId, userId, ProjectStatus.Archived); + return this.facade.setStatus(slug, teamId, userId, 'archived'); } @Post() diff --git a/src/projects/application/controller/projects/swagger.ts b/src/projects/application/controller/projects/swagger.ts index 16d65bc..8f36721 100644 --- a/src/projects/application/controller/projects/swagger.ts +++ b/src/projects/application/controller/projects/swagger.ts @@ -1,7 +1,13 @@ import { applyDecorators, SetMetadata } from '@nestjs/common'; -import { ApiOperation, ApiBody, ApiResponse, ApiParam } from '@nestjs/swagger'; +import { ApiOperation, ApiBody, ApiResponse, ApiParam, ApiQuery } from '@nestjs/swagger'; import { ActionResponse } from '@shared/dtos'; -import { ApiValidationError, ApiUnauthorized, ApiForbidden, ApiNotFound } from '@shared/error'; +import { + ApiValidationError, + ApiUnauthorized, + ApiForbidden, + ApiNotFound, + ApiConflict, +} from '@shared/error'; import { CreateProjectDto, CreateProjectResponse, @@ -11,34 +17,84 @@ import { ProjectDetailResponse, } from '../../dtos'; import { ZOD_RESPONSE_TOKEN } from '@shared/interceptors'; -import { CreateShareTokenResponse } from '@core/projects/application/dtos/projects.dto'; +import { + CheckSlugResponse, + CreateShareTokenResponse, +} from '@core/projects/application/dtos/project.dto'; export const CreateProjectSwagger = () => applyDecorators( - ApiOperation({ summary: 'Создать новый проект в команде' }), - ApiParam({ name: 'teamId', type: 'string' }), - ApiBody({ type: CreateProjectDto.Output }), + ApiOperation({ + summary: 'Создать новый проект в команде', + description: [ + 'Создает проект с указанным названием и настройками.', + 'Slug генерируется автоматически из названия, если не передан явно.', + 'Создатель автоматически становится владельцем (owner) проекта.', + 'Настройки (settings) необязательны — если не переданы, создаются дефолтные.', + 'Требуется роль admin или owner в команде.', + ].join('\n\n'), + }), + ApiParam({ + name: 'teamId', + description: 'ID команды, в которой создается проект', + type: 'string', + example: 'clv123456', + }), + ApiBody({ + type: CreateProjectDto.Output, + description: 'Данные проекта. Slug опционален — если не указан, генерируется из name.', + }), ApiResponse({ status: 201, description: 'Проект успешно создан', type: CreateProjectResponse.Output, }), - ApiValidationError(), + ApiConflict('Проект с таким slug уже существует в команде'), + ApiValidationError('Некорректные данные (slug, цвет, название)'), + ApiForbidden('Недостаточно прав или достигнут лимит проектов'), ApiUnauthorized(), - ApiForbidden(), SetMetadata(ZOD_RESPONSE_TOKEN, CreateProjectResponse), ); export const FindAllProjectsSwagger = () => applyDecorators( - ApiOperation({ summary: 'Получить список всех проектов команды' }), - ApiParam({ name: 'teamId', type: 'string' }), + ApiOperation({ + summary: 'Получить список проектов команды', + description: [ + 'Возвращает все проекты, доступные пользователю в рамках команды.', + 'Включает как публичные проекты, так и те, где пользователь участник.', + 'Архивированные и удаленные проекты не возвращаются.', + 'Сортировка по полю sequence (по возрастанию).', + ].join('\n\n'), + }), + ApiParam({ + name: 'teamId', + description: 'ID команды', + type: 'string', + example: 'clv123456', + }), + ApiQuery({ + name: 'search', + description: 'Поиск по названию проекта', + type: 'string', + required: false, + example: 'маркетинг', + }), + ApiQuery({ + name: 'status', + description: 'Фильтр по статусу проекта', + type: 'string', + required: false, + enum: ['active', 'archived'], + example: 'active', + }), ApiResponse({ status: 200, description: 'Список проектов получен', type: ProjectListResponse.Output, }), + ApiForbidden('У вас нет доступа к этой команде'), ApiUnauthorized(), SetMetadata(ZOD_RESPONSE_TOKEN, ProjectListResponse), @@ -46,64 +102,175 @@ export const FindAllProjectsSwagger = () => export const FindOneProjectSwagger = () => applyDecorators( - ApiOperation({ summary: 'Получить детальную информацию о проекте' }), + ApiOperation({ + summary: 'Получить детальную информацию о проекте', + description: [ + 'Возвращает полную информацию о проекте, включая:', + '- Основные поля (название, описание, статус)', + '- Визуальные настройки (цвет, иконка)', + '- Мета-информацию (счетчики, даты)', + '- Права доступа текущего пользователя', + '- Настройки проекта', + '- Информацию о команде и владельце', + '', + 'Проект должен принадлежать указанной команде.', + 'Пользователь должен иметь доступ к проекту (быть участником или проект публичный).', + ].join('\n'), + }), ApiParam({ - name: 'id', - description: 'CUID проекта', + name: 'teamId', + description: 'ID команды', type: 'string', example: 'clv123456', }), - ApiResponse({ status: 200, type: ProjectDetailResponse.Output }), - ApiNotFound('Проект не найден'), + ApiParam({ + name: 'slug', + description: 'Slug проекта (URL-идентификатор)', + type: 'string', + example: 'my-project', + }), + ApiResponse({ + status: 200, + description: 'Детальная информация о проекте', + type: ProjectDetailResponse.Output, + }), + ApiNotFound('Проект не найден в этой команде'), + ApiForbidden('У вас нет доступа к этому проекту'), ApiUnauthorized(), SetMetadata(ZOD_RESPONSE_TOKEN, ProjectDetailResponse), ); - export const UpdateProjectSwagger = () => applyDecorators( - ApiOperation({ summary: 'Обновить информацию о проекте' }), + ApiOperation({ + summary: 'Обновить информацию о проекте', + description: [ + 'Частичное обновление проекта — можно передать только те поля, которые нужно изменить.', + 'Если поле не передано — оно остается без изменений.', + 'Для сброса опциональных полей (description, icon, color) передайте null.', + '', + 'Особенности обновления slug:', + '- Slug должен быть уникальным в рамках команды', + '- При смене slug старые ссылки на проект становятся невалидными', + '', + 'Обновление настроек (settings):', + '- Если settings передан — обновляются только указанные поля', + '- Не переданные поля настроек остаются без изменений', + '', + 'Требуется роль owner или admin в проекте.', + ].join('\n'), + }), ApiParam({ - name: 'id', - description: 'CUID проекта', + name: 'teamId', + description: 'ID команды', type: 'string', example: 'clv123456', }), - ApiBody({ type: UpdateProjectDto.Output }), - ApiResponse({ status: 200, description: 'Проект обновлен', type: ActionResponse.Output }), - ApiValidationError(), - ApiNotFound(), + ApiParam({ + name: 'slug', + description: 'Текущий slug проекта', + type: 'string', + example: 'my-project', + }), + ApiBody({ + type: UpdateProjectDto.Output, + description: 'Поля для обновления. Все поля опциональны.', + }), + ApiResponse({ + status: 200, + description: 'Проект успешно обновлен', + type: ActionResponse.Output, + }), + ApiConflict('Проект с таким slug уже существует'), + ApiValidationError('Некорректные данные'), + ApiNotFound('Проект не найден'), + ApiForbidden('Недостаточно прав'), ApiUnauthorized(), SetMetadata(ZOD_RESPONSE_TOKEN, ActionResponse), ); -export const RemoveProjectSwagger = () => +export const ArchiveProjectSwagger = () => applyDecorators( - ApiOperation({ summary: 'Архивировать (удалить) проект' }), + ApiOperation({ + summary: 'Архивировать проект', + description: [ + 'Переводит проект в статус "archived".', + 'Архивный проект:', + '- Не отображается в общем списке проектов', + '- Нельзя создавать новые задачи', + '- Нельзя изменять существующие задачи', + '- Можно только просматривать', + '', + 'Перед архивацией проверяется отсутствие активных задач.', + 'Если в проекте есть незавершенные задачи — архивация будет отклонена.', + '', + 'Требуется роль owner или admin в проекте.', + ].join('\n'), + }), ApiParam({ - name: 'id', - description: 'CUID проекта', + name: 'teamId', + description: 'ID команды', type: 'string', example: 'clv123456', }), - ApiResponse({ status: 200, description: 'Проект удален', type: ActionResponse.Output }), - ApiNotFound(), + ApiParam({ + name: 'slug', + description: 'Slug проекта', + type: 'string', + example: 'my-project', + }), + ApiResponse({ + status: 200, + description: 'Проект архивирован', + type: ActionResponse.Output, + }), + ApiConflict('Проект уже в архиве или есть активные задачи'), + ApiNotFound('Проект не найден'), + ApiForbidden('Недостаточно прав'), ApiUnauthorized(), SetMetadata(ZOD_RESPONSE_TOKEN, ActionResponse), ); -export const ArchiveProjectSwagger = () => +export const RemoveProjectSwagger = () => applyDecorators( - ApiOperation({ summary: 'Перевести проект в статус архива' }), + ApiOperation({ + summary: 'Удалить проект (в корзину)', + description: [ + 'Мягкое удаление — проект перемещается в корзину (статус "deleted").', + 'Проект в корзине:', + '- Не отображается нигде, кроме корзины', + '- Недоступен для любых операций', + '- Может быть восстановлен в течение 30 дней', + '- Через 30 дней удаляется безвозвратно', + '', + 'Перед удалением проект должен быть архивирован.', + 'Нельзя удалить активный проект напрямую.', + '', + 'Требуется роль owner в проекте.', + ].join('\n'), + }), ApiParam({ - name: 'id', - description: 'CUID проекта', + name: 'teamId', + description: 'ID команды', type: 'string', example: 'clv123456', }), - ApiResponse({ status: 200, description: 'Статус обновлен', type: ActionResponse.Output }), + ApiParam({ + name: 'slug', + description: 'Slug проекта', + type: 'string', + example: 'my-project', + }), + ApiResponse({ + status: 200, + description: 'Проект перемещен в корзину', + type: ActionResponse.Output, + }), + ApiConflict('Проект не архивирован или уже в корзине'), + ApiNotFound('Проект не найден'), + ApiForbidden('Недостаточно прав (требуется owner)'), ApiUnauthorized(), SetMetadata(ZOD_RESPONSE_TOKEN, ActionResponse), @@ -111,10 +278,30 @@ export const ArchiveProjectSwagger = () => export const GetProjectByTokenSwagger = () => applyDecorators( - ApiOperation({ summary: 'Получить проект по публичному токену' }), - ApiParam({ name: 'token', description: 'Токен доступа', type: 'string' }), - ApiResponse({ status: 200, type: ProjectDetailResponse.Output }), - ApiNotFound('Токен недействителен'), + ApiOperation({ + summary: 'Получить проект по публичной ссылке', + description: [ + 'Позволяет получить доступ к проекту без авторизации — по share-токену.', + 'Токен имеет ограниченный срок действия.', + 'Возвращает ту же структуру что и FindOne, но с ограниченными правами.', + '', + 'Если токен истек — вернется 404.', + 'По share-ссылке доступен только просмотр, нельзя редактировать.', + ].join('\n'), + }), + ApiParam({ + name: 'token', + description: 'Публичный токен доступа к проекту', + type: 'string', + example: 'st_a1b2c3d4e5f6...', + }), + ApiResponse({ + status: 200, + description: 'Проект получен по токену', + type: ProjectDetailResponse.Output, + }), + ApiNotFound('Токен недействителен или истек'), + ApiForbidden('Доступ по токену запрещен (проект приватный)'), SetMetadata(ZOD_RESPONSE_TOKEN, ProjectDetailResponse), ); @@ -122,34 +309,78 @@ export const GetProjectByTokenSwagger = () => export const CreateShareTokenSwagger = () => applyDecorators( ApiOperation({ - summary: 'Сгенерировать публичную ссылку', - description: - 'Создает защищенный токен доступа к проекту. Если expiresAt не указан, по умолчанию ставится доступ на 3 месяца.', + summary: 'Создать публичную ссылку на проект', + description: [ + 'Генерирует уникальный токен для публичного доступа к проекту.', + 'Токен можно передавать кому угодно — по нему открывается проект в режиме чтения.', + '', + 'Срок действия:', + '- Если ttl не указан — токен действует 3 месяца (по умолчанию)', + '- Если ttl указан — токен действует до указанной даты', + '- ttl не может быть в прошлом', + '', + 'Безопасность:', + '- Токен хешируется в БД (SHA-256), сырой токен показывается только при создании', + '- Сохраните сырой токен сразу — потом его нельзя восстановить', + '- Можно отозвать токен, удалив его из проекта', + '', + 'Требуется роль owner или admin в проекте.', + ].join('\n'), }), ApiParam({ name: 'teamId', description: 'ID команды', type: 'string', + example: 'clv123456', }), ApiParam({ - name: 'id', - description: 'CUID проекта', + name: 'slug', + description: 'Slug проекта', type: 'string', - example: 'clv123456', + example: 'my-project', }), ApiBody({ type: CreateShareTokenDto.Output, - description: 'Настройки срока действия ссылки', + description: 'Настройки срока действия. ttl опционален.', }), ApiResponse({ status: 201, - description: 'Токен успешно создан', + description: 'Токен создан', type: CreateShareTokenResponse.Output, }), - ApiNotFound('Проект не найден в этой команде'), - ApiValidationError('Некорректная дата или параметры'), + ApiValidationError('Некорректная дата (ttl в прошлом или невалидный формат)'), + ApiNotFound('Проект не найден'), + ApiForbidden('Недостаточно прав'), ApiUnauthorized(), - ApiForbidden('У вас нет прав для создания ссылки для этого проекта'), SetMetadata(ZOD_RESPONSE_TOKEN, CreateShareTokenResponse), ); + +export const CheckSlugSwagger = () => + applyDecorators( + ApiOperation({ + summary: 'Проверить доступность slug', + description: [ + 'Проверяет, свободен ли slug для создания нового проекта.', + 'Slug глобален — проверка по всем проектам, а не только внутри команды.', + '', + 'Формат slug: строчные латинские буквы, цифры и дефисы (kebab-case).', + 'Пример: `my-project`, `marketing-2024`, `backend`', + ].join('\n'), + }), + ApiQuery({ + name: 'q', + description: 'Slug для проверки', + type: 'string', + required: true, + example: 'my-project', + }), + ApiResponse({ + status: 200, + description: 'Результат проверки', + type: CheckSlugResponse.Output, + }), + ApiUnauthorized(), + + SetMetadata(ZOD_RESPONSE_TOKEN, CheckSlugResponse), + ); diff --git a/src/projects/application/dtos/index.ts b/src/projects/application/dtos/index.ts index 0fe3411..3ecf3c6 100644 --- a/src/projects/application/dtos/index.ts +++ b/src/projects/application/dtos/index.ts @@ -1,2 +1,3 @@ -export * from './projects.dto'; -export * from './states.dto'; +export * from './project.dto'; +export * from './settings.dto'; +export * from './member.dto'; diff --git a/src/projects/application/dtos/member.dto.ts b/src/projects/application/dtos/member.dto.ts new file mode 100644 index 0000000..aab5ae9 --- /dev/null +++ b/src/projects/application/dtos/member.dto.ts @@ -0,0 +1,66 @@ +import { z } from 'zod/v4'; +import { createZodDto } from 'nestjs-zod'; +import { createPaginationSchema } from '@shared/schemas'; + +export const ProjectMemberRoleSchema = z.enum(['owner', 'admin', 'member', 'viewer']); +export type ProjectMemberRole = z.infer; + +export const ProjectMemberSchema = z + .object({ + id: z + .string() + .min(1, 'ID не может быть пустым') + .describe('Уникальный идентификатор записи'), + projectId: z.string().min(1, 'ID проекта обязателен').describe('ID проекта'), + userId: z + .string() + .min(1, 'ID пользователя обязателен') + .describe('ID пользователя — участника проекта'), + role: ProjectMemberRoleSchema.default('member').describe('Роль участника в проекте'), + addedBy: z + .string() + .nullable() + .optional() + .describe('ID пользователя, который добавил участника'), + createdAt: z.string().datetime({ offset: true }).describe('Дата добавления в проект'), + }) + .describe('Участник проекта'); + +export const MemberUserSchema = z.object({ + id: z.string().describe('ID пользователя'), + email: z.string().email().describe('Email'), + firstName: z.string().nullable().optional().describe('Имя'), + lastName: z.string().nullable().optional().describe('Фамилия'), + avatarUrl: z.string().nullable().optional().describe('URL аватара'), +}); + +const MemberResponseSchema = ProjectMemberSchema.omit({ + userId: true, + projectId: true, +}).extend({ + user: MemberUserSchema, +}); + +export const ProjectMemberListResponseSchema = createPaginationSchema(MemberResponseSchema); + +export const AddProjectMemberSchema = z + .object({ + userId: z + .string() + .min(1, 'ID пользователя обязателен') + .describe('ID пользователя для добавления в проект'), + role: ProjectMemberRoleSchema.exclude(['owner']) + .default('member') + .describe('Роль нового участника'), + }) + .describe('Схема для добавления участника'); + +export const UpdateProjectMemberSchema = z + .object({ + role: ProjectMemberRoleSchema.exclude(['owner']).describe('Новая роль участника'), + }) + .describe('Схема для изменения роли участника'); + +export class AddProjectMemberDto extends createZodDto(AddProjectMemberSchema) {} +export class UpdateProjectMemberDto extends createZodDto(UpdateProjectMemberSchema) {} +export class ListMembersResponse extends createZodDto(ProjectMemberListResponseSchema) {} diff --git a/src/projects/application/dtos/project.dto.ts b/src/projects/application/dtos/project.dto.ts new file mode 100644 index 0000000..a0bb334 --- /dev/null +++ b/src/projects/application/dtos/project.dto.ts @@ -0,0 +1,226 @@ +import { z } from 'zod/v4'; +import { createZodDto } from 'nestjs-zod'; +import { ActionResponseSchema } from '@shared/dtos'; +import { createPaginationSchema } from '@shared/schemas'; +import { PROJECT_STATUSES, PROJECT_VISIBILITIES } from '@core/projects/domain/entities'; +import { CreateProjectSettingsSchema, ProjectSettingsSchema } from './settings.dto'; +import { ProjectMemberRoleSchema } from './member.dto'; + +export const ProjectStatusSchema = z.enum(PROJECT_STATUSES); +export const ProjectVisibilitySchema = z.enum(PROJECT_VISIBILITIES); +export const ProjectTypeSchema = z.enum(['team', 'personal']); + +export const ProjectSchema = z.object({ + id: z.string().min(1, 'ID не может быть пустым').describe('Уникальный идентификатор проекта'), + teamId: z.string().nullish().describe('ID команды (null для личных проектов)'), + slug: z + .string() + .min(1, 'Slug обязателен') + .max(100, 'Slug не должен превышать 100 символов') + .regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/, 'Slug должен быть в формате kebab-case') + .describe('URL-дружественный идентификатор проекта'), + name: z + .string() + .min(1, 'Название проекта обязательно') + .max(100, 'Название не должно превышать 100 символов') + .describe('Отображаемое название проекта'), + description: z.string().nullish().describe('Markdown-описание проекта, его целей и правил'), + descriptionHtml: z.string().nullish().describe('Сгенерированный HTML из Markdown описания'), + icon: z + .string() + .max(255, 'Иконка должна быть не длиннее 255 символов') + .nullish() + .describe('Emoji или иконка проекта (например: "🚀", "💼", "🎯")'), + color: z + .string() + .regex( + /^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$/, + 'Цвет должен быть в HEX формате (#RRGGBB или #RGB)', + ) + .nullish() + .describe('HEX-код цвета для визуального выделения проекта'), + status: ProjectStatusSchema.default('active').describe('Текущий статус проекта'), + // type: ProjectTypeSchema.default('team').describe('Тип проекта: командный или личный'), + sequence: z + .number() + .int('Порядковый номер должен быть целым числом') + .min(0, 'Порядковый номер не может быть отрицательным') + .default(0) + .describe('Порядок отображения проекта в списке (меньше число — выше)'), + ownerId: z.string().nullish().describe('ID создателя/владельца проекта'), + visibility: ProjectVisibilitySchema.default('public').describe( + 'Видимость проекта для участников команды', + ), + createdAt: z + .string() + .datetime({ offset: true }) + .describe('Дата и время создания проекта (ISO 8601)'), + updatedAt: z + .string() + .datetime({ offset: true }) + .describe('Дата и время последнего обновления проекта'), + deletedAt: z + .string() + .datetime({ offset: true }) + .nullish() + .describe('Дата мягкого удаления (null — не удалено)'), +}); + +export const CreateProjectSchema = ProjectSchema.omit({ + id: true, + createdAt: true, + updatedAt: true, + deletedAt: true, + ownerId: true, +}) + .partial({ + description: true, + descriptionHtml: true, + icon: true, + color: true, + sequence: true, + visibility: true, + slug: true, + }) + .extend({ + settings: CreateProjectSettingsSchema.optional().describe('Настройки проекта'), + }) + .describe('Схема для создания нового проекта'); + +const CreateProjectsResponseSchema = ActionResponseSchema.extend({ + slug: z.string().describe('Уникальный идентификатор проекта в системе'), +}); + +export const CheckSlugResponseSchema = z + .object({ + available: z + .boolean() + .describe('Доступен ли slug. true — свободен, false — занят или невалидный'), + reason: z + .string() + .nullish() + .describe( + 'Причина недоступности. null если slug свободен. ' + + 'Возможные значения: "Этот slug уже занят", ' + + '"Недопустимый формат. Только строчные латинские буквы, цифры и дефисы"', + ), + }) + .describe('Результат проверки доступности slug'); + +export const UpdateProjectSchema = CreateProjectSchema.partial() + .refine((data) => Object.keys(data).length > 0, { + error: 'Необходимо передать хотя бы одно поле для обновления', + abort: true, + }) + .describe('Схема для обновления существующего проекта'); + +export const TransferProjectSchema = z + .object({ + teamId: z.string().min(1, 'ID команды обязателен').describe('ID новой команды для проекта'), + }) + .describe('Схема для переноса проекта в другую команду'); + +export const ProjectFilterSchema = z + .object({ + status: ProjectStatusSchema.optional(), + type: ProjectTypeSchema.optional(), + visibility: ProjectVisibilitySchema.optional(), + search: z.string().min(1).max(100).optional(), + teamId: z.string().optional(), + }) + .partial() + .describe('Фильтры для списка проектов'); + +export const CreateShareTokenSchema = z + .object({ + ttl: z + .string() + .datetime() + .nullish() + .describe('Дата истечения ссылки. Если не указана — ставится дефолт 3 месяца'), + }) + .optional(); + +export const CreateShareTokenResponseSchema = ActionResponseSchema.extend({ + payload: z.object({ + token: z.string().describe('Токен'), + expiresAt: z + .string() + .datetime({ offset: true }) + .refine((val) => !isNaN(Date.parse(val)), { + message: 'Строка не является валидной датой', + }) + .describe("'Дата истечения ссылки. Если не была указана — ставится дефолт 3 месяца'"), + }), +}); + +export const ProjectListItemSchema = z.object({ + id: z.string().describe('ID проекта'), + slug: z.string().describe('Slug проекта (URL-идентификатор)'), + name: z.string().describe('Название проекта'), + status: ProjectStatusSchema.default('active').describe('Текущий статус проекта'), + color: z.string().describe('Цвет проекта'), + icon: z.string().nullish().describe('Иконка проекта'), + createdAt: z.string().datetime({ offset: true }).describe('Дата создания проекта'), + role: ProjectMemberRoleSchema.describe('Роль текущего пользователя в проекте'), +}); + +export const ProjectListResponseSchema = createPaginationSchema(ProjectListItemSchema); + +export const ProjectDetailResponseSchema = z.object({ + id: z.string().describe('ID проекта'), + slug: z.string().describe('URL-идентификатор проекта'), + name: z.string().describe('Название проекта'), + status: ProjectStatusSchema.default('active').describe('Текущий статус проекта'), + description: z.string().nullable().describe('Markdown-описание проекта'), + descriptionHtml: z.string().nullish().describe('HTML из Markdown описания'), + visuals: z + .object({ + color: z.string().nullish().describe('Цвет проекта'), + icon: z.string().nullish().optional().describe('Иконка проекта'), + }) + .describe('Визуальные настройки'), + meta: z.object({ + sequence: z.number().int().nonnegative().describe('Счётчик задач'), + createdAt: z + .string() + .datetime({ offset: true }) + .refine((val) => !isNaN(Date.parse(val)), { + message: 'Строка не является валидной датой', + }) + .describe('Дата создания'), + updatedAt: z + .string() + .datetime({ offset: true }) + .refine((val) => !isNaN(Date.parse(val)), { + message: 'Строка не является валидной датой', + }) + .describe('Дата обновления'), + }), + access: z + .object({ + visibility: ProjectVisibilitySchema.default('public').describe( + 'Видимость проекта для участников команды', + ), + currentUserRole: z + .enum(['owner', 'admin', 'member', 'viewer']) + .describe('Роль текущего пользователя'), + shareUrl: z.string().nullable().describe('Публичная ссылка для шаринга'), + }) + .describe('Права доступа'), + settings: ProjectSettingsSchema.omit({ + id: true, + projectId: true, + createdAt: true, + updatedAt: true, + }).describe('Настройки проекта'), +}); + +export class CreateProjectDto extends createZodDto(CreateProjectSchema) {} +export class UpdateProjectDto extends createZodDto(UpdateProjectSchema) {} +export class CreateProjectResponse extends createZodDto(CreateProjectsResponseSchema) {} +export class CreateShareTokenDto extends createZodDto(CreateShareTokenSchema) {} +export class CreateShareTokenResponse extends createZodDto(CreateShareTokenResponseSchema) {} +export class ProjectListResponse extends createZodDto(ProjectListResponseSchema) {} +export class ProjectDetailResponse extends createZodDto(ProjectDetailResponseSchema) {} +export class CheckSlugResponse extends createZodDto(CheckSlugResponseSchema) {} diff --git a/src/projects/application/dtos/projects.dto.ts b/src/projects/application/dtos/projects.dto.ts deleted file mode 100644 index cc55c57..0000000 --- a/src/projects/application/dtos/projects.dto.ts +++ /dev/null @@ -1,164 +0,0 @@ -import { z } from 'zod/v4'; -import { createZodDto } from 'nestjs-zod'; -import { ActionResponseSchema } from '@shared/dtos'; -import { createPaginationSchema } from '@shared/schemas'; -import { ProjectStatus, ProjectVisibility } from '@core/projects/domain/entities'; - -export const ProjectVisibilitySchema = z.enum(['public', 'private']).default('private'); - -export const CreateProjectSchema = z.object({ - name: z - .string() - .min(1, 'Название проекта не может быть пустым') - .max(100, 'Название не должно превышать 100 символов') - .describe('Название проекта'), - slug: z - .string() - .min(2, 'Ключ проекта должен быть от 2 до 10 символов') - .max(10) - .regex(/^[a-z0-9]+$/, 'Ключ должен содержать только строчные латинские буквы и цифры') - .describe('Уникальный ключ проекта в URL (2-10 символов, a-z0-9)'), - description: z - .string() - .max(2000, 'Описание слишком длинное') - .nullable() - .optional() - .default(null) - .describe('Описание проекта'), - icon: z - .string() - .max(255, 'URL иконки слишком длинный') - .nullable() - .optional() - .default(null) - .describe('Иконка проекта (эмодзи, URL или id шрифта)'), - color: z - .string() - .regex(/^#[A-Fa-f0-9]{6}$/, 'Цвет должен быть в формате HEX') - .nullable() - .optional() - .default('#3B82F6') - .describe('Цвет проекта в HEX (#RRGGBB)'), - visibility: ProjectVisibilitySchema.optional() - .default('public') - .describe('Видимость: public | private'), - taskSequence: z - .number() - .int() - .min(0) - .optional() - .default(0) - .describe('Счётчик для автонумерации задач'), -}); - -export class CreateProjectDto extends createZodDto(CreateProjectSchema) {} - -export const UpdateProjectSchema = CreateProjectSchema.extend({ - status: z.enum([ProjectStatus.Active, ProjectStatus.Archived]).optional(), - isPublic: z.boolean().optional(), -}) - .partial() - .refine((data) => Object.keys(data).length > 0, { - error: 'Необходимо передать хотя бы одно поле для обновления', - abort: true, - }); - -export class UpdateProjectDto extends createZodDto(UpdateProjectSchema) {} - -const CreateProjectsResponseSchema = ActionResponseSchema.extend({ - slug: z.string().describe('Уникальный идентификатор проекта в системе'), -}); - -export class CreateProjectResponse extends createZodDto(CreateProjectsResponseSchema) {} - -export const CreateShareTokenSchema = z.object({ - ttl: z - .string() - .datetime() - .optional() - .nullable() - .describe('Дата истечения ссылки. Если не указана — ставится дефолт 3 месяца'), -}); - -export class CreateShareTokenDto extends createZodDto(CreateShareTokenSchema) {} - -export const CreateShareTokenResponseSchema = ActionResponseSchema.extend({ - payload: z.object({ - token: z.string().describe('Токен'), - isYourself: z - .boolean() - .describe('Флаг указывает, что ссылка была сгенерирована текущим пользователем'), - expiresAt: z - .string() - .refine((val) => !isNaN(Date.parse(val)), { - message: 'Строка не является валидной датой', - }) - .describe("'Дата истечения ссылки. Если не была указана — ставится дефолт 3 месяца'"), - }), -}); - -export class CreateShareTokenResponse extends createZodDto(CreateShareTokenResponseSchema) {} - -const TeamShortSchema = z.object({ - id: z.string().describe('ID команды'), - name: z.string().describe('Название команды'), - role: z.string().describe('Роль пользователя в команде'), -}); - -export const ProjectListItemSchema = z.object({ - id: z.string().describe('ID проекта'), - key: z.string().describe('Ключ проекта'), - name: z.string().describe('Название проекта'), - status: z.nativeEnum(ProjectStatus).describe('Статус проекта'), - color: z.string().describe('Цвет проекта'), - icon: z.string().nullable().optional().describe('Иконка проекта'), - createdAt: z - .string() - .refine((val) => !isNaN(Date.parse(val)), { - message: 'Строка не является валидной датой', - }) - .describe('Дата создания проекта'), - canEdit: z.boolean().describe('Флаг возможности редактировать проект'), -}); - -export const ProjectListResponseSchema = createPaginationSchema(ProjectListItemSchema).extend({ - team: TeamShortSchema, -}); - -export class ProjectListResponse extends createZodDto(ProjectListResponseSchema) {} - -export const ProjectDetailResponseSchema = z.object({ - id: z.string().describe('ID проекта'), - key: z.string().describe('Ключ проекта'), - name: z.string().describe('Название проекта'), - status: z.nativeEnum(ProjectStatus).describe('Статус проекта'), - description: z.string().nullable().describe('Описание проекта'), - visuals: z.object({ - color: z.string().describe('Цвет проекта'), - icon: z.string().nullable().optional().describe('Иконка проекта'), - }), - meta: z.object({ - taskSequence: z.number().int().nonnegative().describe('Счётчик задач'), - createdAt: z - .string() - .refine((val) => !isNaN(Date.parse(val)), { - message: 'Строка не является валидной датой', - }) - .describe('Дата создания'), - updatedAt: z - .string() - .refine((val) => !isNaN(Date.parse(val)), { - message: 'Строка не является валидной датой', - }) - .describe('Дата обновления'), - }), - access: z.object({ - visibility: z.nativeEnum(ProjectVisibility).describe('Видимость проекта'), - canEdit: z.boolean().describe('Можно ли редактировать проект'), - canDelete: z.boolean().describe('Можно ли удалить проект'), - shareUrl: z.string().nullable().describe('Ссылка на шаринг проекта'), - }), - settings: z.record(z.string(), z.unknown()).describe('Настройки проекта'), -}); - -export class ProjectDetailResponse extends createZodDto(ProjectDetailResponseSchema) {} diff --git a/src/projects/application/dtos/settings.dto.ts b/src/projects/application/dtos/settings.dto.ts new file mode 100644 index 0000000..961ab49 --- /dev/null +++ b/src/projects/application/dtos/settings.dto.ts @@ -0,0 +1,89 @@ +import { z } from 'zod/v4'; + +export const ProjectSettingsSchema = z + .object({ + id: z + .string() + .min(1, 'ID не может быть пустым') + .describe('Уникальный идентификатор настроек'), + projectId: z + .string() + .min(1, 'ID проекта обязателен') + .describe('ID проекта, к которому относятся настройки'), + defaultView: z + .enum(['kanban', 'list', 'calendar', 'gantt']) + .default('kanban') + .describe('Представление по умолчанию'), + taskPrefix: z + .string() + .max(10, 'Префикс не должен превышать 10 символов') + .nullable() + .optional() + .describe('Префикс для номеров задач'), + autoCloseDays: z + .number() + .int('Должно быть целым числом') + .positive('Должно быть положительным числом') + .nullable() + .optional() + .describe('Автозакрытие неактивных задач через N дней'), + maxTasksPerArea: z + .number() + .int('Должно быть целым числом') + .positive('Должно быть положительным числом') + .nullable() + .optional() + .describe('Максимум задач в одной области'), + maxMembers: z + .number() + .int('Должно быть целым числом') + .positive('Должно быть положительным числом') + .nullable() + .optional() + .describe('Максимум участников проекта'), + maxAreas: z + .number() + .int('Должно быть целым числом') + .positive('Должно быть положительным числом') + .nullable() + .optional() + .describe('Максимум областей в проекте'), + allowGuests: z.boolean().default(false).describe('Разрешить гостевой доступ по ссылке'), + timeTracking: z.boolean().default(false).describe('Включить учет времени по задачам'), + timeTrackingMode: z + .enum(['optional', 'required', 'disabled']) + .default('optional') + .describe('Режим учета времени'), + defaultAssigneeId: z + .string() + .nullable() + .optional() + .describe('ID исполнителя по умолчанию для новых задач'), + createdAt: z.string().datetime({ offset: true }).describe('Дата создания настроек'), + updatedAt: z + .string() + .datetime({ offset: true }) + .describe('Дата последнего обновления настроек'), + }) + .describe('Полная схема настроек проекта'); + +export const CreateProjectSettingsSchema = ProjectSettingsSchema.omit({ + id: true, + projectId: true, + createdAt: true, + updatedAt: true, +}) + .partial({ + defaultView: true, + timeTrackingMode: true, + }) + .describe('Схема настроек при создании проекта'); + +export const UpdateProjectSettingsSchema = ProjectSettingsSchema.omit({ + id: true, + projectId: true, + createdAt: true, + updatedAt: true, +}) + .partial() + .describe('Схема для обновления настроек проекта'); diff --git a/src/projects/application/mappers/index.ts b/src/projects/application/mappers/index.ts index 7f5f566..c832100 100644 --- a/src/projects/application/mappers/index.ts +++ b/src/projects/application/mappers/index.ts @@ -1 +1 @@ -export { ProjectsMapper } from './projects.mapper'; +export { ProjectMapper } from './project.mapper'; diff --git a/src/projects/application/mappers/member.mapper.ts b/src/projects/application/mappers/member.mapper.ts new file mode 100644 index 0000000..650ec07 --- /dev/null +++ b/src/projects/application/mappers/member.mapper.ts @@ -0,0 +1,34 @@ +import type { MemberWithUser } from '@core/projects/domain/entities'; + +export class MemberMapper { + public static toMemberResponse(member: MemberWithUser) { + return { + id: member.id, + role: member.role, + createdAt: new Date(member.createdAt).toISOString(), + user: { + id: member.user.id, + email: member.user.email, + firstName: member.user.firstName, + lastName: member.user.lastName, + avatarUrl: member.user.avatarUrl, + }, + }; + } + + public static toMemberListResponse(members: MemberWithUser[]) { + const items = members.map(MemberMapper.toMemberResponse); + + return { + items, + meta: { + hasNextPage: false, + hasPrevPage: false, + total: items.length, + totalPages: 1, + page: 1, + limit: items.length, + }, + }; + } +} diff --git a/src/projects/application/mappers/projects.mapper.ts b/src/projects/application/mappers/project.mapper.ts similarity index 56% rename from src/projects/application/mappers/projects.mapper.ts rename to src/projects/application/mappers/project.mapper.ts index 5a5f784..0ae2684 100644 --- a/src/projects/application/mappers/projects.mapper.ts +++ b/src/projects/application/mappers/project.mapper.ts @@ -1,8 +1,7 @@ -import { ROLE_PRIORITY } from '@shared/constants'; -import { RawMemberRow } from '@core/teams/domain/repository'; -import { Project } from '@core/projects/domain/entities'; +import type { RawMemberRow } from '@core/teams/domain/repository'; +import type { Project } from '@core/projects/domain/entities'; -export class ProjectsMapper { +export class ProjectMapper { public static toDetailResponse(project: Project, member?: RawMemberRow, token?: string) { const { id, @@ -12,15 +11,12 @@ export class ProjectsMapper { description, color, icon, - taskSequence, + sequence, createdAt, updatedAt, visibility, - settings, } = project; - const rolePriority = member ? ROLE_PRIORITY[member.role] : -1; - return { id, slug, @@ -28,21 +24,20 @@ export class ProjectsMapper { status, description, visuals: { - color: color ?? '#3b82f6', + color: color || '#3b82f6', icon, }, meta: { - taskSequence, - createdAt, - updatedAt, + sequence, + createdAt: new Date(createdAt).toISOString(), + updatedAt: new Date(updatedAt).toISOString(), }, access: { visibility, - canEdit: rolePriority >= ROLE_PRIORITY.moderator, - canDelete: rolePriority >= ROLE_PRIORITY.admin, + currentUserRole: member?.role || 'viewer', shareUrl: visibility === 'public' && token ? `/share/${token}` : null, }, - settings: settings || {}, + settings: {}, }; } @@ -54,10 +49,10 @@ export class ProjectsMapper { slug, name, status, - color: color ?? '#3b82f6', + color: color || '#3b82f6', icon, - createdAt, - canEdit: ROLE_PRIORITY[member.role] >= ROLE_PRIORITY.moderator, + role: member?.role || 'viewer', + createdAt: new Date(createdAt).toISOString(), }; } } diff --git a/src/projects/application/projects.facade.ts b/src/projects/application/project.facade.ts similarity index 54% rename from src/projects/application/projects.facade.ts rename to src/projects/application/project.facade.ts index 826fd5a..22d123c 100644 --- a/src/projects/application/projects.facade.ts +++ b/src/projects/application/project.facade.ts @@ -1,12 +1,12 @@ import { Injectable } from '@nestjs/common'; -import { ProjectStatus } from '../domain/entities'; +import type { ProjectStatus } from '../domain/entities'; +import { CheckSlugAvailabilityQuery } from './use-cases/project/check-slug.use-case'; import type { + AddProjectMemberDto, CreateProjectDto, - CreateProjectStateDto, CreateShareTokenDto, - ReorderProjectsStatesDto, UpdateProjectDto, - UpdateProjectStateDto, + UpdateProjectMemberDto, } from './dtos'; import { CreateProjectUseCase, @@ -16,33 +16,32 @@ import { UpdateProjectUseCase, FindProjectsByTeamQuery, GetProjectDetailQuery, - CreateStateUseCase, - DeleteStateUseCase, - GetStateQuery, - GetStatesQuery, - UpdateStateUseCase, - RestoreStateUseCase, - ReorderStateUseCase, +} from './use-cases/project'; +import { + AddProjectMemberUseCase, + DeleteProjectMemberUseCase, + FindAllProjectMembersQuery, + GetAvailableTeamMemberQuery, + UpdateProjectMemberUseCase, } from './use-cases'; @Injectable() -export class ProjectsFacade { +export class ProjectFacade { constructor( + private readonly checkSlugAvailabilityQ: CheckSlugAvailabilityQuery, + private readonly generateTokenUC: GenerateShareTokenUseCase, private readonly createProjectUC: CreateProjectUseCase, private readonly updateProjectUC: UpdateProjectUseCase, private readonly deleteProjectUC: DeleteProjectUseCase, private readonly setStatusUC: SetProjectStatusUseCase, - private readonly generateTokenUC: GenerateShareTokenUseCase, - private readonly getDetailQ: GetProjectDetailQuery, private readonly findByTeamQ: FindProjectsByTeamQuery, + private readonly getDetailQ: GetProjectDetailQuery, - private readonly createStateUC: CreateStateUseCase, - private readonly updateStateUC: UpdateStateUseCase, - private readonly deleteStateUC: DeleteStateUseCase, - private readonly getStateDetailQ: GetStateQuery, - private readonly getStatesQ: GetStatesQuery, - private readonly restoreStateUC: RestoreStateUseCase, - private readonly reorderStateUC: ReorderStateUseCase, + private readonly getMembersQ: FindAllProjectMembersQuery, + private readonly addMemberUC: AddProjectMemberUseCase, + private readonly removeMemberUC: DeleteProjectMemberUseCase, + private readonly updateMemberRoleUC: UpdateProjectMemberUseCase, + private readonly getAvailableTeamMembersQ: GetAvailableTeamMemberQuery, ) {} public async create(userId: string, teamId: string, dto: CreateProjectDto) { @@ -78,36 +77,32 @@ export class ProjectsFacade { return this.findByTeamQ.execute(teamId, userId); } - public async createState(slug: string, dto: CreateProjectStateDto, userId: string) { - return this.createStateUC.execute(slug, dto, userId); + public async getMembers(teamId: string, userId: string) { + return this.getMembersQ.execute(teamId, userId); } - public async deleteState(slug: string, stateId: string, userId: string) { - return this.deleteStateUC.execute(slug, stateId, userId); + public async addMember(slug: string, userId: string, dto: AddProjectMemberDto) { + return this.addMemberUC.execute(slug, userId, dto); } - public async updateState( + public async updateMemberRole( slug: string, - stateId: string, - dto: UpdateProjectStateDto, + memberId: string, userId: string, + dto: UpdateProjectMemberDto, ) { - return this.updateStateUC.execute(slug, stateId, dto, userId); - } - - public async getDetailState(slug: string, stateId: string, userId: string) { - return this.getStateDetailQ.execute(slug, stateId, userId); + return this.updateMemberRoleUC.execute(slug, memberId, userId, dto); } - public async getStates(slug: string, query: unknown, userId: string) { - return this.getStatesQ.execute(slug, query, userId); + public async removeMember(slug: string, memberId: string, userId: string) { + return this.removeMemberUC.execute(slug, memberId, userId); } - public async restoreState(slug: string, stateId: string, userId: string) { - return this.restoreStateUC.execute(slug, stateId, userId); + public async getAvailableTeamMembers(slug: string, userId: string, search?: string) { + return this.getAvailableTeamMembersQ.execute(slug, userId, search); } - public async reoderStates(slug: string, dto: ReorderProjectsStatesDto, userId: string) { - return this.reorderStateUC.execute(slug, dto, userId); + public async checkSlugAvailability(teamId: string, slug: string) { + return this.checkSlugAvailabilityQ.execute(teamId, slug); } } diff --git a/src/projects/application/use-cases/create-project.use-case.ts b/src/projects/application/use-cases/create-project.use-case.ts deleted file mode 100644 index ddfc1f7..0000000 --- a/src/projects/application/use-cases/create-project.use-case.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { Inject, Injectable } from '@nestjs/common'; -import type { CreateProjectDto } from '../dtos'; -import { IProjectsRepository } from '@core/projects/domain/repository'; -import { ProjectStatus } from '@core/projects/domain/entities'; -import { ProjectAccessPolicy } from '@core/projects/domain/policy'; - -@Injectable() -export class CreateProjectUseCase { - constructor( - @Inject('IProjectsRepository') - private readonly projectsRepo: IProjectsRepository, - private readonly policy: ProjectAccessPolicy, - ) {} - - public async execute(userId: string, teamId: string, dto: CreateProjectDto) { - const { team } = await this.policy.ensureTeamAccess(teamId, userId, 'admin'); - - const data = { - ...dto, - teamId: team.id, - ownerId: userId, - slug: dto.slug.toUpperCase(), - status: ProjectStatus.Active, - }; - - const { result, slug } = await this.projectsRepo.create(data); - - return { - success: result, - message: `Проект ${dto.name} успешно создан`, - slug, - }; - } -} diff --git a/src/projects/application/use-cases/delete-project.use-case.ts b/src/projects/application/use-cases/delete-project.use-case.ts deleted file mode 100644 index 72520d0..0000000 --- a/src/projects/application/use-cases/delete-project.use-case.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { ProjectAccessPolicy } from '@core/projects/domain/policy'; -import { IProjectsRepository } from '@core/projects/domain/repository'; -import { HttpStatus, Inject, Injectable } from '@nestjs/common'; -import { BaseException } from '@shared/error'; - -@Injectable() -export class DeleteProjectUseCase { - constructor( - @Inject('IProjectsRepository') - private readonly projectsRepo: IProjectsRepository, - private readonly policy: ProjectAccessPolicy, - ) {} - - public async execute(slug: string, teamId: string, userId: string) { - const { project } = await this.policy.validateProjectAccess(slug, teamId, userId, 'admin'); - const result = await this.projectsRepo.delete(project.id); - - if (!result) { - throw new BaseException( - { - code: 'DELETE_FAILED', - message: 'Не удалось удалить проект', - }, - HttpStatus.SERVICE_UNAVAILABLE, - ); - } - - return { - success: true, - message: result - ? `Проект ${project.name} успешно перемещен в корзину` - : 'Не удалось удалить проект, попробуйте позже', - }; - } -} diff --git a/src/projects/application/use-cases/generate-share-token.use-case.ts b/src/projects/application/use-cases/generate-share-token.use-case.ts deleted file mode 100644 index fc75544..0000000 --- a/src/projects/application/use-cases/generate-share-token.use-case.ts +++ /dev/null @@ -1,82 +0,0 @@ -import { HttpStatus, Inject, Injectable } from '@nestjs/common'; -import type { CreateShareTokenDto } from '../dtos'; -import { createHash, randomBytes } from 'crypto'; -import { BaseException } from '@shared/error'; -import { ProjectAccessPolicy } from '@core/projects/domain/policy'; -import { IProjectsRepository } from '@core/projects/domain/repository'; - -@Injectable() -export class GenerateShareTokenUseCase { - constructor( - @Inject('IProjectsRepository') - private readonly projectsRepo: IProjectsRepository, - private readonly policy: ProjectAccessPolicy, - ) {} - - public async execute(slug: string, teamId: string, userId: string, dto: CreateShareTokenDto) { - const { project } = await this.policy.validateProjectAccess(slug, teamId, userId); - - let expiresAt: Date; - - if (dto.ttl) { - expiresAt = new Date(dto.ttl); - - if (expiresAt <= new Date()) { - throw new BaseException( - { - code: 'INVALID_EXPIRATION', - message: 'Дата истечения не может быть в прошлом', - details: [ - { target: 'ttl', message: 'Expiration date is behind current time' }, - ], - }, - HttpStatus.BAD_REQUEST, - ); - } - } else { - expiresAt = new Date(); - expiresAt.setMonth(expiresAt.getMonth() + 3); - } - - const rawToken = this.generateSecureToken(); - - const isSaved = await this.projectsRepo.createShare({ - projectId: project.id, - token: this.hash(rawToken), - expiresAt: expiresAt.toISOString(), - createdBy: userId, - }); - - if (!isSaved) { - throw new BaseException( - { - code: 'SHARE_CREATE_FAILED', - message: 'Не удалось сгенерировать ссылку доступа', - }, - HttpStatus.INTERNAL_SERVER_ERROR, - ); - } - - const durationMsg = dto.ttl - ? `закроется ${expiresAt.toLocaleDateString('ru-RU')}` - : 'бессрочна (на 3 месяца по умолчанию)'; - - return { - success: true, - message: `Ссылка для проекта «${project.name}» создана и ${durationMsg}`, - payload: { - token: rawToken, - isYourself: !!dto, - expiresAt: expiresAt.toISOString(), - }, - }; - } - - private generateSecureToken(): string { - return `st_${randomBytes(32).toString('hex')}`; - } - - private hash(token: string): string { - return createHash('sha256').update(token).digest('hex'); - } -} diff --git a/src/projects/application/use-cases/index.ts b/src/projects/application/use-cases/index.ts index a0749d1..8f892d7 100644 --- a/src/projects/application/use-cases/index.ts +++ b/src/projects/application/use-cases/index.ts @@ -1,54 +1,12 @@ -import { CreateProjectUseCase } from './create-project.use-case'; -import { DeleteProjectUseCase } from './delete-project.use-case'; -import { GenerateShareTokenUseCase } from './generate-share-token.use-case'; -import { SetProjectStatusUseCase } from './set-project-status.use-case'; -import { UpdateProjectUseCase } from './update-project.use-case'; -import { FindProjectsByTeamQuery } from './find-projects-by-team.query'; -import { GetProjectDetailQuery } from './get-project-detail.query'; -import { FindProjectQuery } from './find-project.query'; -import { CreateStateUseCase } from './states/create-state.use-case'; -import { DeleteStateUseCase } from './states/delete-state.use-case'; -import { UpdateStateUseCase } from './states/update-state.use-case'; -import { GetStateQuery } from './states/get-state.query'; -import { GetStatesQuery } from './states/get-states.query'; -import { RestoreStateUseCase } from './states/restore-state.use-state'; -import { ReorderStateUseCase } from './states/reorder-states.use-case'; +import { MemberQueries, MemberUseCases } from './member'; +import { ProjectQueries, ProjectUseCases } from './project'; -export * from './find-projects-by-team.query'; -export * from './find-project.query'; -export * from './create-project.use-case'; -export * from './delete-project.use-case'; -export * from './generate-share-token.use-case'; -export * from './get-project-detail.query'; -export * from './set-project-status.use-case'; -export * from './update-project.use-case'; -export * from './states/create-state.use-case'; -export * from './states/delete-state.use-case'; -export * from './states/update-state.use-case'; -export * from './states/get-state.query'; -export * from './states/get-states.query'; -export * from './states/restore-state.use-state'; -export * from './states/update-state.use-case'; -export * from './states/reorder-states.use-case'; +export * from './project'; +export * from './member'; -export const ProjectUseCases = [ - CreateProjectUseCase, - DeleteProjectUseCase, - GenerateShareTokenUseCase, - SetProjectStatusUseCase, - UpdateProjectUseCase, +export const USE_CASES = [ + ...MemberQueries, + ...ProjectQueries, + ...MemberUseCases, + ...ProjectUseCases, ]; - -export const ProjectStatesUseCases = [ - CreateStateUseCase, - RestoreStateUseCase, - DeleteStateUseCase, - UpdateStateUseCase, - GetStateQuery, - GetStatesQuery, - ReorderStateUseCase, -]; - -export const ProjectQueries = [FindProjectsByTeamQuery, GetProjectDetailQuery, FindProjectQuery]; - -export const USE_CASES = [...ProjectUseCases, ...ProjectStatesUseCases, ...ProjectQueries]; diff --git a/src/projects/application/use-cases/member/add.use-case.ts b/src/projects/application/use-cases/member/add.use-case.ts new file mode 100644 index 0000000..0816a0c --- /dev/null +++ b/src/projects/application/use-cases/member/add.use-case.ts @@ -0,0 +1,90 @@ +import { ProjectAccessPolicy } from '@core/projects/domain/policy'; +import { IMemberRepository } from '@core/projects/domain/repository'; +import { HttpStatus, Inject, Injectable } from '@nestjs/common'; +import { BaseException } from '@shared/error'; +import { AddProjectMemberDto } from '../../dtos'; +import { MemberErrorCodes, MemberErrorMessages } from '@core/projects/domain/errors/member.errors'; +import { FindTeamMemberQuery } from '@core/teams'; + +// TODO: at feature migrate to dynamic field at project +const MAX_MEMBERS_PROJECT = 10; + +@Injectable() +export class AddProjectMemberUseCase { + constructor( + @Inject('IMemberRepository') private readonly memberRepo: IMemberRepository, + private readonly findTeamMemberQ: FindTeamMemberQuery, + private readonly policy: ProjectAccessPolicy, + ) {} + + async execute(slug: string, userId: string, dto: AddProjectMemberDto) { + const { project } = await this.policy.ensureProjectAccess(slug, userId, ['owner', 'admin']); + + if (dto.userId === userId) { + throw new BaseException( + { + code: MemberErrorCodes.SELF_ADD, + message: MemberErrorMessages[MemberErrorCodes.SELF_ADD], + }, + HttpStatus.BAD_REQUEST, + ); + } + + if (dto.userId === userId) { + throw new BaseException( + { + code: MemberErrorCodes.SELF_ADD, + message: MemberErrorMessages[MemberErrorCodes.SELF_ADD], + }, + HttpStatus.BAD_REQUEST, + ); + } + + const teamMember = await this.findTeamMemberQ.execute(project.teamId, dto.userId); + if (!teamMember) { + throw new BaseException( + { + code: MemberErrorCodes.NOT_IN_TEAM, + message: MemberErrorMessages[MemberErrorCodes.NOT_IN_TEAM], + }, + HttpStatus.BAD_REQUEST, + ); + } + + const existing = await this.memberRepo.findByProjectAndUser(project.id, dto.userId); + if (existing) { + throw new BaseException( + { + code: MemberErrorCodes.ALREADY_EXISTS, + message: MemberErrorMessages[MemberErrorCodes.ALREADY_EXISTS], + }, + HttpStatus.CONFLICT, + ); + } + + const currentCount = await this.memberRepo.countByProject(project.id); + // TODO: project.settings?.maxMembers ?? MAX_MEMBERS_PROJECT + if (currentCount >= MAX_MEMBERS_PROJECT) { + throw new BaseException( + { + code: MemberErrorCodes.LIMIT_REACHED, + message: MemberErrorMessages[MemberErrorCodes.LIMIT_REACHED], + }, + HttpStatus.FORBIDDEN, + ); + } + + const { id } = await this.memberRepo.create({ + projectId: project.id, + userId: dto.userId, + role: dto.role, + addedBy: userId, + }); + + return { + success: true, + memberId: id, + message: `Пользователь добавлен в проект «${project.name}» с ролью ${dto.role}`, + }; + } +} diff --git a/src/projects/application/use-cases/member/delete.use-case.ts b/src/projects/application/use-cases/member/delete.use-case.ts new file mode 100644 index 0000000..ebf97c7 --- /dev/null +++ b/src/projects/application/use-cases/member/delete.use-case.ts @@ -0,0 +1,87 @@ +import { MemberErrorCodes, MemberErrorMessages } from '@core/projects/domain/errors/member.errors'; +import { ProjectAccessPolicy } from '@core/projects/domain/policy'; +import { IMemberRepository } from '@core/projects/domain/repository'; +import { HttpStatus, Inject, Injectable } from '@nestjs/common'; +import { BaseException } from '@shared/error'; + +@Injectable() +export class DeleteProjectMemberUseCase { + constructor( + @Inject('IMemberRepository') private readonly memberRepo: IMemberRepository, + private readonly policy: ProjectAccessPolicy, + ) {} + + async execute(slug: string, memberId: string, userId: string) { + const { project, member: currentMember } = await this.policy.ensureProjectAccess( + slug, + userId, + ); + + const targetMember = await this.memberRepo.findById(memberId); + if (!targetMember || targetMember.projectId !== project.id) { + throw new BaseException( + { + code: MemberErrorCodes.NOT_FOUND, + message: MemberErrorMessages[MemberErrorCodes.NOT_FOUND], + }, + HttpStatus.NOT_FOUND, + ); + } + + const isSelfRemove = targetMember.userId === userId; + + if (targetMember.role === 'owner') { + throw new BaseException( + { + code: isSelfRemove + ? MemberErrorCodes.SELF_REMOVE_OWNER + : MemberErrorCodes.CANNOT_REMOVE_OWNER, + message: isSelfRemove + ? MemberErrorMessages[MemberErrorCodes.SELF_REMOVE_OWNER] + : MemberErrorMessages[MemberErrorCodes.CANNOT_REMOVE_OWNER], + }, + HttpStatus.FORBIDDEN, + ); + } + + if (!isSelfRemove) { + if (currentMember.role !== 'owner' && currentMember.role !== 'admin') { + throw new BaseException( + { + code: MemberErrorCodes.ACCESS_DENIED, + message: MemberErrorMessages[MemberErrorCodes.ACCESS_DENIED], + }, + HttpStatus.FORBIDDEN, + ); + } + + if (targetMember.role === 'admin' && currentMember.role !== 'owner') { + throw new BaseException( + { + code: MemberErrorCodes.ADMIN_REMOVE_FORBIDDEN, + message: MemberErrorMessages[MemberErrorCodes.ADMIN_REMOVE_FORBIDDEN], + }, + HttpStatus.FORBIDDEN, + ); + } + } + + const deleted = await this.memberRepo.delete(memberId); + if (!deleted) { + throw new BaseException( + { + code: MemberErrorCodes.DELETE_FAILED, + message: MemberErrorMessages[MemberErrorCodes.DELETE_FAILED], + }, + HttpStatus.INTERNAL_SERVER_ERROR, + ); + } + + const action = isSelfRemove ? 'покинул' : 'удален из'; + + return { + success: true, + message: `Пользователь ${action} проект «${project.name}»`, + }; + } +} diff --git a/src/projects/application/use-cases/member/find-all.query.ts b/src/projects/application/use-cases/member/find-all.query.ts new file mode 100644 index 0000000..98ea233 --- /dev/null +++ b/src/projects/application/use-cases/member/find-all.query.ts @@ -0,0 +1,30 @@ +import { ProjectAccessPolicy } from '@core/projects/domain/policy'; +import { IMemberRepository } from '@core/projects/domain/repository'; +import { Inject, Injectable } from '@nestjs/common'; +import { MemberMapper } from '../../mappers/member.mapper'; +import { FindByIdsQuery } from '@core/user/application/use-cases'; + +@Injectable() +export class FindAllProjectMembersQuery { + constructor( + @Inject('IMemberRepository') private readonly memberRepo: IMemberRepository, + private readonly policy: ProjectAccessPolicy, + private readonly findUsersQ: FindByIdsQuery, + ) {} + + async execute(slug: string, userId: string) { + const { project } = await this.policy.ensureProjectAccess(slug, userId); + const members = await this.memberRepo.findByProject(project.id); + + const userIds = members.map((m) => m.userId); + const users = await this.findUsersQ.execute(userIds); + + const map = new Map(users.map((u) => [u.id, u])); + const result = members.map((m) => ({ + ...m, + user: map.get(m.userId), + })); + + return MemberMapper.toMemberListResponse(result); + } +} diff --git a/src/projects/application/use-cases/member/get-available.query.ts b/src/projects/application/use-cases/member/get-available.query.ts new file mode 100644 index 0000000..7056c30 --- /dev/null +++ b/src/projects/application/use-cases/member/get-available.query.ts @@ -0,0 +1,16 @@ +import { Injectable } from '@nestjs/common'; + +@Injectable() +export class GetAvailableTeamMemberQuery { + constructor() {} + + async execute(slug: string, userId: string, search?: string) { + return { + success: true, + message: '', + slug, + userId, + search, + }; + } +} diff --git a/src/projects/application/use-cases/member/index.ts b/src/projects/application/use-cases/member/index.ts new file mode 100644 index 0000000..b25b53c --- /dev/null +++ b/src/projects/application/use-cases/member/index.ts @@ -0,0 +1,19 @@ +import { AddProjectMemberUseCase } from './add.use-case'; +import { DeleteProjectMemberUseCase } from './delete.use-case'; +import { FindAllProjectMembersQuery } from './find-all.query'; +import { GetAvailableTeamMemberQuery } from './get-available.query'; +import { UpdateProjectMemberUseCase } from './update.use-case'; + +export * from './add.use-case'; +export * from './delete.use-case'; +export * from './find-all.query'; +export * from './get-available.query'; +export * from './update.use-case'; + +export const MemberQueries = [FindAllProjectMembersQuery, GetAvailableTeamMemberQuery]; + +export const MemberUseCases = [ + AddProjectMemberUseCase, + DeleteProjectMemberUseCase, + UpdateProjectMemberUseCase, +]; diff --git a/src/projects/application/use-cases/member/update.use-case.ts b/src/projects/application/use-cases/member/update.use-case.ts new file mode 100644 index 0000000..7da7a1e --- /dev/null +++ b/src/projects/application/use-cases/member/update.use-case.ts @@ -0,0 +1,78 @@ +import { ProjectAccessPolicy } from '@core/projects/domain/policy'; +import { IMemberRepository } from '@core/projects/domain/repository'; +import { HttpStatus, Inject, Injectable } from '@nestjs/common'; +import { BaseException } from '@shared/error'; +import { UpdateProjectMemberDto } from '../../dtos'; +import { MemberErrorCodes, MemberErrorMessages } from '@core/projects/domain/errors/member.errors'; + +@Injectable() +export class UpdateProjectMemberUseCase { + constructor( + @Inject('IMemberRepository') private readonly memberRepo: IMemberRepository, + private readonly policy: ProjectAccessPolicy, + ) {} + + async execute(slug: string, memberId: string, userId: string, dto: UpdateProjectMemberDto) { + const { project, member: currentMember } = await this.policy.ensureProjectAccess( + slug, + userId, + ['owner', 'admin'], + ); + + const targetMember = await this.memberRepo.findById(memberId); + if (!targetMember || targetMember.projectId !== project.id) { + throw new BaseException( + { + code: MemberErrorCodes.NOT_FOUND, + message: MemberErrorMessages[MemberErrorCodes.NOT_FOUND], + }, + HttpStatus.NOT_FOUND, + ); + } + + if (targetMember.role === 'owner') { + throw new BaseException( + { + code: MemberErrorCodes.CANNOT_CHANGE_OWNER, + message: MemberErrorMessages[MemberErrorCodes.CANNOT_CHANGE_OWNER], + }, + HttpStatus.FORBIDDEN, + ); + } + + if (targetMember.role === 'admin' || dto.role === 'admin') { + if (currentMember.role !== 'owner') { + throw new BaseException( + { + code: MemberErrorCodes.ADMIN_CHANGE_FORBIDDEN, + message: MemberErrorMessages[MemberErrorCodes.ADMIN_CHANGE_FORBIDDEN], + }, + HttpStatus.FORBIDDEN, + ); + } + } + + if (targetMember.role === dto.role) { + return { + success: true, + message: `Пользователь уже имеет роль «${dto.role}»`, + }; + } + + const updated = await this.memberRepo.updateRole(memberId, dto.role); + if (!updated) { + throw new BaseException( + { + code: MemberErrorCodes.UPDATE_FAILED, + message: MemberErrorMessages[MemberErrorCodes.UPDATE_FAILED], + }, + HttpStatus.INTERNAL_SERVER_ERROR, + ); + } + + return { + success: true, + message: `Роль пользователя изменена с «${targetMember.role}» на «${dto.role}» в проекте «${project.name}»`, + }; + } +} diff --git a/src/projects/application/use-cases/project/check-slug.use-case.ts b/src/projects/application/use-cases/project/check-slug.use-case.ts new file mode 100644 index 0000000..adaa5c2 --- /dev/null +++ b/src/projects/application/use-cases/project/check-slug.use-case.ts @@ -0,0 +1,19 @@ +import { IProjectRepository } from '@core/projects/domain/repository'; +import { Inject, Injectable } from '@nestjs/common'; + +@Injectable() +export class CheckSlugAvailabilityQuery { + constructor( + @Inject('IProjectRepository') + private readonly projectsRepo: IProjectRepository, + ) {} + + async execute(teamId: string, slug: string) { + const project = await this.projectsRepo.findBySlug(slug.toLowerCase(), teamId); + + return { + available: !project, + reason: project ? 'Этот slug уже занят' : null, + }; + } +} diff --git a/src/projects/application/use-cases/project/create.use-case.ts b/src/projects/application/use-cases/project/create.use-case.ts new file mode 100644 index 0000000..ec52494 --- /dev/null +++ b/src/projects/application/use-cases/project/create.use-case.ts @@ -0,0 +1,80 @@ +import { HttpStatus, Inject, Injectable } from '@nestjs/common'; +import type { CreateProjectDto } from '../../dtos'; +import { IProjectRepository } from '@core/projects/domain/repository'; +import { PROJECT_STATUSES } from '@core/projects/domain/entities'; +import { ProjectAccessPolicy } from '@core/projects/domain/policy'; +import { BaseException } from '@shared/error'; +import { ProjectErrorCodes, ProjectErrorMessages } from '@core/projects/domain/errors'; +import slugify from 'slugify'; + +// TODO: at feature migrate to dynamic field at team +const MAX_PROJECTS_PER_TEAM = 20; + +@Injectable() +export class CreateProjectUseCase { + constructor( + @Inject('IProjectRepository') private readonly projectsRepo: IProjectRepository, + private readonly policy: ProjectAccessPolicy, + ) {} + + public async execute(userId: string, teamId: string, dto: CreateProjectDto) { + const { settings, ...project } = dto; + const { team } = await this.policy.ensureTeamAccess(teamId, userId, 'admin'); + + console.log(settings); + + const currentSlug = slugify(project?.slug ? project.slug : project.name, { + lower: true, + strict: true, + }); + + const slugExists = await this.projectsRepo.findBySlug(currentSlug, team.id); + + if (slugExists) { + throw new BaseException( + { + code: ProjectErrorCodes.SLUG_DUPLICATE, + message: ProjectErrorMessages[ProjectErrorCodes.SLUG_DUPLICATE], + }, + HttpStatus.CONFLICT, + ); + } + + const projectCount = await this.projectsRepo.countByTeam(team.id); + if (projectCount >= MAX_PROJECTS_PER_TEAM) { + throw new BaseException( + { + code: ProjectErrorCodes.MAX_PROJECTS_REACHED, + message: ProjectErrorMessages[ProjectErrorCodes.MAX_PROJECTS_REACHED], + }, + HttpStatus.FORBIDDEN, + ); + } + + const data = { + ...project, + teamId: team.id, + ownerId: userId, + slug: currentSlug, + status: PROJECT_STATUSES[0], + }; + + const { result, slug } = await this.projectsRepo.create(userId, data); + + if (!result) { + throw new BaseException( + { + code: ProjectErrorCodes.CREATE_FAILED, + message: ProjectErrorMessages[ProjectErrorCodes.CREATE_FAILED], + }, + HttpStatus.INTERNAL_SERVER_ERROR, + ); + } + + return { + success: result, + message: `Проект ${dto.name} успешно создан`, + slug, + }; + } +} diff --git a/src/projects/application/use-cases/project/delete.use-case.ts b/src/projects/application/use-cases/project/delete.use-case.ts new file mode 100644 index 0000000..104724b --- /dev/null +++ b/src/projects/application/use-cases/project/delete.use-case.ts @@ -0,0 +1,48 @@ +import { ProjectErrorCodes, ProjectErrorMessages } from '@core/projects/domain/errors'; +import { ProjectAccessPolicy } from '@core/projects/domain/policy'; +import { IProjectRepository } from '@core/projects/domain/repository'; +import { HttpStatus, Inject, Injectable } from '@nestjs/common'; +import { BaseException } from '@shared/error'; + +@Injectable() +export class DeleteProjectUseCase { + constructor( + @Inject('IProjectRepository') + private readonly projectsRepo: IProjectRepository, + private readonly policy: ProjectAccessPolicy, + ) {} + + public async execute(slug: string, teamId: string, userId: string) { + const { team } = await this.policy.ensureTeamAccess(teamId, userId, 'admin'); + + const project = await this.projectsRepo.findBySlug(slug, team.id); + if (!project) { + throw new BaseException( + { + code: ProjectErrorCodes.NOT_FOUND, + message: ProjectErrorMessages[ProjectErrorCodes.NOT_FOUND], + }, + HttpStatus.NOT_FOUND, + ); + } + + const result = await this.projectsRepo.delete(team.id, project.id); + + if (!result) { + throw new BaseException( + { + code: ProjectErrorCodes.DELETE_FAILED, + message: ProjectErrorMessages[ProjectErrorCodes.DELETE_FAILED], + }, + HttpStatus.INTERNAL_SERVER_ERROR, + ); + } + + return { + success: true, + message: result + ? `Проект ${project.name} успешно перемещен в корзину` + : 'Не удалось удалить проект, попробуйте позже', + }; + } +} diff --git a/src/projects/application/use-cases/find-projects-by-team.query.ts b/src/projects/application/use-cases/project/find-by-team.query.ts similarity index 60% rename from src/projects/application/use-cases/find-projects-by-team.query.ts rename to src/projects/application/use-cases/project/find-by-team.query.ts index c27f25e..9e217b2 100644 --- a/src/projects/application/use-cases/find-projects-by-team.query.ts +++ b/src/projects/application/use-cases/project/find-by-team.query.ts @@ -1,32 +1,29 @@ import { Inject, Injectable } from '@nestjs/common'; -import { ProjectsMapper } from '../mappers'; -import { IProjectsRepository } from '@core/projects/domain/repository'; +import { ProjectMapper } from '../../mappers'; +import { IProjectRepository } from '@core/projects/domain/repository'; import { ProjectAccessPolicy } from '@core/projects/domain/policy'; @Injectable() export class FindProjectsByTeamQuery { constructor( - @Inject('IProjectsRepository') - private readonly projectsRepo: IProjectsRepository, + @Inject('IProjectRepository') + private readonly projectsRepo: IProjectRepository, private readonly policy: ProjectAccessPolicy, ) {} public async execute(teamId: string, userId: string) { const { team, member } = await this.policy.ensureTeamAccess(teamId, userId, 'viewer'); const projects = await this.projectsRepo.findByTeam(team.id); - const items = projects.map((p) => ProjectsMapper.toListResponse(p, member)); + const items = projects.map((p) => ProjectMapper.toListResponse(p, member)); + + console.log(items); return { - team: { - id: team.id, - name: team.name, - role: member.role, - }, // TODO: реализовать полноценную пагинацию для проектов команды. items, meta: { - total: items.length, - totalPages: items.length ? items.length : 1, + total: items.length + 1, + totalPages: items.length ? items.length + 1 : 1, page: 1, limit: 10, hasPrevPage: false, diff --git a/src/projects/application/use-cases/find-project.query.ts b/src/projects/application/use-cases/project/find-one.query.ts similarity index 87% rename from src/projects/application/use-cases/find-project.query.ts rename to src/projects/application/use-cases/project/find-one.query.ts index 4aa8da8..031e610 100644 --- a/src/projects/application/use-cases/find-project.query.ts +++ b/src/projects/application/use-cases/project/find-one.query.ts @@ -3,35 +3,33 @@ import { FindTeamMemberQuery, FindTeamQuery } from '@core/teams'; import { createHash } from 'crypto'; import { BaseException } from '@shared/error'; import { ROLE_PRIORITY } from '@shared/constants'; -import { IProjectsRepository } from '@core/projects/domain/repository'; +import { IProjectRepository } from '@core/projects/domain/repository'; import type { Project } from '@core/projects/domain/entities'; +import { ProjectErrorCodes, ProjectErrorMessages } from '@core/projects/domain/errors'; @Injectable() export class FindProjectQuery { constructor( - @Inject('IProjectsRepository') - private readonly projectsRepo: IProjectsRepository, + @Inject('IProjectRepository') + private readonly projectsRepo: IProjectRepository, private readonly findTeamQ: FindTeamQuery, private readonly findTeamMemberQ: FindTeamMemberQuery, ) {} - /** - * Точка входа для получения проекта с проверкой прав. - */ public async execute( slug: string, teamId: string, - userId?: string, - shareToken?: string, minRole: keyof typeof ROLE_PRIORITY = 'viewer', + shareToken?: string, + userId?: string, ) { - const project = await this.projectsRepo.findOne(slug); + const project = await this.projectsRepo.findBySlug(slug, teamId); if (!project) { throw new BaseException( { - code: 'PROJECT_NOT_FOUND', - message: 'Проект не найден', + code: ProjectErrorCodes.NOT_FOUND, + message: ProjectErrorMessages[ProjectErrorCodes.NOT_FOUND], details: [{ target: 'slug', value: slug }], }, HttpStatus.NOT_FOUND, @@ -102,7 +100,7 @@ export class FindProjectQuery { } const hashedToken = createHash('sha256').update(token).digest('hex'); - const isValidToken = await this.projectsRepo.hasValidShareToken(project.slug, hashedToken); + const isValidToken = await this.projectsRepo.hasValidShareToken(project.id, hashedToken); if (!isValidToken) { throw new BaseException( diff --git a/src/projects/application/use-cases/project/generate-share-token.use-case.ts b/src/projects/application/use-cases/project/generate-share-token.use-case.ts new file mode 100644 index 0000000..b991632 --- /dev/null +++ b/src/projects/application/use-cases/project/generate-share-token.use-case.ts @@ -0,0 +1,113 @@ +import { HttpStatus, Inject, Injectable } from '@nestjs/common'; +import type { CreateShareTokenDto } from '../../dtos'; +import { createHash, randomBytes } from 'crypto'; +import { BaseException } from '@shared/error'; +import { ProjectAccessPolicy } from '@core/projects/domain/policy'; +import { IProjectRepository } from '@core/projects/domain/repository'; +import { ProjectErrorCodes, ProjectErrorMessages } from '@core/projects/domain/errors'; + +@Injectable() +export class GenerateShareTokenUseCase { + private readonly TOKEN_PREFIX = 'st_'; + private readonly DEFAULT_TTL_MONTHS = 3; + + constructor( + @Inject('IProjectRepository') + private readonly projectsRepo: IProjectRepository, + private readonly policy: ProjectAccessPolicy, + ) {} + + public async execute(slug: string, teamId: string, userId: string, dto: CreateShareTokenDto) { + const { team } = await this.policy.ensureTeamAccess(teamId, userId, 'admin'); + + const project = await this.projectsRepo.findBySlug(slug, team.id); + if (!project) { + throw new BaseException( + { + code: ProjectErrorCodes.NOT_FOUND, + message: ProjectErrorMessages[ProjectErrorCodes.NOT_FOUND], + }, + HttpStatus.NOT_FOUND, + ); + } + + const expiresAt = this.resolveExpiration(dto?.ttl); + + const rawToken = this.generateToken(); + const hashedToken = this.hashToken(rawToken); + + const result = await this.projectsRepo.createShare({ + projectId: project.id, + token: hashedToken, + expiresAt: expiresAt.toISOString(), + createdBy: userId, + }); + + if (!result) { + throw new BaseException( + { + code: ProjectErrorCodes.UPDATE_FAILED, + message: 'Не удалось создать ссылку доступа', + }, + HttpStatus.INTERNAL_SERVER_ERROR, + ); + } + + const durationMsg = dto?.ttl + ? `закроется ${expiresAt.toLocaleDateString('ru-RU')}` + : 'бессрочна (на 3 месяца по умолчанию)'; + + return { + success: true, + message: `Ссылка для проекта «${project.name}» создана и ${durationMsg}`, + payload: { + token: rawToken, + expiresAt: expiresAt.toISOString(), + }, + }; + } + + /** + * Вычисляет дату истечения токена. + * Если ttl передан — использует его, иначе +3 месяца от текущей даты. + */ + private resolveExpiration(ttl?: string): Date { + if (ttl) { + const date = new Date(ttl); + + if (isNaN(date.getTime())) { + throw new BaseException( + { + code: ProjectErrorCodes.INVALID_STATUS, + message: 'Невалидная дата истечения', + }, + HttpStatus.BAD_REQUEST, + ); + } + + if (date <= new Date()) { + throw new BaseException( + { + code: ProjectErrorCodes.INVALID_STATUS, + message: 'Дата истечения не может быть в прошлом', + }, + HttpStatus.BAD_REQUEST, + ); + } + + return date; + } + + const date = new Date(); + date.setMonth(date.getMonth() + this.DEFAULT_TTL_MONTHS); + return date; + } + + private generateToken(): string { + return `${this.TOKEN_PREFIX}${randomBytes(32).toString('hex')}`; + } + + private hashToken(token: string): string { + return createHash('sha256').update(token).digest('hex'); + } +} diff --git a/src/projects/application/use-cases/get-project-detail.query.ts b/src/projects/application/use-cases/project/get-detail.query.ts similarity index 73% rename from src/projects/application/use-cases/get-project-detail.query.ts rename to src/projects/application/use-cases/project/get-detail.query.ts index 5ef8b57..a6e560e 100644 --- a/src/projects/application/use-cases/get-project-detail.query.ts +++ b/src/projects/application/use-cases/project/get-detail.query.ts @@ -1,6 +1,6 @@ import { Injectable } from '@nestjs/common'; -import { ProjectsMapper } from '../mappers'; -import { FindProjectQuery } from './find-project.query'; +import { ProjectMapper } from '../../mappers'; +import { FindProjectQuery } from './find-one.query'; @Injectable() export class GetProjectDetailQuery { @@ -10,11 +10,11 @@ export class GetProjectDetailQuery { const { project, member } = await this.findProjectQuery.execute( slug, teamId, - userId, - token, 'viewer', + token, + userId, ); - return ProjectsMapper.toDetailResponse(project, member); + return ProjectMapper.toDetailResponse(project, member); } } diff --git a/src/projects/application/use-cases/project/index.ts b/src/projects/application/use-cases/project/index.ts new file mode 100644 index 0000000..b2f0e0d --- /dev/null +++ b/src/projects/application/use-cases/project/index.ts @@ -0,0 +1,34 @@ +import { CreateProjectUseCase } from './create.use-case'; +import { DeleteProjectUseCase } from './delete.use-case'; +import { GenerateShareTokenUseCase } from './generate-share-token.use-case'; +import { SetProjectStatusUseCase } from './set-status.use-case'; +import { UpdateProjectUseCase } from './update.use-case'; +import { FindProjectsByTeamQuery } from './find-by-team.query'; +import { GetProjectDetailQuery } from './get-detail.query'; +import { FindProjectQuery } from './find-one.query'; +import { CheckSlugAvailabilityQuery } from './check-slug.use-case'; + +export * from './find-by-team.query'; +export * from './find-one.query'; +export * from './create.use-case'; +export * from './delete.use-case'; +export * from './generate-share-token.use-case'; +export * from './get-detail.query'; +export * from './set-status.use-case'; +export * from './update.use-case'; +export * from './check-slug.use-case'; + +export const ProjectUseCases = [ + CreateProjectUseCase, + DeleteProjectUseCase, + GenerateShareTokenUseCase, + SetProjectStatusUseCase, + UpdateProjectUseCase, +]; + +export const ProjectQueries = [ + CheckSlugAvailabilityQuery, + FindProjectsByTeamQuery, + GetProjectDetailQuery, + FindProjectQuery, +]; diff --git a/src/projects/application/use-cases/project/set-status.use-case.ts b/src/projects/application/use-cases/project/set-status.use-case.ts new file mode 100644 index 0000000..074adc6 --- /dev/null +++ b/src/projects/application/use-cases/project/set-status.use-case.ts @@ -0,0 +1,88 @@ +import { PROJECT_STATUSES, type ProjectStatus } from '@core/projects/domain/entities'; +import { ProjectErrorCodes, ProjectErrorMessages } from '@core/projects/domain/errors'; +import { ProjectAccessPolicy } from '@core/projects/domain/policy'; +import { IProjectRepository } from '@core/projects/domain/repository'; +import { HttpStatus, Inject, Injectable } from '@nestjs/common'; +import { BaseException } from '@shared/error'; + +@Injectable() +export class SetProjectStatusUseCase { + constructor( + @Inject('IProjectRepository') + private readonly projectsRepo: IProjectRepository, + private readonly policy: ProjectAccessPolicy, + ) {} + + public async execute(slug: string, teamId: string, userId: string, status: ProjectStatus) { + const { team } = await this.policy.ensureTeamAccess(teamId, userId, 'admin'); + + const project = await this.projectsRepo.findBySlug(slug, team.id); + + if (!project) { + throw new BaseException( + { + code: ProjectErrorCodes.NOT_FOUND, + message: ProjectErrorMessages[ProjectErrorCodes.NOT_FOUND], + }, + HttpStatus.NOT_FOUND, + ); + } + + if (project.status === status) { + throw new BaseException( + { + code: + status === PROJECT_STATUSES[1] + ? ProjectErrorCodes.ALREADY_ARCHIVED + : ProjectErrorCodes.ALREADY_ACTIVE, + message: + status === PROJECT_STATUSES[1] + ? ProjectErrorMessages[ProjectErrorCodes.ALREADY_ARCHIVED] + : ProjectErrorMessages[ProjectErrorCodes.ALREADY_ACTIVE], + }, + HttpStatus.CONFLICT, + ); + } + + // if (status === ProjectStatus.Archived) { + // const activeTasksCount = await this.projectsRepo.countActiveTasks(project.id); + // if (activeTasksCount > 0) { + // throw new BaseException( + // { + // code: ProjectErrorCodes.CANNOT_ARCHIVE_WITH_ACTIVE_TASKS, + // message: + // ProjectErrorMessages[ + // ProjectErrorCodes.CANNOT_ARCHIVE_WITH_ACTIVE_TASKS + // ], + // }, + // HttpStatus.CONFLICT, + // ); + // } + // } + + const result = await this.projectsRepo.update(team.id, project.id, { status }); + + if (!result) { + throw new BaseException( + { + code: 'STATUS_UPDATE_FAILED', + message: 'Не удалось обновить статус проекта', + details: [{ target: 'status', value: status }], + }, + HttpStatus.INTERNAL_SERVER_ERROR, + ); + } + + const messages: Record = { + [PROJECT_STATUSES[0]]: `Проект «${project.name}» восстановлен`, + [PROJECT_STATUSES[1]]: `Проект «${project.name}» архивирован`, + [PROJECT_STATUSES[2]]: `Проект «${project.name}» сохранен как шаблон`, + [PROJECT_STATUSES[3]]: `Проект «${project.name}» удален`, + }; + + return { + success: result, + message: messages[status] || `Статус проекта «${project.name}» изменен`, + }; + } +} diff --git a/src/projects/application/use-cases/project/update.use-case.ts b/src/projects/application/use-cases/project/update.use-case.ts new file mode 100644 index 0000000..7d9c797 --- /dev/null +++ b/src/projects/application/use-cases/project/update.use-case.ts @@ -0,0 +1,96 @@ +import { HttpStatus, Inject, Injectable } from '@nestjs/common'; +import type { UpdateProjectDto } from '../../dtos'; +import { BaseException } from '@shared/error'; +import { IProjectRepository } from '@core/projects/domain/repository'; +import { ProjectAccessPolicy } from '@core/projects/domain/policy'; +import { ProjectErrorCodes, ProjectErrorMessages } from '@core/projects/domain/errors'; +import slugify from 'slugify'; + +@Injectable() +export class UpdateProjectUseCase { + constructor( + @Inject('IProjectRepository') + private readonly projectsRepo: IProjectRepository, + private readonly policy: ProjectAccessPolicy, + ) {} + + public async execute(slug: string, teamId: string, userId: string, dto: UpdateProjectDto) { + const { team } = await this.policy.ensureTeamAccess(teamId, userId, 'admin'); + + const project = await this.projectsRepo.findBySlug(slug, team.id); + if (!project) { + throw new BaseException( + { + code: ProjectErrorCodes.NOT_FOUND, + message: ProjectErrorMessages[ProjectErrorCodes.NOT_FOUND], + }, + HttpStatus.NOT_FOUND, + ); + } + + if (dto.slug && dto.slug !== project.slug) { + const slugExists = await this.projectsRepo.findBySlug(dto.slug, team.id); + if (slugExists) { + throw new BaseException( + { + code: ProjectErrorCodes.SLUG_DUPLICATE, + message: ProjectErrorMessages[ProjectErrorCodes.SLUG_DUPLICATE], + }, + HttpStatus.CONFLICT, + ); + } + } + + const data: Record = {}; + + if (dto.slug) data.slug = slugify(dto.slug, { lower: true, strict: true }); + if (dto.name) data.name = dto.name.trim(); + if (dto.description !== undefined) data.description = dto.description?.trim() || null; + if (dto.descriptionHtml !== undefined) { + data.descriptionHtml = dto.descriptionHtml?.trim() || null; + } + if (dto.icon !== undefined) data.icon = dto.icon || null; + if (dto.color !== undefined) data.color = dto.color || null; + if (dto.sequence !== undefined) data.sequence = dto.sequence; + if (dto.visibility) data.visibility = dto.visibility; + + if (Object.keys(data).length === 0 && !dto.settings) { + return { + success: true, + message: 'Нет данных для обновления', + }; + } + + const result = await this.projectsRepo.update(team.id, project.id, data); + + if (!result) { + throw new BaseException( + { + code: ProjectErrorCodes.UPDATE_FAILED, + message: ProjectErrorMessages[ProjectErrorCodes.UPDATE_FAILED], + }, + HttpStatus.INTERNAL_SERVER_ERROR, + ); + } + + if (!result) { + throw new BaseException( + { + code: 'UPDATE_FAILED', + message: + 'Изменения не были применены. Возможно, данные идентичны текущим или проект недоступен', + }, + HttpStatus.INTERNAL_SERVER_ERROR, + ); + } + + // if (dto.settings) { + // await this.settingsRepo.update(project.id, dto.settings); + // } + + return { + success: result, + message: result ? 'Настройки проекта успешно обновлены' : 'Изменения не были применены', + }; + } +} diff --git a/src/projects/application/use-cases/set-project-status.use-case.ts b/src/projects/application/use-cases/set-project-status.use-case.ts deleted file mode 100644 index 7bc694e..0000000 --- a/src/projects/application/use-cases/set-project-status.use-case.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { ProjectStatus } from '@core/projects/domain/entities'; -import { ProjectAccessPolicy } from '@core/projects/domain/policy'; -import { IProjectsRepository } from '@core/projects/domain/repository'; -import { HttpStatus, Inject, Injectable } from '@nestjs/common'; -import { BaseException } from '@shared/error'; - -@Injectable() -export class SetProjectStatusUseCase { - constructor( - @Inject('IProjectsRepository') - private readonly projectsRepo: IProjectsRepository, - private readonly policy: ProjectAccessPolicy, - ) {} - - public async execute(slug: string, teamId: string, userId: string, status: ProjectStatus) { - const { project } = await this.policy.validateProjectAccess(slug, teamId, userId); - const result = await this.projectsRepo.update(project.id, { status }); - - if (!result) { - throw new BaseException( - { - code: 'STATUS_UPDATE_FAILED', - message: 'Не удалось обновить статус проекта', - details: [{ target: 'status', value: status }], - }, - HttpStatus.SERVICE_UNAVAILABLE, - ); - } - - const messages: Record = { - archived: `Проект «${project.name}» успешно архивирован`, - active: `Проект «${project.name}» теперь активен`, - template: `Проект «${project.name}» успешно сохранен как шаблон`, - }; - - return { - success: result, - message: messages[status] || `Статус проекта «${project.name}» изменен`, - }; - } -} diff --git a/src/projects/application/use-cases/states/get-state.query.ts b/src/projects/application/use-cases/states/get-state.query.ts deleted file mode 100644 index 4a0bb61..0000000 --- a/src/projects/application/use-cases/states/get-state.query.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { - ProjectErrorCodes, - ProjectErrorMessages, - ProjectStateErrorCodes, - ProjectStateErrorMessages, -} from '@core/projects/domain/errors'; -import { ProjectAccessPolicy } from '@core/projects/domain/policy'; -import { IProjectsRepository, IProjectStatesRepository } from '@core/projects/domain/repository'; -import { HttpStatus, Inject, Injectable } from '@nestjs/common'; -import { BaseException } from '@shared/error'; - -@Injectable() -export class GetStateQuery { - constructor( - @Inject('IProjectsRepository') - private readonly projectRepo: IProjectsRepository, - @Inject('IProjectStatesRepository') - private readonly projectStatesRepo: IProjectStatesRepository, - private readonly policy: ProjectAccessPolicy, - ) {} - - async execute(slug: string, stateId: string, userId: string) { - const project = await this.projectRepo.findOne(slug); - - if (!project) { - throw new BaseException( - { - code: ProjectErrorCodes.NOT_FOUND, - message: ProjectErrorMessages[ProjectErrorCodes.NOT_FOUND], - }, - HttpStatus.NOT_FOUND, - ); - } - - await this.policy.ensureTeamAccess(project.teamId, userId, 'viewer'); - - const state = await this.projectStatesRepo.findOne(project.id, stateId); - - if (!state) { - throw new BaseException( - { - code: ProjectStateErrorCodes.NOT_FOUND, - message: ProjectStateErrorMessages[ProjectStateErrorCodes.NOT_FOUND], - }, - HttpStatus.NOT_FOUND, - ); - } - - return state; - } -} diff --git a/src/projects/application/use-cases/states/get-states.query.ts b/src/projects/application/use-cases/states/get-states.query.ts deleted file mode 100644 index 85a0881..0000000 --- a/src/projects/application/use-cases/states/get-states.query.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { ProjectErrorCodes, ProjectErrorMessages } from '@core/projects/domain/errors'; -import { ProjectAccessPolicy } from '@core/projects/domain/policy'; -import { IProjectsRepository, IProjectStatesRepository } from '@core/projects/domain/repository'; -import { HttpStatus, Inject, Injectable } from '@nestjs/common'; -import { BaseException } from '@shared/error'; - -@Injectable() -export class GetStatesQuery { - constructor( - @Inject('IProjectsRepository') - private readonly projectsRepo: IProjectsRepository, - @Inject('IProjectStatesRepository') - private readonly projectStatesRepo: IProjectStatesRepository, - private readonly policy: ProjectAccessPolicy, - ) {} - - async execute(slug: string, query: unknown, userId: string) { - const project = await this.projectsRepo.findOne(slug); - - if (!project) { - throw new BaseException( - { - code: ProjectErrorCodes.NOT_FOUND, - message: ProjectErrorMessages[ProjectErrorCodes.NOT_FOUND], - }, - HttpStatus.NOT_FOUND, - ); - } - - await this.policy.ensureTeamAccess(project.teamId, userId, 'viewer'); - - const states = await this.projectStatesRepo.find(project.id, query); - - return states; - } -} diff --git a/src/projects/application/use-cases/states/reorder-states.use-case.ts b/src/projects/application/use-cases/states/reorder-states.use-case.ts deleted file mode 100644 index 6f8b00f..0000000 --- a/src/projects/application/use-cases/states/reorder-states.use-case.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { - ProjectErrorCodes, - ProjectErrorMessages, - ProjectStateErrorCodes, - ProjectStateErrorMessages, -} from '@core/projects/domain/errors'; -import { ProjectAccessPolicy } from '@core/projects/domain/policy'; -import { IProjectsRepository, IProjectStatesRepository } from '@core/projects/domain/repository'; -import { HttpStatus, Inject, Injectable } from '@nestjs/common'; -import { BaseException } from '@shared/error'; -import { ReorderProjectsStatesDto } from '../../dtos'; - -@Injectable() -export class ReorderStateUseCase { - constructor( - @Inject('IProjectsRepository') - private readonly projectsRepo: IProjectsRepository, - @Inject('IProjectStatesRepository') - private readonly projectStatesRepo: IProjectStatesRepository, - private readonly policy: ProjectAccessPolicy, - ) {} - - async execute(slug: string, dto: ReorderProjectsStatesDto, userId: string) { - const project = await this.projectsRepo.findOne(slug); - - if (!project) { - throw new BaseException( - { - code: ProjectErrorCodes.NOT_FOUND, - message: ProjectErrorMessages[ProjectErrorCodes.NOT_FOUND], - }, - HttpStatus.NOT_FOUND, - ); - } - - await this.policy.ensureTeamAccess(project.teamId, userId, 'admin'); - - const state = await this.projectStatesRepo.find(slug); - - if (!state) { - throw new BaseException( - { - code: ProjectStateErrorCodes.NOT_FOUND, - message: ProjectStateErrorMessages[ProjectStateErrorCodes.NOT_FOUND], - }, - HttpStatus.NOT_FOUND, - ); - } - - // TODO: ADD REODER STATES - void dto; - const result = true; - - return { - success: result, - message: result - ? 'Состояние успешно восстановлено' - : 'Не удалось восстановить состояние: запись не найдена или уже активна', - }; - } -} diff --git a/src/projects/application/use-cases/states/restore-state.use-state.ts b/src/projects/application/use-cases/states/restore-state.use-state.ts deleted file mode 100644 index 17a8046..0000000 --- a/src/projects/application/use-cases/states/restore-state.use-state.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { - ProjectErrorCodes, - ProjectErrorMessages, - ProjectStateErrorCodes, - ProjectStateErrorMessages, -} from '@core/projects/domain/errors'; -import { ProjectAccessPolicy } from '@core/projects/domain/policy'; -import { IProjectsRepository, IProjectStatesRepository } from '@core/projects/domain/repository'; -import { HttpStatus, Inject, Injectable } from '@nestjs/common'; -import { BaseException } from '@shared/error'; - -@Injectable() -export class RestoreStateUseCase { - constructor( - @Inject('IProjectsRepository') - private readonly projectsRepo: IProjectsRepository, - @Inject('IProjectStatesRepository') - private readonly projectStatesRepo: IProjectStatesRepository, - private readonly policy: ProjectAccessPolicy, - ) {} - - async execute(slug: string, stateId: string, userId: string) { - const project = await this.projectsRepo.findOne(slug); - - if (!project) { - throw new BaseException( - { - code: ProjectErrorCodes.NOT_FOUND, - message: ProjectErrorMessages[ProjectErrorCodes.NOT_FOUND], - }, - HttpStatus.NOT_FOUND, - ); - } - - await this.policy.ensureTeamAccess(project.teamId, userId, 'admin'); - - const state = await this.projectStatesRepo.findOne(slug, stateId, true); - - if (!state) { - throw new BaseException( - { - code: ProjectStateErrorCodes.NOT_FOUND, - message: ProjectStateErrorMessages[ProjectStateErrorCodes.NOT_FOUND], - }, - HttpStatus.NOT_FOUND, - ); - } - - const result = await this.projectStatesRepo.update(project.id, stateId, { - deletedAt: null, - }); - - return { - success: result, - message: result - ? 'Состояние успешно восстановлено' - : 'Не удалось восстановить состояние: запись не найдена или уже активна', - }; - } -} diff --git a/src/projects/application/use-cases/update-project.use-case.ts b/src/projects/application/use-cases/update-project.use-case.ts deleted file mode 100644 index 0e59ea9..0000000 --- a/src/projects/application/use-cases/update-project.use-case.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { HttpStatus, Inject, Injectable } from '@nestjs/common'; -import type { UpdateProjectDto } from '../dtos'; -import { BaseException } from '@shared/error'; -import { IProjectsRepository } from '@core/projects/domain/repository'; -import { ProjectAccessPolicy } from '@core/projects/domain/policy'; - -@Injectable() -export class UpdateProjectUseCase { - constructor( - @Inject('IProjectsRepository') - private readonly projectsRepo: IProjectsRepository, - private readonly policy: ProjectAccessPolicy, - ) {} - - public async execute( - projectSlug: string, - teamId: string, - userId: string, - dto: UpdateProjectDto, - ) { - const { project } = await this.policy.validateProjectAccess(projectSlug, teamId, userId); - const { isPublic, slug, ...data } = dto; - - const result = await this.projectsRepo.update(project.slug, { - ...data, - ...(slug && { slug: slug.toUpperCase() }), - ...(typeof isPublic === 'boolean' && { - visibility: isPublic ? 'public' : 'private', - }), - }); - - if (!result) { - throw new BaseException( - { - code: 'UPDATE_FAILED', - message: - 'Изменения не были применены. Возможно, данные идентичны текущим или проект недоступен', - }, - HttpStatus.BAD_REQUEST, - ); - } - - return { - success: result, - message: result ? 'Настройки проекта успешно обновлены' : 'Изменения не были применены', - }; - } -} diff --git a/src/projects/domain/entities/enum.ts b/src/projects/domain/entities/enum.ts new file mode 100644 index 0000000..215d696 --- /dev/null +++ b/src/projects/domain/entities/enum.ts @@ -0,0 +1,15 @@ +export const PROJECT_STATUSES = ['active', 'archived', 'template', 'deleted'] as const; +export const PROJECT_VISIBILITIES = ['public', 'private'] as const; +export const LAYOUTS = ['kanban', 'list', 'calendar', 'gantt'] as const; + +export const MEMBER_ROLES = [ + 'owner', // Владелец — создатель проекта, может всё, включая удаление + 'admin', // Админ — управляет участниками, настройками, может всё кроме удаления + 'editor', // Редактор — создает и редактирует задачи, меняет статусы, но не управляет людьми + 'member', // Участник — работает со своими задачами, комментирует + 'viewer', // Наблюдатель — только смотрит, комментирует +] as const; +export type MemberRole = (typeof MEMBER_ROLES)[number]; +export type ProjectStatus = (typeof PROJECT_STATUSES)[number]; +export type ProjectVisibility = (typeof PROJECT_VISIBILITIES)[number]; +export type Layout = (typeof LAYOUTS)[number]; diff --git a/src/projects/domain/entities/index.ts b/src/projects/domain/entities/index.ts index 0b3718b..8481671 100644 --- a/src/projects/domain/entities/index.ts +++ b/src/projects/domain/entities/index.ts @@ -1,2 +1,3 @@ export * from './project.domain'; -export * from './state.domain'; +export * from './member.domain'; +export * from './enum'; diff --git a/src/projects/domain/entities/member.domain.ts b/src/projects/domain/entities/member.domain.ts new file mode 100644 index 0000000..838a0c8 --- /dev/null +++ b/src/projects/domain/entities/member.domain.ts @@ -0,0 +1,15 @@ +import type { InferInsertModel, InferSelectModel } from 'drizzle-orm'; +import { projectMembers } from '@shared/entities'; + +export type Member = InferSelectModel; +export type NewMember = InferInsertModel; + +export interface MemberWithUser extends Member { + user: { + id: string; + email: string; + firstName: string | null; + lastName: string | null; + avatarUrl: string | null; + }; +} diff --git a/src/projects/domain/entities/project.domain.ts b/src/projects/domain/entities/project.domain.ts index 4df40c2..b28530a 100644 --- a/src/projects/domain/entities/project.domain.ts +++ b/src/projects/domain/entities/project.domain.ts @@ -1,19 +1,9 @@ import type { InferInsertModel, InferSelectModel } from 'drizzle-orm'; -import { projects, projectShares } from '../../infrastructure/persistence/models/projects.model'; - -export enum ProjectStatus { - Active = 'active', - Archived = 'archived', - Template = 'template', -} - -export enum ProjectVisibility { - Public = 'public', - Private = 'private', -} +import { projects, projectShares } from '../../infrastructure/persistence/models/project.model'; export type Project = InferSelectModel; export type NewProject = InferInsertModel; + export interface ProjectSettings { allowGuestComments?: boolean; defaultAssigneeId?: string; diff --git a/src/projects/domain/entities/state.domain.ts b/src/projects/domain/entities/state.domain.ts deleted file mode 100644 index 040fb20..0000000 --- a/src/projects/domain/entities/state.domain.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { projectStates } from '@shared/entities'; -import { InferInsertModel, InferSelectModel } from 'drizzle-orm'; - -export type ProjectState = InferSelectModel; -export type NewProjectState = InferInsertModel; diff --git a/src/projects/domain/errors/index.ts b/src/projects/domain/errors/index.ts index 73b4b28..a435f03 100644 --- a/src/projects/domain/errors/index.ts +++ b/src/projects/domain/errors/index.ts @@ -1,2 +1 @@ -export * from './state.errors'; export * from './project.errors'; diff --git a/src/projects/domain/errors/member.errors.ts b/src/projects/domain/errors/member.errors.ts new file mode 100644 index 0000000..e78052f --- /dev/null +++ b/src/projects/domain/errors/member.errors.ts @@ -0,0 +1,54 @@ +export const MemberErrorCodes = { + // 404 + NOT_FOUND: 'MEMBER.NOT_FOUND', + + // 409 — Conflict + ALREADY_EXISTS: 'MEMBER.ALREADY_EXISTS', + + // 400 — Bad Request + SELF_ADD: 'MEMBER.SELF_ADD', + SELF_REMOVE_OWNER: 'MEMBER.SELF_REMOVE_OWNER', + NOT_IN_TEAM: 'MEMBER.NOT_IN_TEAM', + INVALID_ROLE: 'MEMBER.INVALID_ROLE', + + // 403 — Forbidden + CANNOT_REMOVE_OWNER: 'MEMBER.CANNOT_REMOVE_OWNER', + CANNOT_CHANGE_OWNER: 'MEMBER.CANNOT_CHANGE_OWNER', + CANNOT_ASSIGN_OWNER: 'MEMBER.CANNOT_ASSIGN_OWNER', + ADMIN_REMOVE_FORBIDDEN: 'MEMBER.ADMIN_REMOVE_FORBIDDEN', + ADMIN_CHANGE_FORBIDDEN: 'MEMBER.ADMIN_CHANGE_FORBIDDEN', + ACCESS_DENIED: 'MEMBER.ACCESS_DENIED', + LIMIT_REACHED: 'MEMBER.LIMIT_REACHED', + + INSUFFICIENT_PERMISSIONS: 'MEMBER.INSUFFICIENT_PERMISSIONS', + + // 500 — Internal + CREATE_FAILED: 'MEMBER.CREATE_FAILED', + UPDATE_FAILED: 'MEMBER.UPDATE_FAILED', + DELETE_FAILED: 'MEMBER.DELETE_FAILED', +} as const; + +export type MemberErrorCode = (typeof MemberErrorCodes)[keyof typeof MemberErrorCodes]; + +export const MemberErrorMessages: Record = { + [MemberErrorCodes.NOT_FOUND]: 'Участник не найден в проекте', + [MemberErrorCodes.ALREADY_EXISTS]: 'Пользователь уже является участником проекта', + [MemberErrorCodes.SELF_ADD]: 'Нельзя добавить самого себя в участники', + [MemberErrorCodes.SELF_REMOVE_OWNER]: + 'Владелец не может покинуть проект. Сначала передайте права другому участнику', + [MemberErrorCodes.NOT_IN_TEAM]: 'Пользователь не является участником команды', + [MemberErrorCodes.INVALID_ROLE]: 'Недопустимая роль. Доступные роли: admin, member, viewer', + [MemberErrorCodes.CANNOT_REMOVE_OWNER]: 'Невозможно удалить владельца проекта', + [MemberErrorCodes.CANNOT_CHANGE_OWNER]: 'Невозможно изменить роль владельца проекта', + [MemberErrorCodes.CANNOT_ASSIGN_OWNER]: + 'Нельзя назначить роль владельца через этот метод. Используйте трансфер прав', + [MemberErrorCodes.ADMIN_REMOVE_FORBIDDEN]: 'Только владелец может удалить администратора', + [MemberErrorCodes.ADMIN_CHANGE_FORBIDDEN]: + 'Только владелец может назначать или снимать роль администратора', + [MemberErrorCodes.ACCESS_DENIED]: 'У вас нет доступа к участникам этого проекта', + [MemberErrorCodes.LIMIT_REACHED]: 'Достигнут лимит участников проекта', + [MemberErrorCodes.CREATE_FAILED]: 'Не удалось добавить участника', + [MemberErrorCodes.UPDATE_FAILED]: 'Не удалось обновить роль участника', + [MemberErrorCodes.DELETE_FAILED]: 'Не удалось удалить участника', + [MemberErrorCodes.INSUFFICIENT_PERMISSIONS]: 'Требуется одна из ролей', +} as const; diff --git a/src/projects/domain/errors/project.errors.ts b/src/projects/domain/errors/project.errors.ts index 3857bae..59ce391 100644 --- a/src/projects/domain/errors/project.errors.ts +++ b/src/projects/domain/errors/project.errors.ts @@ -1,21 +1,32 @@ export const ProjectErrorCodes = { + // 404 NOT_FOUND: 'PROJECT.NOT_FOUND', + + // 409 — Conflict SLUG_DUPLICATE: 'PROJECT.SLUG_DUPLICATE', - NAME_DUPLICATE: 'PROJECT.NAME_DUPLICATE', + ALREADY_ARCHIVED: 'PROJECT.ALREADY_ARCHIVED', + ALREADY_ACTIVE: 'PROJECT.ALREADY_ACTIVE', + + // 400 — Bad Request SLUG_INVALID: 'PROJECT.SLUG_INVALID', NAME_INVALID: 'PROJECT.NAME_INVALID', COLOR_INVALID: 'PROJECT.COLOR_INVALID', ICON_INVALID: 'PROJECT.ICON_INVALID', DESCRIPTION_TOO_LONG: 'PROJECT.DESCRIPTION_TOO_LONG', - MAX_PROJECTS_REACHED: 'PROJECT.MAX_PROJECTS_REACHED', - ALREADY_ARCHIVED: 'PROJECT.ALREADY_ARCHIVED', - ALREADY_ACTIVE: 'PROJECT.ALREADY_ACTIVE', - CANNOT_ARCHIVE_WITH_ACTIVE_TASKS: 'PROJECT.CANNOT_ARCHIVE_WITH_ACTIVE_TASKS', - CANNOT_DELETE_NOT_ARCHIVED: 'PROJECT.CANNOT_DELETE_NOT_ARCHIVED', - OWNER_NOT_IN_TEAM: 'PROJECT.OWNER_NOT_IN_TEAM', INVALID_VISIBILITY: 'PROJECT.INVALID_VISIBILITY', INVALID_STATUS: 'PROJECT.INVALID_STATUS', TEAM_REQUIRED: 'PROJECT.TEAM_REQUIRED', + + // 403 — Forbidden + MAX_PROJECTS_REACHED: 'PROJECT.MAX_PROJECTS_REACHED', + OWNER_NOT_IN_TEAM: 'PROJECT.OWNER_NOT_IN_TEAM', + + // 422 — Unprocessable + CANNOT_ARCHIVE_WITH_ACTIVE_TASKS: 'PROJECT.CANNOT_ARCHIVE_WITH_ACTIVE_TASKS', + CANNOT_DELETE_NOT_ARCHIVED: 'PROJECT.CANNOT_DELETE_NOT_ARCHIVED', + + // 500 — Internal + CREATE_FAILED: 'PROJECT.CREATE_FAILED', UPDATE_FAILED: 'PROJECT.UPDATE_FAILED', DELETE_FAILED: 'PROJECT.DELETE_FAILED', RESTORE_FAILED: 'PROJECT.RESTORE_FAILED', @@ -25,8 +36,11 @@ export type ProjectErrorCode = (typeof ProjectErrorCodes)[keyof typeof ProjectEr export const ProjectErrorMessages: Record = { [ProjectErrorCodes.NOT_FOUND]: 'Проект не найден', + [ProjectErrorCodes.SLUG_DUPLICATE]: 'Проект с таким ключом уже существует в команде', - [ProjectErrorCodes.NAME_DUPLICATE]: 'Проект с таким названием уже существует в команде', + [ProjectErrorCodes.ALREADY_ARCHIVED]: 'Проект уже находится в архиве', + [ProjectErrorCodes.ALREADY_ACTIVE]: 'Проект уже активен', + [ProjectErrorCodes.SLUG_INVALID]: 'Ключ проекта должен содержать только строчные латинские буквы и цифры (2-10 символов)', [ProjectErrorCodes.NAME_INVALID]: @@ -34,18 +48,20 @@ export const ProjectErrorMessages: Record = { [ProjectErrorCodes.COLOR_INVALID]: 'Цвет должен быть в формате HEX (например, #FFFFFF)', [ProjectErrorCodes.ICON_INVALID]: 'URL иконки слишком длинный (максимум 255 символов)', [ProjectErrorCodes.DESCRIPTION_TOO_LONG]: 'Описание слишком длинное (максимум 2000 символов)', + [ProjectErrorCodes.INVALID_VISIBILITY]: 'Недопустимый тип видимости проекта', + [ProjectErrorCodes.INVALID_STATUS]: 'Недопустимый статус проекта', + [ProjectErrorCodes.TEAM_REQUIRED]: 'ID команды обязателен', + [ProjectErrorCodes.MAX_PROJECTS_REACHED]: 'Достигнут лимит проектов в команде', - [ProjectErrorCodes.ALREADY_ARCHIVED]: 'Проект уже находится в архиве', - [ProjectErrorCodes.ALREADY_ACTIVE]: 'Проект уже активен', + [ProjectErrorCodes.OWNER_NOT_IN_TEAM]: 'Владелец проекта должен быть участником команды', + [ProjectErrorCodes.CANNOT_ARCHIVE_WITH_ACTIVE_TASKS]: 'Нельзя архивировать проект с активными задачами', [ProjectErrorCodes.CANNOT_DELETE_NOT_ARCHIVED]: 'Перед удалением проект необходимо архивировать', - [ProjectErrorCodes.OWNER_NOT_IN_TEAM]: 'Владелец проекта должен быть участником команды', - [ProjectErrorCodes.INVALID_VISIBILITY]: 'Недопустимый тип видимости проекта', - [ProjectErrorCodes.INVALID_STATUS]: 'Недопустимый статус проекта', - [ProjectErrorCodes.TEAM_REQUIRED]: 'ID команды обязателен', + + [ProjectErrorCodes.CREATE_FAILED]: 'Не удалось создать проект', [ProjectErrorCodes.UPDATE_FAILED]: 'Не удалось обновить проект', [ProjectErrorCodes.DELETE_FAILED]: 'Не удалось удалить проект', [ProjectErrorCodes.RESTORE_FAILED]: 'Не удалось восстановить проект', -}; +} as const; diff --git a/src/projects/domain/policy/project-access.policy.ts b/src/projects/domain/policy/project-access.policy.ts index a09ca68..b7f8da4 100644 --- a/src/projects/domain/policy/project-access.policy.ts +++ b/src/projects/domain/policy/project-access.policy.ts @@ -1,14 +1,19 @@ import { HttpStatus, Inject, Injectable } from '@nestjs/common'; -import { IProjectsRepository } from '../repository'; +import { IMemberRepository, IProjectRepository } from '../repository'; import { BaseException } from '@shared/error'; import { FindTeamMemberQuery, FindTeamQuery } from '@core/teams'; -import { ROLE_PRIORITY } from '@shared/constants'; +import { ROLE_PRIORITY, PROJECT_ROLE_PRIORITY } from '@shared/constants'; +import type { MemberRole } from '../entities'; +import { MemberErrorCodes, MemberErrorMessages } from '../errors/member.errors'; +import { ProjectErrorCodes, ProjectErrorMessages } from '../errors'; @Injectable() export class ProjectAccessPolicy { constructor( - @Inject('IProjectsRepository') - private readonly projectsRepo: IProjectsRepository, + @Inject('IProjectRepository') + private readonly projectRepo: IProjectRepository, + @Inject('IMemberRepository') + private readonly memberRepo: IMemberRepository, private readonly findTeamQ: FindTeamQuery, private readonly findTeamMemberQ: FindTeamMemberQuery, ) {} @@ -52,28 +57,51 @@ export class ProjectAccessPolicy { } /** - * Полная проверка доступа к конкретному проекту внутри команды + * Проверка доступа к проекту. + * Проверяет роль пользователя именно в проекте, а не в команде. */ - public async validateProjectAccess( + public async ensureProjectAccess( slug: string, - teamId: string, userId: string, - minRole: keyof typeof ROLE_PRIORITY = 'admin', + minRoles: MemberRole[] = ['viewer'], ) { - const { team, member } = await this.ensureTeamAccess(teamId, userId, minRole); - - const project = await this.projectsRepo.findOne(slug); - if (!project || project.teamId !== team.id) { + const project = await this.projectRepo.findBySlug(slug); + if (!project) { throw new BaseException( { - code: 'PROJECT_NOT_FOUND', - message: 'Проект не найден в этой команде', + code: ProjectErrorCodes.NOT_FOUND, + message: ProjectErrorMessages[ProjectErrorCodes.NOT_FOUND], }, HttpStatus.NOT_FOUND, ); } - return { project, member, team }; + const member = await this.memberRepo.findByProjectAndUser(project.id, userId); + if (!member) { + throw new BaseException( + { + code: MemberErrorCodes.ACCESS_DENIED, + message: MemberErrorMessages[MemberErrorCodes.ACCESS_DENIED], + }, + HttpStatus.FORBIDDEN, + ); + } + + const hasRole = minRoles.some( + (role) => PROJECT_ROLE_PRIORITY[member.role] >= PROJECT_ROLE_PRIORITY[role], + ); + + if (!hasRole) { + throw new BaseException( + { + code: MemberErrorCodes.INSUFFICIENT_PERMISSIONS, + message: `Требуется одна из ролей: ${minRoles.join(', ')}. Ваша роль: ${member.role}`, + }, + HttpStatus.FORBIDDEN, + ); + } + + return { project, member }; } /** @@ -84,7 +112,7 @@ export class ProjectAccessPolicy { userId: string, minRole: keyof typeof ROLE_PRIORITY = 'viewer', ) { - const project = await this.projectsRepo.findOne(slug); + const project = await this.projectRepo.findOne(slug); if (!project) { throw new BaseException( { code: 'PROJECT_NOT_FOUND', message: 'Проект не найден' }, diff --git a/src/projects/domain/repository/index.ts b/src/projects/domain/repository/index.ts index 078c432..d4fcc44 100644 --- a/src/projects/domain/repository/index.ts +++ b/src/projects/domain/repository/index.ts @@ -1,2 +1,2 @@ -export * from './projects.repository.interface'; -export * from './project-states.repository.interface'; +export * from './project.repository.interface'; +export * from './member.repository.interface'; diff --git a/src/projects/domain/repository/member.repository.interface.ts b/src/projects/domain/repository/member.repository.interface.ts new file mode 100644 index 0000000..558197a --- /dev/null +++ b/src/projects/domain/repository/member.repository.interface.ts @@ -0,0 +1,16 @@ +import type { Member, MemberRole, NewMember } from '../entities'; + +export interface IMemberRepository { + create(data: NewMember): Promise<{ id: string }>; + updateRole(memberId: string, role: MemberRole): Promise; + delete(memberId: string): Promise; + + findById(memberId: string): Promise; + findByProjectAndUser(projectId: string, userId: string): Promise; + findByProject(projectId: string): Promise; + + isMember(projectId: string, userId: string): Promise; + getUserRole(projectId: string, userId: string): Promise; + + countByProject(projectId: string): Promise; +} diff --git a/src/projects/domain/repository/project-states.repository.interface.ts b/src/projects/domain/repository/project-states.repository.interface.ts deleted file mode 100644 index f421af8..0000000 --- a/src/projects/domain/repository/project-states.repository.interface.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { NewProjectState, ProjectState } from '../entities'; - -export interface IProjectStatesRepository { - create(dto: NewProjectState): Promise<{ id: string }>; - update(projectId: string, stateId: string, dto: Partial): Promise; - delete(projectId: string, stateId: string): Promise; - findOne(projectId: string, stateId: string, deleted?: boolean): Promise; - find(projectId: string, query?: unknown): Promise; - - findByTitle(projectId: string, title: string): Promise; - - // TODO: FIX that any, to coerce - findByStateType(projectId: string, stateType: any): Promise; - - countByProject(projectId: string): Promise; -} diff --git a/src/projects/domain/repository/project.repository.interface.ts b/src/projects/domain/repository/project.repository.interface.ts new file mode 100644 index 0000000..95c9d10 --- /dev/null +++ b/src/projects/domain/repository/project.repository.interface.ts @@ -0,0 +1,17 @@ +import type { NewProject, NewProjectShare, Project } from '../entities'; + +export interface IProjectRepository { + create(userId: string, data: NewProject): Promise<{ result: boolean; slug: string }>; + update(teamId: string, projectId: string, data: Partial): Promise; + delete(teamId: string, projectId: string): Promise; + findOne(projectId: string, teamId?: string): Promise; + findByTeam(teamId: string): Promise; + createShare(data: NewProjectShare): Promise; + + findBySlug(slug: string, teamId?: string): Promise; + + hasValidShareToken(slug: string, token: string): Promise; + revokeAllShares(projectId: string): Promise; + + countByTeam(teamId: string): Promise; +} diff --git a/src/projects/domain/repository/projects.repository.interface.ts b/src/projects/domain/repository/projects.repository.interface.ts deleted file mode 100644 index c40781f..0000000 --- a/src/projects/domain/repository/projects.repository.interface.ts +++ /dev/null @@ -1,12 +0,0 @@ -import type { NewProject, NewProjectShare, Project } from '../entities'; - -export interface IProjectsRepository { - create(data: NewProject): Promise<{ result: boolean; slug: string }>; - update(slug: string, data: Partial): Promise; - delete(slug: string): Promise; - findOne(slug: string): Promise; - findByTeam(teamId: string): Promise; - createShare(data: NewProjectShare): Promise; - hasValidShareToken(slug: string, token: string): Promise; - revokeAllShares(projectId: string): Promise; -} diff --git a/src/projects/index.ts b/src/projects/index.ts index f17852e..c402167 100644 --- a/src/projects/index.ts +++ b/src/projects/index.ts @@ -1 +1,2 @@ export { ProjectsModule } from './projects.module'; +export { FindProjectQuery } from './application/use-cases/project'; diff --git a/src/projects/infrastructure/persistence/models/enum.ts b/src/projects/infrastructure/persistence/models/enum.ts new file mode 100644 index 0000000..626c10e --- /dev/null +++ b/src/projects/infrastructure/persistence/models/enum.ts @@ -0,0 +1,6 @@ +import { baseSchema } from '@shared/entities'; +import { LAYOUTS, PROJECT_STATUSES, PROJECT_VISIBILITIES } from '@core/projects/domain/entities'; + +export const projectStatusEnum = baseSchema.enum('project_status', PROJECT_STATUSES); +export const projectVisibilityEnum = baseSchema.enum('project_visibility', PROJECT_VISIBILITIES); +export const layoutEnum = baseSchema.enum('layout_type', LAYOUTS); diff --git a/src/projects/infrastructure/persistence/models/index.ts b/src/projects/infrastructure/persistence/models/index.ts index 08b849a..8809d89 100644 --- a/src/projects/infrastructure/persistence/models/index.ts +++ b/src/projects/infrastructure/persistence/models/index.ts @@ -1,2 +1,3 @@ -export { projectStatusEnum, projectVisibilityEnum } from './enums'; -export { projectShares, projects, projectStates } from './projects.model'; +export { projectStatusEnum, projectVisibilityEnum, layoutEnum } from './enum'; +export { projectShares, projectSettings, projects } from './project.model'; +export { projectMembers } from './member.model'; diff --git a/src/projects/infrastructure/persistence/models/member.model.ts b/src/projects/infrastructure/persistence/models/member.model.ts new file mode 100644 index 0000000..095c67b --- /dev/null +++ b/src/projects/infrastructure/persistence/models/member.model.ts @@ -0,0 +1,28 @@ +import { createId } from '@paralleldrive/cuid2'; +import { baseSchema, projects, users } from '@shared/entities'; +import { index, uniqueIndex, timestamp, varchar, text } from 'drizzle-orm/pg-core'; + +export const projectMembers = baseSchema.table( + 'project_members', + { + id: text('id') + .primaryKey() + .$defaultFn(() => createId()), + projectId: text('project_id') + .references(() => projects.id, { onDelete: 'cascade' }) + .notNull(), + userId: text('user_id') + .references(() => users.id, { onDelete: 'cascade' }) + .notNull(), + role: varchar('role', { length: 20 }).notNull().default('member'), + addedBy: text('added_by').references(() => users.id), + createdAt: timestamp('created_at', { withTimezone: true, mode: 'string' }) + .defaultNow() + .notNull(), + }, + (t) => ({ + uniqueMember: uniqueIndex('project_member_unique_idx').on(t.projectId, t.userId), + userIdx: index('project_member_user_idx').on(t.userId), + projectIdx: index('project_member_project_idx').on(t.projectId), + }), +); diff --git a/src/projects/infrastructure/persistence/models/projects.model.ts b/src/projects/infrastructure/persistence/models/project.model.ts similarity index 51% rename from src/projects/infrastructure/persistence/models/projects.model.ts rename to src/projects/infrastructure/persistence/models/project.model.ts index ae18a92..e3aa481 100644 --- a/src/projects/infrastructure/persistence/models/projects.model.ts +++ b/src/projects/infrastructure/persistence/models/project.model.ts @@ -1,22 +1,16 @@ import { text, - boolean, varchar, timestamp, - jsonb, integer, + boolean, uniqueIndex, index, } from 'drizzle-orm/pg-core'; import { baseSchema, teams, users } from '@shared/entities'; import { createId } from '@paralleldrive/cuid2'; -import { and, eq, isNotNull, isNull, not } from 'drizzle-orm'; -import { - projectStatusEnum, - projectVisibilityEnum, - stateCategoryEnum, - stateTypeEnum, -} from './enums'; +import { isNull } from 'drizzle-orm'; +import { layoutEnum, projectStatusEnum, projectVisibilityEnum } from './enum'; export const projects = baseSchema.table( 'projects', @@ -28,15 +22,15 @@ export const projects = baseSchema.table( .references(() => teams.id, { onDelete: 'cascade' }) .notNull(), slug: varchar('slug', { length: 100 }).notNull().unique(), - name: varchar('name', { length: 100 }).notNull().unique(), + name: varchar('name', { length: 100 }).notNull(), description: text('description'), + descriptionHtml: text('descriptionHtml'), icon: varchar('icon', { length: 255 }), color: varchar('color', { length: 7 }), status: projectStatusEnum('status').default('active').notNull(), - taskSequence: integer('task_sequence').default(0).notNull(), + sequence: integer('sequence').default(0), ownerId: text('owner_id').references(() => users.id, { onDelete: 'set null' }), visibility: projectVisibilityEnum('visibility').default('public').notNull(), - settings: jsonb('settings').default({}), createdAt: timestamp('created_at', { withTimezone: true, mode: 'string' }) .defaultNow() .notNull(), @@ -49,54 +43,48 @@ export const projects = baseSchema.table( uniqueTeamSlug: uniqueIndex('project_team_slug_idx') .on(t.teamId, t.slug) .where(isNull(t.deletedAt)), - uniqueTeamName: uniqueIndex('project_team_name_idx') - .on(t.teamId, t.name) - .where(isNull(t.deletedAt)), ownerIdx: index('project_owner_id_idx').on(t.ownerId), teamIdx: index('project_team_id_idx').on(t.teamId), }), ); -export const projectStates = baseSchema.table( - 'project_states', +export const projectSettings = baseSchema.table( + 'project_settings', { id: text('id') .primaryKey() .$defaultFn(() => createId()), - projectId: text('project_id').references(() => projects.id, { onDelete: 'cascade' }), - title: text('title').notNull(), - description: text('description'), - stateType: stateTypeEnum('state_type').notNull().default('custom'), - category: stateCategoryEnum('category').notNull().default('active'), - color: varchar('color', { length: 10 }), - icon: varchar('icon', { length: 20 }), - orderIndex: integer('order_index').notNull().default(0), - isVisible: boolean('is_visible').notNull().default(true), - maxTasksLimit: integer('max_tasks_limit'), - autoTransitionTo: text('auto_transition_to'), - notifyOnEnter: boolean('notify_on_enter').default(false), - notifyOnExit: boolean('notify_on_exit').default(false), - isLocked: boolean('is_locked').default(false), - createdAt: timestamp('created_at', { withTimezone: true, mode: 'string' }) - .notNull() - .defaultNow(), - updatedAt: timestamp('updated_at', { withTimezone: true, mode: 'string' }) + projectId: text('project_id') + .references(() => projects.id, { onDelete: 'cascade' }) .notNull() - .defaultNow(), - createdBy: text('created_by').references(() => users.id), - deletedAt: timestamp('deleted_at', { withTimezone: true, mode: 'string' }), + .unique(), + defaultView: layoutEnum('default_view').default('kanban').notNull(), + taskPrefix: varchar('task_prefix', { length: 10 }), + autoCloseDays: integer('auto_close_days'), + maxTasksPerArea: integer('max_tasks_per_area'), + maxMembers: integer('max_members'), + maxAreas: integer('max_areas'), + allowGuests: boolean('allow_guests').default(false), + timeTracking: boolean('time_tracking').default(false), + timeTrackingMode: varchar('time_tracking_mode', { length: 20 }).default('optional'), + defaultAssigneeId: text('default_assignee_id').references(() => users.id, { + onDelete: 'set null', + }), + createdAt: timestamp('created_at', { + withTimezone: true, + mode: 'string', + }) + .defaultNow() + .notNull(), + updatedAt: timestamp('updated_at', { + withTimezone: true, + mode: 'string', + }) + .defaultNow() + .notNull(), }, (t) => ({ - projectOrderIdx: index('idx_project_states_project_order').on(t.projectId, t.orderIndex), - uniqueProjectStateType: uniqueIndex('idx_project_states_unique_type') - .on(t.projectId, t.stateType) - .where(and(isNull(t.deletedAt), not(eq(t.stateType, 'custom')))), - uniqueProjectStateTitle: uniqueIndex('idx_project_states_unique_title') - .on(t.projectId, t.title) - .where(isNull(t.deletedAt)), - deletedAtIdx: index('idx_project_states_deleted_at') - .on(t.deletedAt) - .where(isNotNull(t.deletedAt)), + projectIdx: uniqueIndex('project_settings_project_idx').on(t.projectId), }), ); diff --git a/src/projects/infrastructure/persistence/repositories/index.ts b/src/projects/infrastructure/persistence/repositories/index.ts index 3393866..431500b 100644 --- a/src/projects/infrastructure/persistence/repositories/index.ts +++ b/src/projects/infrastructure/persistence/repositories/index.ts @@ -1,13 +1,13 @@ -import { ProjectStatesRepository } from './states.repository'; -import { ProjectsRepository } from './projects.repository'; +import { MemberRepository } from './member.repository'; +import { ProjectRepository } from './project.repository'; export const REPOSITORIES = [ { - provide: 'IProjectsRepository', - useClass: ProjectsRepository, + provide: 'IProjectRepository', + useClass: ProjectRepository, }, { - provide: 'IProjectStatesRepository', - useClass: ProjectStatesRepository, + provide: 'IMemberRepository', + useClass: MemberRepository, }, ]; diff --git a/src/projects/infrastructure/persistence/repositories/member.repository.ts b/src/projects/infrastructure/persistence/repositories/member.repository.ts new file mode 100644 index 0000000..108381d --- /dev/null +++ b/src/projects/infrastructure/persistence/repositories/member.repository.ts @@ -0,0 +1,113 @@ +import { DATABASE_SERVICE, DatabaseService } from '@libs/database'; +import { Inject, Injectable } from '@nestjs/common'; +import * as schema from '../models'; +import { and, eq, sql } from 'drizzle-orm'; +import type { MemberRole } from '@core/projects/domain/entities'; +import { IMemberRepository } from '@core/projects/domain/repository'; + +@Injectable() +export class MemberRepository implements IMemberRepository { + constructor( + @Inject(DATABASE_SERVICE) + private readonly db: DatabaseService, + ) {} + + public create = async (data: typeof schema.projectMembers.$inferInsert) => { + const [result] = await this.db + .insert(schema.projectMembers) + .values(data) + .returning({ id: schema.projectMembers.id }); + + return { id: result.id }; + }; + + public findById = async (memberId: string) => { + const [result] = await this.db + .select() + .from(schema.projectMembers) + .where(eq(schema.projectMembers.id, memberId)) + .limit(1); + + return result ?? null; + }; + + public findByProject = async (projectId: string) => { + return this.db + .select() + .from(schema.projectMembers) + .where(eq(schema.projectMembers.projectId, projectId)) + .orderBy(schema.projectMembers.createdAt); + }; + + async isMember(projectId: string, userId: string) { + const [result] = await this.db + .select({ one: sql`1` }) + .from(schema.projectMembers) + .where( + and( + eq(schema.projectMembers.projectId, projectId), + eq(schema.projectMembers.userId, userId), + ), + ) + .limit(1); + + return result !== undefined; + } + + public findByProjectAndUser = async (projectId: string, userId: string) => { + const [result] = await this.db + .select() + .from(schema.projectMembers) + .where( + and( + eq(schema.projectMembers.projectId, projectId), + eq(schema.projectMembers.userId, userId), + ), + ); + + return result || null; + }; + + async getUserRole(projectId: string, userId: string) { + const [result] = await this.db + .select({ role: schema.projectMembers.role }) + .from(schema.projectMembers) + .where( + and( + eq(schema.projectMembers.projectId, projectId), + eq(schema.projectMembers.userId, userId), + ), + ) + .limit(1); + + return (result?.role as MemberRole) ?? null; + } + + async countByProject(projectId: string) { + const [result] = await this.db + .select({ count: sql`count(*)` }) + .from(schema.projectMembers) + .where(eq(schema.projectMembers.projectId, projectId)); + + return result.count; + } + + async updateRole(memberId: string, role: MemberRole) { + const [result] = await this.db + .update(schema.projectMembers) + .set({ role }) + .where(eq(schema.projectMembers.id, memberId)) + .returning(); + + return result ?? null; + } + + async delete(memberId: string): Promise { + const [result] = await this.db + .delete(schema.projectMembers) + .where(eq(schema.projectMembers.id, memberId)) + .returning({ id: schema.projectMembers.id }); + + return result !== undefined; + } +} diff --git a/src/projects/infrastructure/persistence/repositories/project.repository.ts b/src/projects/infrastructure/persistence/repositories/project.repository.ts new file mode 100644 index 0000000..5d37851 --- /dev/null +++ b/src/projects/infrastructure/persistence/repositories/project.repository.ts @@ -0,0 +1,168 @@ +import { DATABASE_SERVICE, DatabaseService } from '@libs/database'; +import { Injectable, Inject } from '@nestjs/common'; +import * as schema from '../models'; +import { IProjectRepository } from '../../../domain/repository'; +import { and, count, eq, gt, isNull, or } from 'drizzle-orm'; +import type { NewProject, NewProjectShare } from '@core/projects/domain/entities'; + +@Injectable() +export class ProjectRepository implements IProjectRepository { + constructor( + @Inject(DATABASE_SERVICE) + private readonly db: DatabaseService, + ) {} + + public create = async (userId: string, data: NewProject) => { + const result = await this.db.transaction(async (tx) => { + const project = await tx + .insert(schema.projects) + .values(data) + .returning({ slug: schema.projects.slug, id: schema.projects.id }); + + const member = await tx + .insert(schema.projectMembers) + .values({ + projectId: project[0].id, + userId, + role: 'owner', + }) + .returning({ id: schema.projectMembers.id }); + + return { slug: project[0].slug, result: project.length > 0 && member.length > 0 }; + }); + + return result; + }; + + public update = async (teamId: string, projectId: string, data: Partial) => { + const result = await this.db + .update(schema.projects) + .set({ ...data, updatedAt: new Date().toISOString() }) + .where( + and( + eq(schema.projects.id, projectId), + eq(schema.projects.teamId, teamId), + isNull(schema.projects.deletedAt), + ), + ) + .returning({ id: schema.projects.id }); + + return result.length > 0; + }; + + public delete = async (teamId: string, projectId: string) => { + const result = await this.db + .update(schema.projects) + .set({ + status: 'deleted', + deletedAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }) + .where( + and( + eq(schema.projects.id, projectId), + eq(schema.projects.teamId, teamId), + isNull(schema.projects.deletedAt), + ), + ) + .returning({ id: schema.projects.id }); + + return result.length > 0; + }; + + public findOne = async (id: string, teamId?: string) => { + const [project] = await this.db + .select() + .from(schema.projects) + .where( + and( + eq(schema.projects.id, id), + isNull(schema.projects.deletedAt), + teamId ? eq(schema.projects.teamId, teamId) : undefined, + ), + ); + + return project || null; + }; + + public findBySlug = async (slug: string, teamId?: string) => { + const [project] = await this.db + .select() + .from(schema.projects) + .where( + and( + eq(schema.projects.slug, slug), + isNull(schema.projects.deletedAt), + teamId ? eq(schema.projects.teamId, teamId) : undefined, + ), + ); + + return project || null; + }; + + public findByTeam = async (teamId: string) => { + return this.db + .select() + .from(schema.projects) + .where(and(eq(schema.projects.teamId, teamId), isNull(schema.projects.deletedAt))); + }; + + public createShare = async (data: NewProjectShare) => { + const [result] = await this.db + .insert(schema.projectShares) + .values(data) + .onConflictDoUpdate({ + target: schema.projectShares.token, + set: { + expiresAt: data.expiresAt, + token: data.token, + }, + }) + .returning({ id: schema.projectShares.id }); + + return !!result; + }; + + public hasValidShareToken = async (id: string, token: string) => { + const [result] = await this.db + .select() + .from(schema.projectShares) + .where( + and( + eq(schema.projectShares.projectId, id), + eq(schema.projectShares.token, token), + or( + isNull(schema.projectShares.expiresAt), + gt(schema.projectShares.expiresAt, new Date().toISOString()), + ), + ), + ) + .limit(1); + + return !!result; + }; + + public revokeAllShares = async (projectId: string) => { + const result = await this.db + .delete(schema.projectShares) + .where(eq(schema.projectShares.projectId, projectId)) + .returning({ id: schema.projectShares.id }); + + return result.length > 0; + }; + + public countByTeam = async (teamId: string) => { + const [result] = await this.db + .select({ count: count() }) + .from(schema.projects) + .where( + and( + eq(schema.projects.teamId, teamId), + isNull(schema.projects.deletedAt), + eq(schema.projects.status, 'active'), + ), + ); + + return result.count; + }; +} diff --git a/src/projects/infrastructure/persistence/repositories/projects.repository.ts b/src/projects/infrastructure/persistence/repositories/projects.repository.ts deleted file mode 100644 index 915f319..0000000 --- a/src/projects/infrastructure/persistence/repositories/projects.repository.ts +++ /dev/null @@ -1,105 +0,0 @@ -import { DATABASE_SERVICE, DatabaseService } from '@libs/database'; -import { Injectable, Inject } from '@nestjs/common'; -import * as schema from '../models'; -import { IProjectsRepository } from '../../../domain/repository'; -import { and, eq, gt, isNull, or } from 'drizzle-orm'; -import type { NewProject, NewProjectShare } from '@core/projects/domain/entities'; - -@Injectable() -export class ProjectsRepository implements IProjectsRepository { - constructor( - @Inject(DATABASE_SERVICE) - private readonly db: DatabaseService, - ) {} - - public create = async (data: NewProject) => { - const result = await this.db - .insert(schema.projects) - .values(data) - .returning({ slug: schema.projects.slug }); - - return { result: result.length > 0, slug: result[0].slug }; - }; - - public update = async (id: string, data: Partial) => { - const result = await this.db - .update(schema.projects) - .set({ ...data, updatedAt: new Date().toISOString() }) - .where(eq(schema.projects.id, id)) - .returning({ id: schema.projects.id }); - - return result.length > 0; - }; - - public delete = async (id: string) => { - const result = await this.db - .update(schema.projects) - .set({ deletedAt: new Date().toISOString() }) - .where(eq(schema.projects.id, id)) - .returning({ id: schema.projects.id }); - - return result.length > 0; - }; - - public findOne = async (id: string) => { - const [project] = await this.db - .select() - .from(schema.projects) - .where(and(eq(schema.projects.id, id), isNull(schema.projects.deletedAt))); - - if (!project) return null; - - return project; - }; - - public findByTeam = async (teamId: string) => { - return this.db - .select() - .from(schema.projects) - .where(and(eq(schema.projects.teamId, teamId), isNull(schema.projects.deletedAt))); - }; - - public createShare = async (data: NewProjectShare) => { - const [result] = await this.db - .insert(schema.projectShares) - .values(data) - .onConflictDoUpdate({ - target: schema.projectShares.token, - set: { - expiresAt: data.expiresAt, - token: data.token, - }, - }) - .returning({ id: schema.projectShares.id }); - - return !!result; - }; - - public hasValidShareToken = async (id: string, token: string) => { - const [result] = await this.db - .select() - .from(schema.projectShares) - .where( - and( - eq(schema.projectShares.projectId, id), - eq(schema.projectShares.token, token), - or( - isNull(schema.projectShares.expiresAt), - gt(schema.projectShares.expiresAt, new Date().toISOString()), - ), - ), - ) - .limit(1); - - return !!result; - }; - - public revokeAllShares = async (projectId: string) => { - const result = await this.db - .delete(schema.projectShares) - .where(eq(schema.projectShares.projectId, projectId)) - .returning({ id: schema.projectShares.id }); - - return result.length > 0; - }; -} diff --git a/src/projects/infrastructure/persistence/repositories/states.repository.ts b/src/projects/infrastructure/persistence/repositories/states.repository.ts deleted file mode 100644 index 0c8697c..0000000 --- a/src/projects/infrastructure/persistence/repositories/states.repository.ts +++ /dev/null @@ -1,122 +0,0 @@ -import { Inject, Injectable } from '@nestjs/common'; -import { IProjectStatesRepository } from '@core/projects/domain/repository'; -import { DATABASE_SERVICE, DatabaseService } from '@libs/database'; -import * as schema from '../models'; -import { and, count, eq, isNotNull, isNull } from 'drizzle-orm'; -import type { NewProjectState } from '@core/projects/domain/entities'; - -@Injectable() -export class ProjectStatesRepository implements IProjectStatesRepository { - constructor( - @Inject(DATABASE_SERVICE) - private readonly db: DatabaseService, - ) {} - - public async create(data: NewProjectState) { - const [result] = await this.db - .insert(schema.projectStates) - .values(data) - .returning({ id: schema.projectStates.id }); - - return result; - } - - public async delete(projectId: string, stateId: string) { - const result = await this.db - .delete(schema.projectStates) - .where( - and( - eq(schema.projectStates.id, stateId), - eq(schema.projectStates.projectId, projectId), - isNotNull(schema.projectStates.deletedAt), - ), - ); - - return (result.count ?? 0) > 0; - } - - public async find(query: unknown) { - void query; - return this.db.select().from(schema.projectStates); - } - - public async findOne(projectId: string, stateId: string, deleted?: boolean) { - const [result] = await this.db - .select() - .from(schema.projectStates) - .where( - and( - eq(schema.projectStates.id, stateId), - eq(schema.projectStates.projectId, projectId), - deleted - ? isNotNull(schema.projectStates.deletedAt) - : isNull(schema.projectStates.deletedAt), - ), - ); - - return result ?? null; - } - - public async update(projectId: string, stateId: string, data: Partial) { - const result = await this.db - .update(schema.projectStates) - .set(data) - .where( - and( - eq(schema.projectStates.id, stateId), - eq(schema.projectStates.projectId, projectId), - isNull(schema.projectStates.deletedAt), - ), - ); - - return (result.count ?? 0) > 0; - } - - public async findByStateType( - projectId: string, - // TODO: ADD BASE ENUM TOO - stateType: 'custom' | 'archived' | 'backlog' | 'todo' | 'in_progress' | 'review' | 'done', - ) { - const [result] = await this.db - .select() - .from(schema.projectStates) - .where( - and( - eq(schema.projectStates.projectId, projectId), - eq(schema.projectStates.stateType, stateType), - isNull(schema.projectStates.deletedAt), - ), - ); - - return result ?? null; - } - - public async findByTitle(projectId: string, title: string) { - const [result] = await this.db - .select() - .from(schema.projectStates) - .where( - and( - eq(schema.projectStates.projectId, projectId), - eq(schema.projectStates.title, title), - isNull(schema.projectStates.deletedAt), - ), - ); - - return result ?? null; - } - - public async countByProject(projectId: string) { - const [result] = await this.db - .select({ count: count() }) - .from(schema.projectStates) - .where( - and( - eq(schema.projectStates.projectId, projectId), - isNull(schema.projectStates.deletedAt), - ), - ); - - return result.count; - } -} diff --git a/src/projects/projects.module.ts b/src/projects/projects.module.ts index 97ca4c3..133b9cf 100644 --- a/src/projects/projects.module.ts +++ b/src/projects/projects.module.ts @@ -3,13 +3,14 @@ import { TeamsModule } from '@core/teams'; import { CONTROLLERS } from './application/controller'; import { FindProjectQuery, USE_CASES } from './application/use-cases'; import { POLICIES, ProjectAccessPolicy } from './domain/policy'; -import { ProjectsFacade } from './application/projects.facade'; +import { ProjectFacade } from './application/project.facade'; import { REPOSITORIES } from './infrastructure/persistence/repositories'; +import { UserModule } from '@core/user'; @Module({ - imports: [forwardRef(() => TeamsModule)], + imports: [UserModule, forwardRef(() => TeamsModule)], controllers: CONTROLLERS, - providers: [...REPOSITORIES, ...POLICIES, ...USE_CASES, ProjectsFacade], + providers: [...REPOSITORIES, ...POLICIES, ...USE_CASES, ProjectFacade], exports: [FindProjectQuery, ProjectAccessPolicy], }) export class ProjectsModule {} diff --git a/src/shared/constants/roles.constant.ts b/src/shared/constants/roles.constant.ts index 1da5f2c..f6056d4 100644 --- a/src/shared/constants/roles.constant.ts +++ b/src/shared/constants/roles.constant.ts @@ -5,3 +5,11 @@ export const ROLE_PRIORITY: Record = { member: 1, viewer: 0, }; + +export const PROJECT_ROLE_PRIORITY: Record = { + owner: 4, + admin: 3, + editor: 2, + member: 1, + viewer: 0, +}; diff --git a/src/shared/decorators/skip-zod-validation.decorator.ts b/src/shared/decorators/skip-zod-validation.decorator.ts index 5911056..6b793d5 100644 --- a/src/shared/decorators/skip-zod-validation.decorator.ts +++ b/src/shared/decorators/skip-zod-validation.decorator.ts @@ -1,4 +1,4 @@ import { SetMetadata } from '@nestjs/common'; -export const SKIP_ZOD_VALIDATION = 'SKIP_ZOD_VALIDATION'; -export const SkipZodValidation = () => SetMetadata(SKIP_ZOD_VALIDATION, true); +export const SKIP_CONTRACT_HANDLE = 'SKIP_CONTRACT_HANDLE'; +export const SkipContractHandle = () => SetMetadata(SKIP_CONTRACT_HANDLE, true); diff --git a/src/shared/entities/index.ts b/src/shared/entities/index.ts index b50a6a2..f6b52ef 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 '../../area/infrastructure/persistence/models'; diff --git a/src/shared/interceptors/zod-validation.interceptor.ts b/src/shared/interceptors/zod-validation.interceptor.ts index ef89975..be9e136 100644 --- a/src/shared/interceptors/zod-validation.interceptor.ts +++ b/src/shared/interceptors/zod-validation.interceptor.ts @@ -9,7 +9,7 @@ import { Reflector } from '@nestjs/core'; import { map, Observable } from 'rxjs'; import { BaseException } from '@shared/error'; import { z } from 'zod/v4'; -import { SKIP_ZOD_VALIDATION } from '@shared/decorators'; +import { SKIP_CONTRACT_HANDLE } from '@shared/decorators'; export const ZOD_RESPONSE_TOKEN = 'ZOD_RESPONSE_TOKEN'; @@ -24,7 +24,7 @@ export class ZodValidationInterceptor implements NestInterceptor(SKIP_ZOD_VALIDATION, handler); + const skipValidation = this.reflector.get(SKIP_CONTRACT_HANDLE, handler); if (skipValidation) { return next.handle(); diff --git a/src/user/application/use-cases/find-by-ids.query.ts b/src/user/application/use-cases/find-by-ids.query.ts new file mode 100644 index 0000000..74cbf0c --- /dev/null +++ b/src/user/application/use-cases/find-by-ids.query.ts @@ -0,0 +1,11 @@ +import { IUserRepository } from '@core/user/domain/repository'; +import { Inject, Injectable } from '@nestjs/common'; + +@Injectable() +export class FindByIdsQuery { + constructor(@Inject('IUserRepository') private readonly userRepo: IUserRepository) {} + + async execute(ids: string[]) { + return this.userRepo.findByIds(ids); + } +} diff --git a/src/user/application/use-cases/index.ts b/src/user/application/use-cases/index.ts index 2939769..ae3ba88 100644 --- a/src/user/application/use-cases/index.ts +++ b/src/user/application/use-cases/index.ts @@ -7,6 +7,7 @@ import { UploadAvatarUseCase } from './upload-avatar.use-case'; import { FindProfileQuery } from './find-profile.query'; import { FindUserQuery } from './find-user.query'; import { GetActivityQuery } from './get-activity.query'; +import { FindByIdsQuery } from './find-by-ids.query'; export * from './register-user.use-case'; export * from './update-notifications.use-case'; @@ -17,6 +18,7 @@ export * from './upload-avatar.use-case'; export * from './find-profile.query'; export * from './find-user.query'; export * from './get-activity.query'; +export * from './find-by-ids.query'; export const UserUseCases = [ RegisterUserUseCase, @@ -26,6 +28,11 @@ export const UserUseCases = [ UploadAvatarUseCase, ]; -export const UserQueries = [FindProfileQuery, FindUserQuery, GetActivityQuery]; +export const UserQueries = [FindProfileQuery, FindByIdsQuery, FindUserQuery, GetActivityQuery]; -export const USER_EXTERNAL_USE_CASES = [RegisterUserUseCase, UpdatePasswordUseCase, FindUserQuery]; +export const USER_EXTERNAL_USE_CASES = [ + RegisterUserUseCase, + UpdatePasswordUseCase, + FindUserQuery, + FindByIdsQuery, +]; diff --git a/src/user/domain/repository/user.repository.interface.ts b/src/user/domain/repository/user.repository.interface.ts index 316db23..b1d4a07 100644 --- a/src/user/domain/repository/user.repository.interface.ts +++ b/src/user/domain/repository/user.repository.interface.ts @@ -12,6 +12,7 @@ import type { export interface IUserRepository { create(data: NewUser): Promise; findById(id: string): Promise; + findByIds(ids: string[]): Promise; findByEmail(email: string): Promise; findProfile(id: string): Promise; findActivityByUser( diff --git a/src/user/infrastructure/persistence/models/user.entity.ts b/src/user/infrastructure/persistence/models/user.entity.ts index 4e2afbb..6ba5e3f 100644 --- a/src/user/infrastructure/persistence/models/user.entity.ts +++ b/src/user/infrastructure/persistence/models/user.entity.ts @@ -28,6 +28,7 @@ export const users = baseSchema.table('users', { avatarUrl: varchar('avatar_url', { length: 512 }), emailVerified: boolean('email_verified').default(false).notNull(), emailVerifiedAt: timestamp('email_verified_at', { withTimezone: true, mode: 'string' }), + lastTeamId: text('last_team_id'), deletedAt: timestamp('deleted_at', { withTimezone: true, mode: 'string' }), createdAt: timestamp('created_at', { withTimezone: true, mode: 'string' }) .defaultNow() diff --git a/src/user/infrastructure/persistence/repositories/user.repository.ts b/src/user/infrastructure/persistence/repositories/user.repository.ts index 59dde06..8fc683f 100644 --- a/src/user/infrastructure/persistence/repositories/user.repository.ts +++ b/src/user/infrastructure/persistence/repositories/user.repository.ts @@ -3,7 +3,7 @@ import * as sc from '../models'; import { DATABASE_SERVICE, DatabaseService } from '@libs/database'; import { Inject, Injectable } from '@nestjs/common'; import { createId } from '@paralleldrive/cuid2'; -import { desc, eq, count } from 'drizzle-orm'; +import { desc, eq, count, inArray } from 'drizzle-orm'; import type { NewUser, NewUserActivity, @@ -48,6 +48,12 @@ export class UserRepository implements IUserRepository { }; } + async findByIds(ids: string[]) { + if (ids.length === 0) return []; + + return this.db.select().from(sc.users).where(inArray(sc.users.id, ids)); + } + async findById(id: string) { const [row] = await this.fullUserQuery.where(eq(sc.users.id, id)); if (!row || !row.user_security) return null; From d7b6f0868ff75fcb66c4e1b2617fb8beff863255 Mon Sep 17 00:00:00 2001 From: soorq Date: Sun, 14 Jun 2026 20:20:28 +0300 Subject: [PATCH 4/5] feat(areas): add full CRUD for areas and states and validation --- ...awhide_kid.sql => 0010_sour_praxagora.sql} | 18 +- migrations/0010_yummy_runaways.sql | 9 - ...hrike.sql => 0011_massive_black_queen.sql} | 0 ...good_pixie.sql => 0012_nasty_mandarin.sql} | 10 +- migrations/meta/0010_snapshot.json | 403 +++- migrations/meta/0011_snapshot.json | 232 +- migrations/meta/0012_snapshot.json | 316 ++- migrations/meta/0013_snapshot.json | 2007 ----------------- migrations/meta/_journal.json | 19 +- src/area/application/area.facade.ts | 6 +- .../controllers/area/controller.ts | 6 +- .../application/controllers/area/swagger.ts | 6 +- .../controllers/state/controller.ts | 11 + .../application/controllers/state/swagger.ts | 31 +- src/area/application/dtos/area.dto.ts | 17 +- src/area/application/dtos/states.dto.ts | 36 +- .../use-cases/areas/create.use-case.ts | 93 +- .../use-cases/areas/delete.use-case.ts | 70 +- .../use-cases/areas/get-all.query.ts | 17 +- .../use-cases/areas/get-one.query.ts | 42 +- .../use-cases/areas/update.use-case.ts | 169 +- .../use-cases/states/create.use-case.ts | 105 +- .../use-cases/states/delete.use-case.ts | 108 +- .../use-cases/states/get-all.query.ts | 10 +- .../use-cases/states/get-one.query.ts | 21 +- .../use-cases/states/reorder.use-case.ts | 48 +- .../use-cases/states/restore.use-state.ts | 52 +- .../use-cases/states/update.use-case.ts | 85 +- src/area/domain/entities/enum.ts | 36 + src/area/domain/entities/index.ts | 1 + src/area/domain/errors/area.errors.ts | 6 - src/area/domain/errors/state.errors.ts | 107 +- .../repository/area.repository.interface.ts | 6 +- src/area/infrastructure/constants/index.ts | 11 + .../persistence/models/area.model.ts | 49 +- .../infrastructure/persistence/models/enum.ts | 20 +- .../persistence/models/index.ts | 3 +- .../persistence/models/state.model.ts | 55 + .../repositories/area.repository.ts | 44 +- .../repositories/state.repository.ts | 13 +- .../use-cases/member/add.use-case.ts | 10 +- .../use-cases/member/delete.use-case.ts | 2 +- .../use-cases/member/update.use-case.ts | 2 +- .../use-cases/project/create.use-case.ts | 7 +- .../project/generate-share-token.use-case.ts | 12 +- src/projects/domain/errors/index.ts | 1 + .../infrastructure/constants/index.ts | 6 + src/shared/decorators/index.ts | 1 + src/shared/decorators/query-list.decorator.ts | 109 + src/shared/error/schema.ts | 2 +- src/shared/schemas/datetime.schema.ts | 28 + src/shared/schemas/index.ts | 5 +- ...esponse.schema.ts => pagination.schema.ts} | 37 + src/shared/schemas/search.schema.ts | 11 + src/shared/schemas/sorting.schema.ts | 28 + 55 files changed, 2037 insertions(+), 2522 deletions(-) rename migrations/{0011_closed_rawhide_kid.sql => 0010_sour_praxagora.sql} (89%) delete mode 100644 migrations/0010_yummy_runaways.sql rename migrations/{0012_military_killer_shrike.sql => 0011_massive_black_queen.sql} (100%) rename migrations/{0013_good_pixie.sql => 0012_nasty_mandarin.sql} (80%) delete mode 100644 migrations/meta/0013_snapshot.json create mode 100644 src/area/domain/entities/enum.ts create mode 100644 src/area/infrastructure/constants/index.ts create mode 100644 src/area/infrastructure/persistence/models/state.model.ts create mode 100644 src/projects/infrastructure/constants/index.ts create mode 100644 src/shared/decorators/query-list.decorator.ts create mode 100644 src/shared/schemas/datetime.schema.ts rename src/shared/schemas/{pagination-response.schema.ts => pagination.schema.ts} (53%) create mode 100644 src/shared/schemas/search.schema.ts create mode 100644 src/shared/schemas/sorting.schema.ts diff --git a/migrations/0011_closed_rawhide_kid.sql b/migrations/0010_sour_praxagora.sql similarity index 89% rename from migrations/0011_closed_rawhide_kid.sql rename to migrations/0010_sour_praxagora.sql index 6335098..9d5ca98 100644 --- a/migrations/0011_closed_rawhide_kid.sql +++ b/migrations/0010_sour_praxagora.sql @@ -37,6 +37,18 @@ CREATE TABLE CONSTRAINT "project_settings_project_id_unique" UNIQUE ("project_id") ); +ALTER TABLE "base"."board_columns" DISABLE ROW LEVEL SECURITY; + +ALTER TABLE "base"."boards_views" DISABLE ROW LEVEL SECURITY; + +ALTER TABLE "base"."boards" DISABLE ROW LEVEL SECURITY; + +DROP TABLE "base"."board_columns" CASCADE; + +DROP TABLE "base"."boards_views" CASCADE; + +DROP TABLE "base"."boards" CASCADE; + DROP INDEX "base"."project_team_key_idx"; DROP INDEX "base"."project_team_name_idx"; @@ -90,4 +102,8 @@ DROP COLUMN "task_sequence"; ALTER TABLE "base"."projects" DROP COLUMN "settings"; -ALTER TABLE "base"."projects" ADD CONSTRAINT "projects_slug_unique" UNIQUE ("slug"); \ No newline at end of file +ALTER TABLE "base"."projects" ADD CONSTRAINT "projects_slug_unique" UNIQUE ("slug"); + +DROP TYPE "base"."board_type"; + +DROP TYPE "base"."column_status"; \ No newline at end of file diff --git a/migrations/0010_yummy_runaways.sql b/migrations/0010_yummy_runaways.sql deleted file mode 100644 index e5eab56..0000000 --- a/migrations/0010_yummy_runaways.sql +++ /dev/null @@ -1,9 +0,0 @@ -DROP TABLE "base"."board_columns" CASCADE; - -DROP TABLE "base"."boards_views" CASCADE; - -DROP TABLE "base"."boards" CASCADE; - -DROP TYPE "base"."board_type"; - -DROP TYPE "base"."column_status"; \ No newline at end of file diff --git a/migrations/0012_military_killer_shrike.sql b/migrations/0011_massive_black_queen.sql similarity index 100% rename from migrations/0012_military_killer_shrike.sql rename to migrations/0011_massive_black_queen.sql diff --git a/migrations/0013_good_pixie.sql b/migrations/0012_nasty_mandarin.sql similarity index 80% rename from migrations/0013_good_pixie.sql rename to migrations/0012_nasty_mandarin.sql index f92acca..55f419f 100644 --- a/migrations/0013_good_pixie.sql +++ b/migrations/0012_nasty_mandarin.sql @@ -22,8 +22,8 @@ CREATE TABLE "area_id" text, "title" text NOT NULL, "description" text, - "state_type" "base"."state_type" DEFAULT 'custom' NOT NULL, - "category" "base"."state_category" DEFAULT 'active' NOT NULL, + "state_type" "state_type" DEFAULT 'custom' NOT NULL, + "category" "state_category" DEFAULT 'active' NOT NULL, "color" varchar(10), "icon" varchar(20), "position" integer DEFAULT 0 NOT NULL, @@ -51,6 +51,12 @@ ALTER TABLE "base"."states" ADD CONSTRAINT "states_created_by_users_id_fk" FOREI CREATE INDEX "idx_states_position" ON "base"."states" USING btree ("area_id", "position"); +CREATE INDEX "idx_states_title" ON "base"."states" USING btree ("area_id", "title"); + +CREATE INDEX "idx_states_created_at" ON "base"."states" USING btree ("area_id", "created_at"); + +CREATE INDEX "idx_states_search" ON "base"."states" USING btree ("area_id", "title"); + CREATE UNIQUE INDEX "idx_states_unique_title" ON "base"."states" USING btree ("area_id", "title") WHERE "base"."states"."deleted_at" is null; diff --git a/migrations/meta/0010_snapshot.json b/migrations/meta/0010_snapshot.json index 5cf4bc2..01a7fac 100644 --- a/migrations/meta/0010_snapshot.json +++ b/migrations/meta/0010_snapshot.json @@ -1,5 +1,5 @@ { - "id": "538a6952-f990-41f5-8300-a030d99d738d", + "id": "50986a1a-2bf7-4948-8064-9f740b84e1af", "prevId": "9afd9304-3a2f-40df-986d-b9fcd4f0e596", "version": "7", "dialect": "postgresql", @@ -358,6 +358,12 @@ "primaryKey": false, "notNull": false }, + "last_team_id": { + "name": "last_team_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, "deleted_at": { "name": "deleted_at", "type": "timestamp with time zone", @@ -834,6 +840,309 @@ "checkConstraints": {}, "isRLSEnabled": false }, + "base.project_members": { + "name": "project_members", + "schema": "base", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'member'" + }, + "added_by": { + "name": "added_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "project_member_unique_idx": { + "name": "project_member_unique_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "project_member_user_idx": { + "name": "project_member_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "project_member_project_idx": { + "name": "project_member_project_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "project_members_project_id_projects_id_fk": { + "name": "project_members_project_id_projects_id_fk", + "tableFrom": "project_members", + "tableTo": "projects", + "schemaTo": "base", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "project_members_user_id_users_id_fk": { + "name": "project_members_user_id_users_id_fk", + "tableFrom": "project_members", + "tableTo": "users", + "schemaTo": "base", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "project_members_added_by_users_id_fk": { + "name": "project_members_added_by_users_id_fk", + "tableFrom": "project_members", + "tableTo": "users", + "schemaTo": "base", + "columnsFrom": [ + "added_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "base.project_settings": { + "name": "project_settings", + "schema": "base", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "default_view": { + "name": "default_view", + "type": "layout_type", + "typeSchema": "base", + "primaryKey": false, + "notNull": true, + "default": "'kanban'" + }, + "task_prefix": { + "name": "task_prefix", + "type": "varchar(10)", + "primaryKey": false, + "notNull": false + }, + "auto_close_days": { + "name": "auto_close_days", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "max_tasks_per_area": { + "name": "max_tasks_per_area", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "max_members": { + "name": "max_members", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "max_areas": { + "name": "max_areas", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "allow_guests": { + "name": "allow_guests", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "time_tracking": { + "name": "time_tracking", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "time_tracking_mode": { + "name": "time_tracking_mode", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false, + "default": "'optional'" + }, + "default_assignee_id": { + "name": "default_assignee_id", + "type": "text", + "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()" + } + }, + "indexes": { + "project_settings_project_idx": { + "name": "project_settings_project_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "project_settings_project_id_projects_id_fk": { + "name": "project_settings_project_id_projects_id_fk", + "tableFrom": "project_settings", + "tableTo": "projects", + "schemaTo": "base", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "project_settings_default_assignee_id_users_id_fk": { + "name": "project_settings_default_assignee_id_users_id_fk", + "tableFrom": "project_settings", + "tableTo": "users", + "schemaTo": "base", + "columnsFrom": [ + "default_assignee_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "project_settings_project_id_unique": { + "name": "project_settings_project_id_unique", + "nullsNotDistinct": false, + "columns": [ + "project_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, "base.project_shares": { "name": "project_shares", "schema": "base", @@ -866,7 +1175,7 @@ "name": "created_by", "type": "text", "primaryKey": false, - "notNull": true + "notNull": false }, "created_at": { "name": "created_at", @@ -922,6 +1231,20 @@ ], "onDelete": "cascade", "onUpdate": "no action" + }, + "project_shares_created_by_users_id_fk": { + "name": "project_shares_created_by_users_id_fk", + "tableFrom": "project_shares", + "tableTo": "users", + "schemaTo": "base", + "columnsFrom": [ + "created_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" } }, "compositePrimaryKeys": {}, @@ -954,9 +1277,9 @@ "primaryKey": false, "notNull": true }, - "key": { - "name": "key", - "type": "varchar(10)", + "slug": { + "name": "slug", + "type": "varchar(100)", "primaryKey": false, "notNull": true }, @@ -972,6 +1295,12 @@ "primaryKey": false, "notNull": false }, + "descriptionHtml": { + "name": "descriptionHtml", + "type": "text", + "primaryKey": false, + "notNull": false + }, "icon": { "name": "icon", "type": "varchar(255)", @@ -992,11 +1321,11 @@ "notNull": true, "default": "'active'" }, - "task_sequence": { - "name": "task_sequence", + "sequence": { + "name": "sequence", "type": "integer", "primaryKey": false, - "notNull": true, + "notNull": false, "default": 0 }, "owner_id": { @@ -1013,13 +1342,6 @@ "notNull": true, "default": "'public'" }, - "settings": { - "name": "settings", - "type": "jsonb", - "primaryKey": false, - "notNull": false, - "default": "'{}'::jsonb" - }, "created_at": { "name": "created_at", "type": "timestamp with time zone", @@ -1042,30 +1364,8 @@ } }, "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", + "project_team_slug_idx": { + "name": "project_team_slug_idx", "columns": [ { "expression": "team_id", @@ -1074,7 +1374,7 @@ "nulls": "last" }, { - "expression": "name", + "expression": "slug", "isExpression": false, "asc": true, "nulls": "last" @@ -1148,7 +1448,15 @@ } }, "compositePrimaryKeys": {}, - "uniqueConstraints": {}, + "uniqueConstraints": { + "projects_slug_unique": { + "name": "projects_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "slug" + ] + } + }, "policies": {}, "checkConstraints": {}, "isRLSEnabled": false @@ -1176,13 +1484,24 @@ "inactive" ] }, + "base.layout_type": { + "name": "layout_type", + "schema": "base", + "values": [ + "kanban", + "list", + "calendar", + "gantt" + ] + }, "base.project_status": { "name": "project_status", "schema": "base", "values": [ "active", "archived", - "template" + "template", + "deleted" ] }, "base.project_visibility": { diff --git a/migrations/meta/0011_snapshot.json b/migrations/meta/0011_snapshot.json index 66c464c..288d3d4 100644 --- a/migrations/meta/0011_snapshot.json +++ b/migrations/meta/0011_snapshot.json @@ -1,6 +1,6 @@ { - "id": "cb4bcb9a-13e5-40ae-8289-fec4bd18a440", - "prevId": "538a6952-f990-41f5-8300-a030d99d738d", + "id": "017be6b7-8676-4f45-8572-efc4d2a02abc", + "prevId": "50986a1a-2bf7-4948-8064-9f740b84e1af", "version": "7", "dialect": "postgresql", "tables": { @@ -1460,6 +1460,234 @@ "policies": {}, "checkConstraints": {}, "isRLSEnabled": false + }, + "base.areas": { + "name": "areas", + "schema": "base", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description_html": { + "name": "description_html", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "color": { + "name": "color", + "type": "varchar(10)", + "primaryKey": false, + "notNull": false + }, + "tasks_count": { + "name": "tasks_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "default_view": { + "name": "default_view", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'kanban'" + }, + "icon": { + "name": "icon", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "max_tasks_limit": { + "name": "max_tasks_limit", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "is_locked": { + "name": "is_locked", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": 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()" + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_areas_slug": { + "name": "idx_areas_slug", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_areas_project_active": { + "name": "idx_areas_project_active", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "position", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"base\".\"areas\".\"deleted_at\" is null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_areas_created_by": { + "name": "idx_areas_created_by", + "columns": [ + { + "expression": "created_by", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"base\".\"areas\".\"deleted_at\" is null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_areas_deleted_at": { + "name": "idx_areas_deleted_at", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"base\".\"areas\".\"deleted_at\" is not null", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "areas_project_id_projects_id_fk": { + "name": "areas_project_id_projects_id_fk", + "tableFrom": "areas", + "tableTo": "projects", + "schemaTo": "base", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "areas_created_by_users_id_fk": { + "name": "areas_created_by_users_id_fk", + "tableFrom": "areas", + "tableTo": "users", + "schemaTo": "base", + "columnsFrom": [ + "created_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "areas_slug_unique": { + "name": "areas_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "slug" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false } }, "enums": { diff --git a/migrations/meta/0012_snapshot.json b/migrations/meta/0012_snapshot.json index d6e4ef3..4dd78d5 100644 --- a/migrations/meta/0012_snapshot.json +++ b/migrations/meta/0012_snapshot.json @@ -1,6 +1,6 @@ { - "id": "b9544b3c-75a2-47dd-ad39-d76008c7f340", - "prevId": "cb4bcb9a-13e5-40ae-8289-fec4bd18a440", + "id": "b7984130-22d6-4b1a-ba34-d695aafdb2e0", + "prevId": "017be6b7-8676-4f45-8572-efc4d2a02abc", "version": "7", "dialect": "postgresql", "tables": { @@ -1688,6 +1688,294 @@ "policies": {}, "checkConstraints": {}, "isRLSEnabled": false + }, + "base.states": { + "name": "states", + "schema": "base", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "area_id": { + "name": "area_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "state_type": { + "name": "state_type", + "type": "state_type", + "primaryKey": false, + "notNull": true, + "default": "'custom'" + }, + "category": { + "name": "category", + "type": "state_category", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "color": { + "name": "color", + "type": "varchar(10)", + "primaryKey": false, + "notNull": false + }, + "icon": { + "name": "icon", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "is_visible": { + "name": "is_visible", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "max_tasks_limit": { + "name": "max_tasks_limit", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "auto_transition_to": { + "name": "auto_transition_to", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "notify_on_enter": { + "name": "notify_on_enter", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "notify_on_exit": { + "name": "notify_on_exit", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "is_locked": { + "name": "is_locked", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": 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()" + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_states_position": { + "name": "idx_states_position", + "columns": [ + { + "expression": "area_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "position", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_states_title": { + "name": "idx_states_title", + "columns": [ + { + "expression": "area_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "title", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_states_created_at": { + "name": "idx_states_created_at", + "columns": [ + { + "expression": "area_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_states_search": { + "name": "idx_states_search", + "columns": [ + { + "expression": "area_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "title", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_states_unique_title": { + "name": "idx_states_unique_title", + "columns": [ + { + "expression": "area_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "title", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"base\".\"states\".\"deleted_at\" is null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_states_deleted_at": { + "name": "idx_states_deleted_at", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"base\".\"states\".\"deleted_at\" is not null", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "states_area_id_areas_id_fk": { + "name": "states_area_id_areas_id_fk", + "tableFrom": "states", + "tableTo": "areas", + "schemaTo": "base", + "columnsFrom": [ + "area_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "states_created_by_users_id_fk": { + "name": "states_created_by_users_id_fk", + "tableFrom": "states", + "tableTo": "users", + "schemaTo": "base", + "columnsFrom": [ + "created_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false } }, "enums": { @@ -1739,6 +2027,30 @@ "public", "private" ] + }, + "base.state_category": { + "name": "state_category", + "schema": "base", + "values": [ + "backlog", + "active", + "review", + "completed", + "archived" + ] + }, + "base.state_type": { + "name": "state_type", + "schema": "base", + "values": [ + "backlog", + "todo", + "in_progress", + "review", + "done", + "archived", + "custom" + ] } }, "schemas": { diff --git a/migrations/meta/0013_snapshot.json b/migrations/meta/0013_snapshot.json deleted file mode 100644 index ecf383d..0000000 --- a/migrations/meta/0013_snapshot.json +++ /dev/null @@ -1,2007 +0,0 @@ -{ - "id": "1721bc7b-29b1-4693-8d17-655402229992", - "prevId": "b9544b3c-75a2-47dd-ad39-d76008c7f340", - "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_preferences": { - "name": "user_preferences", - "schema": "base", - "columns": { - "user_id": { - "name": "user_id", - "type": "text", - "primaryKey": true, - "notNull": true - }, - "theme": { - "name": "theme", - "type": "text", - "primaryKey": false, - "notNull": false, - "default": "'system'" - }, - "timezone": { - "name": "timezone", - "type": "varchar(50)", - "primaryKey": false, - "notNull": true, - "default": "'UTC'" - }, - "language": { - "name": "language", - "type": "varchar(5)", - "primaryKey": false, - "notNull": true, - "default": "'ru'" - } - }, - "indexes": {}, - "foreignKeys": { - "user_preferences_user_id_users_id_fk": { - "name": "user_preferences_user_id_users_id_fk", - "tableFrom": "user_preferences", - "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": false - }, - "recovery_email": { - "name": "recovery_email", - "type": "varchar(255)", - "primaryKey": false, - "notNull": false - }, - "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_login_at": { - "name": "last_login_at", - "type": "timestamp with time zone", - "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 - }, - "username": { - "name": "username", - "type": "varchar(50)", - "primaryKey": false, - "notNull": false - }, - "headline": { - "name": "headline", - "type": "varchar(200)", - "primaryKey": false, - "notNull": false - }, - "location": { - "name": "location", - "type": "varchar(255)", - "primaryKey": false, - "notNull": false - }, - "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 - }, - "phone": { - "name": "phone", - "type": "varchar(20)", - "primaryKey": false, - "notNull": false - }, - "vacation_start": { - "name": "vacation_start", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": false - }, - "vacation_end": { - "name": "vacation_end", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": false - }, - "vacation_message": { - "name": "vacation_message", - "type": "varchar(255)", - "primaryKey": false, - "notNull": false - }, - "gender": { - "name": "gender", - "type": "text", - "primaryKey": false, - "notNull": false, - "default": "'none'" - }, - "pronouns": { - "name": "pronouns", - "type": "text", - "primaryKey": false, - "notNull": false, - "default": "'none'" - }, - "pronouns_custom": { - "name": "pronouns_custom", - "type": "varchar(50)", - "primaryKey": false, - "notNull": false - }, - "avatar_url": { - "name": "avatar_url", - "type": "varchar(512)", - "primaryKey": false, - "notNull": false - }, - "email_verified": { - "name": "email_verified", - "type": "boolean", - "primaryKey": false, - "notNull": true, - "default": false - }, - "email_verified_at": { - "name": "email_verified_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": false - }, - "last_team_id": { - "name": "last_team_id", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "deleted_at": { - "name": "deleted_at", - "type": "timestamp with time zone", - "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()" - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": { - "users_username_unique": { - "name": "users_username_unique", - "nullsNotDistinct": false, - "columns": [ - "username" - ] - }, - "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.user_identities": { - "name": "user_identities", - "schema": "base", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true - }, - "user_id": { - "name": "user_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "provider": { - "name": "provider", - "type": "varchar(50)", - "primaryKey": false, - "notNull": true - }, - "provider_user_id": { - "name": "provider_user_id", - "type": "varchar(255)", - "primaryKey": false, - "notNull": true - }, - "email": { - "name": "email", - "type": "varchar(255)", - "primaryKey": false, - "notNull": true - }, - "avatar_url": { - "name": "avatar_url", - "type": "varchar(255)", - "primaryKey": false, - "notNull": false - }, - "created_at": { - "name": "created_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": {}, - "foreignKeys": { - "user_identities_user_id_users_id_fk": { - "name": "user_identities_user_id_users_id_fk", - "tableFrom": "user_identities", - "tableTo": "users", - "schemaTo": "base", - "columnsFrom": [ - "user_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": { - "provider_user_id_idx": { - "name": "provider_user_id_idx", - "nullsNotDistinct": false, - "columns": [ - "provider", - "provider_user_id" - ] - } - }, - "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 with time zone", - "primaryKey": false, - "notNull": false - }, - "created_at": { - "name": "created_at", - "type": "timestamp with time zone", - "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 - }, - "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 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()" - }, - "deleted_at": { - "name": "deleted_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": false - } - }, - "indexes": { - "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": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "base.project_members": { - "name": "project_members", - "schema": "base", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true - }, - "project_id": { - "name": "project_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "user_id": { - "name": "user_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "role": { - "name": "role", - "type": "varchar(20)", - "primaryKey": false, - "notNull": true, - "default": "'member'" - }, - "added_by": { - "name": "added_by", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "created_at": { - "name": "created_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": { - "project_member_unique_idx": { - "name": "project_member_unique_idx", - "columns": [ - { - "expression": "project_id", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "user_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": true, - "concurrently": false, - "method": "btree", - "with": {} - }, - "project_member_user_idx": { - "name": "project_member_user_idx", - "columns": [ - { - "expression": "user_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "project_member_project_idx": { - "name": "project_member_project_idx", - "columns": [ - { - "expression": "project_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": { - "project_members_project_id_projects_id_fk": { - "name": "project_members_project_id_projects_id_fk", - "tableFrom": "project_members", - "tableTo": "projects", - "schemaTo": "base", - "columnsFrom": [ - "project_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - }, - "project_members_user_id_users_id_fk": { - "name": "project_members_user_id_users_id_fk", - "tableFrom": "project_members", - "tableTo": "users", - "schemaTo": "base", - "columnsFrom": [ - "user_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - }, - "project_members_added_by_users_id_fk": { - "name": "project_members_added_by_users_id_fk", - "tableFrom": "project_members", - "tableTo": "users", - "schemaTo": "base", - "columnsFrom": [ - "added_by" - ], - "columnsTo": [ - "id" - ], - "onDelete": "no action", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "base.project_settings": { - "name": "project_settings", - "schema": "base", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true - }, - "project_id": { - "name": "project_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "default_view": { - "name": "default_view", - "type": "layout_type", - "typeSchema": "base", - "primaryKey": false, - "notNull": true, - "default": "'kanban'" - }, - "task_prefix": { - "name": "task_prefix", - "type": "varchar(10)", - "primaryKey": false, - "notNull": false - }, - "auto_close_days": { - "name": "auto_close_days", - "type": "integer", - "primaryKey": false, - "notNull": false - }, - "max_tasks_per_area": { - "name": "max_tasks_per_area", - "type": "integer", - "primaryKey": false, - "notNull": false - }, - "max_members": { - "name": "max_members", - "type": "integer", - "primaryKey": false, - "notNull": false - }, - "max_areas": { - "name": "max_areas", - "type": "integer", - "primaryKey": false, - "notNull": false - }, - "allow_guests": { - "name": "allow_guests", - "type": "boolean", - "primaryKey": false, - "notNull": false, - "default": false - }, - "time_tracking": { - "name": "time_tracking", - "type": "boolean", - "primaryKey": false, - "notNull": false, - "default": false - }, - "time_tracking_mode": { - "name": "time_tracking_mode", - "type": "varchar(20)", - "primaryKey": false, - "notNull": false, - "default": "'optional'" - }, - "default_assignee_id": { - "name": "default_assignee_id", - "type": "text", - "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()" - } - }, - "indexes": { - "project_settings_project_idx": { - "name": "project_settings_project_idx", - "columns": [ - { - "expression": "project_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": true, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": { - "project_settings_project_id_projects_id_fk": { - "name": "project_settings_project_id_projects_id_fk", - "tableFrom": "project_settings", - "tableTo": "projects", - "schemaTo": "base", - "columnsFrom": [ - "project_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - }, - "project_settings_default_assignee_id_users_id_fk": { - "name": "project_settings_default_assignee_id_users_id_fk", - "tableFrom": "project_settings", - "tableTo": "users", - "schemaTo": "base", - "columnsFrom": [ - "default_assignee_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "set null", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": { - "project_settings_project_id_unique": { - "name": "project_settings_project_id_unique", - "nullsNotDistinct": false, - "columns": [ - "project_id" - ] - } - }, - "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": false - }, - "created_at": { - "name": "created_at", - "type": "timestamp with time zone", - "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" - }, - "project_shares_created_by_users_id_fk": { - "name": "project_shares_created_by_users_id_fk", - "tableFrom": "project_shares", - "tableTo": "users", - "schemaTo": "base", - "columnsFrom": [ - "created_by" - ], - "columnsTo": [ - "id" - ], - "onDelete": "no action", - "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 - }, - "slug": { - "name": "slug", - "type": "varchar(100)", - "primaryKey": false, - "notNull": true - }, - "name": { - "name": "name", - "type": "varchar(100)", - "primaryKey": false, - "notNull": true - }, - "description": { - "name": "description", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "descriptionHtml": { - "name": "descriptionHtml", - "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'" - }, - "sequence": { - "name": "sequence", - "type": "integer", - "primaryKey": false, - "notNull": false, - "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'" - }, - "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()" - }, - "deleted_at": { - "name": "deleted_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": false - } - }, - "indexes": { - "project_team_slug_idx": { - "name": "project_team_slug_idx", - "columns": [ - { - "expression": "team_id", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "slug", - "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": { - "projects_slug_unique": { - "name": "projects_slug_unique", - "nullsNotDistinct": false, - "columns": [ - "slug" - ] - } - }, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "base.areas": { - "name": "areas", - "schema": "base", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true - }, - "project_id": { - "name": "project_id", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "title": { - "name": "title", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "slug": { - "name": "slug", - "type": "varchar(100)", - "primaryKey": false, - "notNull": true - }, - "description": { - "name": "description", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "description_html": { - "name": "description_html", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "color": { - "name": "color", - "type": "varchar(10)", - "primaryKey": false, - "notNull": false - }, - "tasks_count": { - "name": "tasks_count", - "type": "integer", - "primaryKey": false, - "notNull": true, - "default": 0 - }, - "default_view": { - "name": "default_view", - "type": "varchar(20)", - "primaryKey": false, - "notNull": true, - "default": "'kanban'" - }, - "icon": { - "name": "icon", - "type": "varchar(20)", - "primaryKey": false, - "notNull": false - }, - "position": { - "name": "position", - "type": "integer", - "primaryKey": false, - "notNull": true, - "default": 0 - }, - "max_tasks_limit": { - "name": "max_tasks_limit", - "type": "integer", - "primaryKey": false, - "notNull": false - }, - "is_locked": { - "name": "is_locked", - "type": "boolean", - "primaryKey": false, - "notNull": false, - "default": 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()" - }, - "created_by": { - "name": "created_by", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "deleted_at": { - "name": "deleted_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": false - } - }, - "indexes": { - "idx_areas_slug": { - "name": "idx_areas_slug", - "columns": [ - { - "expression": "slug", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "idx_areas_project_active": { - "name": "idx_areas_project_active", - "columns": [ - { - "expression": "project_id", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "position", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "where": "\"base\".\"areas\".\"deleted_at\" is null", - "concurrently": false, - "method": "btree", - "with": {} - }, - "idx_areas_created_by": { - "name": "idx_areas_created_by", - "columns": [ - { - "expression": "created_by", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "where": "\"base\".\"areas\".\"deleted_at\" is null", - "concurrently": false, - "method": "btree", - "with": {} - }, - "idx_areas_deleted_at": { - "name": "idx_areas_deleted_at", - "columns": [ - { - "expression": "deleted_at", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "where": "\"base\".\"areas\".\"deleted_at\" is not null", - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": { - "areas_project_id_projects_id_fk": { - "name": "areas_project_id_projects_id_fk", - "tableFrom": "areas", - "tableTo": "projects", - "schemaTo": "base", - "columnsFrom": [ - "project_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - }, - "areas_created_by_users_id_fk": { - "name": "areas_created_by_users_id_fk", - "tableFrom": "areas", - "tableTo": "users", - "schemaTo": "base", - "columnsFrom": [ - "created_by" - ], - "columnsTo": [ - "id" - ], - "onDelete": "no action", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": { - "areas_slug_unique": { - "name": "areas_slug_unique", - "nullsNotDistinct": false, - "columns": [ - "slug" - ] - } - }, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "base.states": { - "name": "states", - "schema": "base", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true - }, - "area_id": { - "name": "area_id", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "title": { - "name": "title", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "description": { - "name": "description", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "state_type": { - "name": "state_type", - "type": "state_type", - "typeSchema": "base", - "primaryKey": false, - "notNull": true, - "default": "'custom'" - }, - "category": { - "name": "category", - "type": "state_category", - "typeSchema": "base", - "primaryKey": false, - "notNull": true, - "default": "'active'" - }, - "color": { - "name": "color", - "type": "varchar(10)", - "primaryKey": false, - "notNull": false - }, - "icon": { - "name": "icon", - "type": "varchar(20)", - "primaryKey": false, - "notNull": false - }, - "position": { - "name": "position", - "type": "integer", - "primaryKey": false, - "notNull": true, - "default": 0 - }, - "is_visible": { - "name": "is_visible", - "type": "boolean", - "primaryKey": false, - "notNull": true, - "default": true - }, - "max_tasks_limit": { - "name": "max_tasks_limit", - "type": "integer", - "primaryKey": false, - "notNull": false - }, - "auto_transition_to": { - "name": "auto_transition_to", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "notify_on_enter": { - "name": "notify_on_enter", - "type": "boolean", - "primaryKey": false, - "notNull": false, - "default": false - }, - "notify_on_exit": { - "name": "notify_on_exit", - "type": "boolean", - "primaryKey": false, - "notNull": false, - "default": false - }, - "is_locked": { - "name": "is_locked", - "type": "boolean", - "primaryKey": false, - "notNull": false, - "default": 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()" - }, - "created_by": { - "name": "created_by", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "deleted_at": { - "name": "deleted_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": false - } - }, - "indexes": { - "idx_states_position": { - "name": "idx_states_position", - "columns": [ - { - "expression": "area_id", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "position", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "idx_states_unique_title": { - "name": "idx_states_unique_title", - "columns": [ - { - "expression": "area_id", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "title", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": true, - "where": "\"base\".\"states\".\"deleted_at\" is null", - "concurrently": false, - "method": "btree", - "with": {} - }, - "idx_states_deleted_at": { - "name": "idx_states_deleted_at", - "columns": [ - { - "expression": "deleted_at", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "where": "\"base\".\"states\".\"deleted_at\" is not null", - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": { - "states_area_id_areas_id_fk": { - "name": "states_area_id_areas_id_fk", - "tableFrom": "states", - "tableTo": "areas", - "schemaTo": "base", - "columnsFrom": [ - "area_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - }, - "states_created_by_users_id_fk": { - "name": "states_created_by_users_id_fk", - "tableFrom": "states", - "tableTo": "users", - "schemaTo": "base", - "columnsFrom": [ - "created_by" - ], - "columnsTo": [ - "id" - ], - "onDelete": "no action", - "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.layout_type": { - "name": "layout_type", - "schema": "base", - "values": [ - "kanban", - "list", - "calendar", - "gantt" - ] - }, - "base.project_status": { - "name": "project_status", - "schema": "base", - "values": [ - "active", - "archived", - "template", - "deleted" - ] - }, - "base.project_visibility": { - "name": "project_visibility", - "schema": "base", - "values": [ - "public", - "private" - ] - }, - "base.state_category": { - "name": "state_category", - "schema": "base", - "values": [ - "backlog", - "active", - "review", - "completed", - "archived" - ] - }, - "base.state_type": { - "name": "state_type", - "schema": "base", - "values": [ - "backlog", - "todo", - "in_progress", - "review", - "done", - "archived", - "custom" - ] - } - }, - "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 d4e318e..f7ae003 100644 --- a/migrations/meta/_journal.json +++ b/migrations/meta/_journal.json @@ -75,29 +75,22 @@ { "idx": 10, "version": "7", - "when": 1780857935273, - "tag": "0010_yummy_runaways", + "when": 1781457335805, + "tag": "0010_sour_praxagora", "breakpoints": false }, { "idx": 11, "version": "7", - "when": 1781308288700, - "tag": "0011_closed_rawhide_kid", + "when": 1781457351595, + "tag": "0011_massive_black_queen", "breakpoints": false }, { "idx": 12, "version": "7", - "when": 1781308305488, - "tag": "0012_military_killer_shrike", - "breakpoints": false - }, - { - "idx": 13, - "version": "7", - "when": 1781308313721, - "tag": "0013_good_pixie", + "when": 1781457356804, + "tag": "0012_nasty_mandarin", "breakpoints": false } ] diff --git a/src/area/application/area.facade.ts b/src/area/application/area.facade.ts index 969c470..5a721c7 100644 --- a/src/area/application/area.facade.ts +++ b/src/area/application/area.facade.ts @@ -53,12 +53,12 @@ export class AreaFacade { return this.deleteAreaUC.execute(slug, key, userId); } - public async getAreas(slug: string, deleted: boolean, userId: string) { - return this.getAreasQ.execute(slug, deleted, userId); + public async getAreas(slug: string, userId: string, query: unknown) { + return this.getAreasQ.execute(slug, userId, query); } public async getArea(slug: string, key: string, userId: string) { - return this.getAreaQ.execute(slug, key, userId); + return this.getAreaQ.execute({ projectSlug: slug, key }, userId); } public async createState(slug: string, dto: CreateStateDto, userId: string) { diff --git a/src/area/application/controllers/area/controller.ts b/src/area/application/controllers/area/controller.ts index 6145cd4..dbd38eb 100644 --- a/src/area/application/controllers/area/controller.ts +++ b/src/area/application/controllers/area/controller.ts @@ -31,10 +31,10 @@ export class AreaController { @GetUserId() userId: string, @Query('deleted') deleted?: string, ) { - return this.facade.getAreas(slug, deleted === 'true', userId); + return this.facade.getAreas(slug, userId, deleted === 'true'); } - @Get(':slug') + @Get(':key') @FindOneAreaSwagger() async findOne( @Param('slug') slug: string, @@ -44,7 +44,7 @@ export class AreaController { return this.facade.getArea(slug, key, userId); } - @Delete(':slug') + @Delete(':key') @DeleteAreaSwagger() async delete( @Param('slug') slug: string, diff --git a/src/area/application/controllers/area/swagger.ts b/src/area/application/controllers/area/swagger.ts index 42f5cc5..772b9a7 100644 --- a/src/area/application/controllers/area/swagger.ts +++ b/src/area/application/controllers/area/swagger.ts @@ -9,7 +9,7 @@ import { ApiConflict, } from '@shared/error'; import { ZOD_RESPONSE_TOKEN } from '@shared/interceptors'; -import { CreateAreaDto, UpdateAreaDto, AreaResponse } from '../../dtos'; +import { CreateAreaDto, UpdateAreaDto, AreaResponse, AreasResponse } from '../../dtos'; export const CreateAreaSwagger = () => applyDecorators( @@ -89,12 +89,12 @@ export const FindAllAreasSwagger = () => ApiResponse({ status: 200, description: 'Список областей получен', - type: [AreaResponse.Output], + type: AreasResponse.Output, }), ApiUnauthorized(), ApiNotFound('Проект не найден'), - SetMetadata(ZOD_RESPONSE_TOKEN, AreaResponse), + SetMetadata(ZOD_RESPONSE_TOKEN, AreasResponse), ); export const FindOneAreaSwagger = () => diff --git a/src/area/application/controllers/state/controller.ts b/src/area/application/controllers/state/controller.ts index 753a56c..75f58bf 100644 --- a/src/area/application/controllers/state/controller.ts +++ b/src/area/application/controllers/state/controller.ts @@ -22,11 +22,17 @@ export class StateController { async getAll( @Param('slug') slug: string, @GetUserId() userId: string, + // TODO: ADD SCHEMA, AT DTO, AND VALIDATE WITH CONTRACT @Query('hidden') hidden?: boolean, @Query('counts') counts?: boolean, @Query('my') my?: boolean, @Query('category') category?: string, @Query('overdue') overdue?: boolean, + @Query('orderBy') orderBy?: 'order' | 'title' | 'tasksCount' | 'createdAt', + @Query('order') order?: 'asc' | 'desc', + @Query('page') page?: number, + @Query('offset') offset?: number, + @Query('limit') limit?: number, ) { const query = { hidden, @@ -34,6 +40,11 @@ export class StateController { my, category, overdue, + order, + limit, + offset, + page, + orderBy, }; return this.facade.getStates(slug, query, userId); diff --git a/src/area/application/controllers/state/swagger.ts b/src/area/application/controllers/state/swagger.ts index e436a7d..e0b62c2 100644 --- a/src/area/application/controllers/state/swagger.ts +++ b/src/area/application/controllers/state/swagger.ts @@ -15,7 +15,9 @@ import { ReordersStatesDto, CreateStateResponse, StateResponse, + StatesResponse, } from '../../dtos'; +import { ApiListQuery } from '@shared/decorators'; export const FindAllStatesSwagger = () => applyDecorators( @@ -39,8 +41,8 @@ export const FindAllStatesSwagger = () => name: 'hidden', required: false, type: Boolean, - description: 'Показать скрытые статусы', - example: 'false', + description: 'Показать скрытые статусы (isVisible = false)', + example: false, }), ApiQuery({ name: 'category', @@ -49,19 +51,31 @@ export const FindAllStatesSwagger = () => description: 'Фильтр по категории статусов', example: 'active', }), + ApiQuery({ + name: 'isLocked', + required: false, + type: Boolean, + description: 'Фильтр по заблокированным статусам', + example: false, + }), ApiQuery({ name: 'counts', required: false, type: Boolean, description: 'Добавить количество задач в каждом статусе (tasksCount)', - example: 'true', + example: true, }), ApiQuery({ name: 'my', required: false, type: Boolean, description: 'Показать только мои задачи (добавляет myTasksCount)', - example: 'false', + example: false, + }), + ApiListQuery({ + sortableFields: ['order', 'title', 'tasksCount', 'createdAt'], + defaultSortField: 'order', + defaultSortOrder: 'asc', }), ApiQuery({ name: 'overdue', @@ -69,17 +83,17 @@ export const FindAllStatesSwagger = () => type: Boolean, description: 'Добавить информацию о просроченных задачах (hasOverdueTasks, overdueTasksCount)', - example: 'true', + example: true, }), ApiResponse({ status: 200, description: 'Список состояний получен', - type: [StateResponse.Output], + type: StatesResponse.Output, }), ApiUnauthorized(), ApiNotFound('Проект не найден'), - SetMetadata(ZOD_RESPONSE_TOKEN, StateResponse), + SetMetadata(ZOD_RESPONSE_TOKEN, StatesResponse), ); export const FindOneStateSwagger = () => @@ -147,7 +161,7 @@ export const CreateStateSwagger = () => ApiForbidden('Нет прав для создания состояния в этом проекте'), ApiConflict('Состояние с таким названием или типом уже существует'), - SetMetadata(ZOD_RESPONSE_TOKEN, CreateStateDto), + SetMetadata(ZOD_RESPONSE_TOKEN, CreateStateResponse), ); export const UpdateStateSwagger = () => @@ -193,6 +207,7 @@ export const UpdateStateSwagger = () => export const ReorderStatesSwagger = () => applyDecorators( ApiOperation({ + deprecated: true, summary: 'Изменить порядок колонок на доске', description: [ 'Позволяет переставить колонки на канбан-доске так, как вам удобно.', diff --git a/src/area/application/dtos/area.dto.ts b/src/area/application/dtos/area.dto.ts index 0680008..fa66d03 100644 --- a/src/area/application/dtos/area.dto.ts +++ b/src/area/application/dtos/area.dto.ts @@ -1,8 +1,9 @@ import { z } from 'zod/v4'; import { createZodDto } from 'nestjs-zod'; +import { DEFAULT_VIEWS } from '@core/area/domain/entities'; export const DefaultViewSchema = z - .enum(['kanban', 'list', 'calendar', 'gantt']) + .enum(DEFAULT_VIEWS) .default('kanban') .describe('Тип отображения по умолчанию для области'); @@ -97,10 +98,10 @@ export const CreateAreaSchema = AreaSchema.omit({ updatedAt: true, createdBy: true, deletedAt: true, - descriptionHtml: true, }) .partial({ description: true, + descriptionHtml: true, color: true, icon: true, position: true, @@ -116,10 +117,16 @@ export const CreateAreaSchema = AreaSchema.omit({ }) .describe('Схема для создания новой области'); -export const UpdateAreaSchema = CreateAreaSchema.partial().describe('Схема для обновления области'); +export const UpdateAreaSchema = CreateAreaSchema.partial() + .refine((data) => Object.keys(data).length > 0, { + error: 'Необходимо передать хотя бы одно поле для обновления', + abort: true, + }) + .describe('Схема для обновления области'); -export class AreaResponse extends createZodDto(AreaSchema) {} +export const AreasSchema = z.array(AreaSchema); +export class AreaResponse extends createZodDto(AreaSchema) {} +export class AreasResponse extends createZodDto(AreasSchema) {} export class CreateAreaDto extends createZodDto(CreateAreaSchema) {} - export class UpdateAreaDto extends createZodDto(UpdateAreaSchema) {} diff --git a/src/area/application/dtos/states.dto.ts b/src/area/application/dtos/states.dto.ts index a285a5d..353e1e0 100644 --- a/src/area/application/dtos/states.dto.ts +++ b/src/area/application/dtos/states.dto.ts @@ -1,13 +1,14 @@ import { z } from 'zod/v4'; import { createZodDto } from 'nestjs-zod'; import { ActionResponseSchema } from '@shared/dtos'; +import { STATE_CATEGORIES, STATE_TYPES } from '@core/area/domain/entities'; export const StateTypeSchema = z - .enum(['backlog', 'todo', 'in_progress', 'review', 'done', 'archived', 'custom']) + .enum(STATE_TYPES) .describe('Тип состояния: системный или кастомный'); export const StateCategorySchema = z - .enum(['active', 'completed', 'backlog', 'review', 'archived']) + .enum(STATE_CATEGORIES) .describe('Категория состояния: активное, завершённое или отменённое'); export const StateSchema = z.object({ @@ -15,32 +16,22 @@ export const StateSchema = z.object({ .string() .min(1, 'ID не может быть пустым') .describe('Уникальный идентификатор состояния (UUID или наноид)'), - - Id: z - .string() - .min(1, 'ID проекта обязателен') - .describe('ID проекта, к которому принадлежит состояние'), - title: z .string() .min(1, 'Название состояния обязательно') .max(255, 'Название не должно превышать 255 символов') .describe('Отображаемое название состояния (например: "To Do", "In Progress", "Done")'), - description: z .string() .nullable() .optional() .describe('Описание состояния, его назначение и правила использования в workflow'), - stateType: StateTypeSchema.default('custom').describe( 'Тип состояния: custom — пользовательское, default — системное (нельзя удалить)', ), - category: StateCategorySchema.default('active').describe( 'Группа для аналитики и фильтрации: backlog, active, done, closed', ), - color: z .string() .regex( @@ -50,26 +41,22 @@ export const StateSchema = z.object({ .nullable() .optional() .describe('HEX-код цвета для визуального отображения на доске (например: "#4A90E2")'), - icon: z .string() .max(20, 'Иконка должна быть не длиннее 20 символов') .nullable() .optional() .describe('Emoji или иконка для визуального обозначения (например: "📋", "🚀", "✅")'), - orderIndex: z .number() .int('Порядковый номер должен быть целым числом') .min(0, 'Порядковый номер не может быть отрицательным') .default(0) .describe('Порядок отображения на доске (меньше число — левее/выше)'), - isVisible: z .boolean() .default(true) .describe('Видимость состояния на доске и в выпадающих списках (можно скрыть, не удаляя)'), - maxTasksLimit: z .number() .int('Лимит задач должен быть целым числом') @@ -79,40 +66,32 @@ export const StateSchema = z.object({ .describe( 'Максимальное количество задач в этом состоянии (WIP лимит для Kanban). Null — без лимита', ), - autoTransitionTo: z .string() .nullable() .optional() .describe('Автоматический переход в другое состояние при достижении лимита или по условию'), - notifyOnEnter: z .boolean() .default(false) .describe('Отправлять уведомление, когда задача попадает в это состояние'), - notifyOnExit: z .boolean() .default(false) .describe('Отправлять уведомление, когда задача покидает это состояние'), - isLocked: z .boolean() .default(false) .describe('Заблокировано для изменений (нельзя перемещать задачи в/из этого состояния)'), - createdAt: z .string() .datetime({ offset: true }) .describe('Дата и время создания состояния (ISO 8601 с таймзоной)'), - updatedAt: z .string() .datetime({ offset: true }) .describe('Дата и время последнего обновления состояния'), - createdBy: z.string().nullable().optional().describe('ID пользователя, создавшего состояние'), - deletedAt: z .string() .datetime({ offset: true }) @@ -122,9 +101,11 @@ export const StateSchema = z.object({ }); export const CreateStateResponseSchema = ActionResponseSchema.extend({ - id: z.string().describe('ID созданного состояния'), + stateId: z.string().describe('ID созданного состояния'), }); +export const StatesSchema = z.array(StateSchema); + export const CreateStateSchema = StateSchema.omit({ id: true, createdAt: true, @@ -151,11 +132,8 @@ export const ReorderStatesSchema = z.object({ }); export class StateResponse extends createZodDto(StateSchema) {} - +export class StatesResponse extends createZodDto(StatesSchema) {} export class CreateStateDto extends createZodDto(CreateStateSchema) {} - export class UpdateStateDto extends createZodDto(CreateStateSchema.partial()) {} - export class CreateStateResponse extends createZodDto(CreateStateResponseSchema) {} - export class ReordersStatesDto extends createZodDto(ReorderStatesSchema) {} diff --git a/src/area/application/use-cases/areas/create.use-case.ts b/src/area/application/use-cases/areas/create.use-case.ts index 6645e54..9aefea0 100644 --- a/src/area/application/use-cases/areas/create.use-case.ts +++ b/src/area/application/use-cases/areas/create.use-case.ts @@ -1,7 +1,11 @@ import { IAreaRepository } from '@core/area/domain/repository'; -import { Inject, Injectable } from '@nestjs/common'; -import { CreateAreaDto } from '../../dtos'; +import { HttpStatus, Inject, Injectable } from '@nestjs/common'; +import type { CreateAreaDto } from '../../dtos'; import { ProjectAccessPolicy } from '@core/projects/domain/policy'; +import slugify from 'slugify'; +import { BaseException } from '@shared/error'; +import { AreaErrorCodes, AreaErrorMessages } from '@core/area/domain/errors'; +import { MAX_AREAS_PER_PROJECT } from '@core/area/infrastructure/constants'; @Injectable() export class CreateAreaUseCase { @@ -12,17 +16,78 @@ export class CreateAreaUseCase { ) {} async execute(slug: string, dto: CreateAreaDto, userId: string) { - const { member, project } = await this.projectPolicy.ensureProjectAccess(slug, userId, [ - 'admin', - 'owner', - ]); - - (void member, project, dto); - void this.areaRepo; - - return { - success: true, - message: '', - }; + try { + const { project } = await this.projectPolicy.ensureProjectAccess(slug, userId, [ + 'admin', + 'owner', + ]); + + const baseSlug = dto.slug || dto.title; + const currentSlug = slugify(baseSlug, { + lower: true, + strict: true, + trim: true, + }); + + if (!currentSlug) { + throw new BaseException( + { + code: AreaErrorCodes.SLUG_INVALID, + message: AreaErrorMessages[AreaErrorCodes.SLUG_INVALID], + }, + HttpStatus.BAD_REQUEST, + ); + } + + const existingArea = await this.areaRepo.findBySlug(project.id, currentSlug); + + if (existingArea) { + throw new BaseException( + { + code: AreaErrorCodes.SLUG_DUPLICATE, + message: AreaErrorMessages[AreaErrorCodes.SLUG_DUPLICATE], + }, + HttpStatus.CONFLICT, + ); + } + + await this.checkProjectLimits(project.id); + + const result = await this.areaRepo.create({ + ...dto, + projectId: project.id, + slug: currentSlug, + createdBy: userId, + }); + + return { + success: true, + message: `Пространство ${dto.title} успешно создано.`, + slug: result.slug, + }; + } catch (e) { + if (e instanceof BaseException) throw e; + + throw new BaseException( + { + code: AreaErrorCodes.CREATE_FAILED, + message: AreaErrorMessages[AreaErrorCodes.CREATE_FAILED], + }, + HttpStatus.INTERNAL_SERVER_ERROR, + ); + } + } + + private async checkProjectLimits(projectId: string): Promise { + const areasCount = await this.areaRepo.countByProject(projectId); + if (areasCount >= MAX_AREAS_PER_PROJECT) { + throw new BaseException( + { + code: AreaErrorCodes.MAX_LIMIT_REACHED, + message: AreaErrorMessages[AreaErrorCodes.MAX_LIMIT_REACHED], + }, + HttpStatus.FORBIDDEN, + ); + } } } diff --git a/src/area/application/use-cases/areas/delete.use-case.ts b/src/area/application/use-cases/areas/delete.use-case.ts index a36d433..88d24d7 100644 --- a/src/area/application/use-cases/areas/delete.use-case.ts +++ b/src/area/application/use-cases/areas/delete.use-case.ts @@ -1,6 +1,8 @@ +import { AreaErrorCodes, AreaErrorMessages } from '@core/area/domain/errors'; import { IAreaRepository } from '@core/area/domain/repository'; import { ProjectAccessPolicy } from '@core/projects/domain/policy'; -import { Inject, Injectable } from '@nestjs/common'; +import { HttpStatus, Inject, Injectable } from '@nestjs/common'; +import { BaseException } from '@shared/error'; @Injectable() export class DeleteAreaUseCase { @@ -11,14 +13,62 @@ export class DeleteAreaUseCase { ) {} async execute(slug: string, key: string, userId: string) { - (void this.areaRepo, this.projectPolicy); - - return { - success: true, - message: '', - slug, - key, - userId, - }; + try { + const { project } = await this.projectPolicy.ensureProjectAccess(slug, userId, [ + 'admin', + 'owner', + ]); + + const area = await this.areaRepo.findBySlug(project.id, key); + + if (!area) { + throw new BaseException( + { + code: AreaErrorCodes.NOT_FOUND, + message: AreaErrorMessages[AreaErrorCodes.NOT_FOUND], + }, + HttpStatus.NOT_FOUND, + ); + } + + // 3. ⭐ БИЗНЕС-ПРОВЕРКИ + // 3.1 Проверка на наличие связанных задач + // Вариант А: Запретить удаление (безопасно) + // Вариант Б: Спросить подтверждение (через query параметр) + // Вариант В: Автоматически переместить задачи в дефолтную область + + await this.checkLastArea(project.id); + + const result = await this.areaRepo.delete(project.id, area.id); + + return { + success: result, + message: `Пространство ${area.title} успешно удалено.`, + }; + } catch (e) { + if (e instanceof BaseException) throw e; + + throw new BaseException( + { + code: AreaErrorCodes.DELETE_FAILED, + message: AreaErrorMessages[AreaErrorCodes.DELETE_FAILED], + }, + HttpStatus.INTERNAL_SERVER_ERROR, + ); + } + } + + private async checkLastArea(projectId: string): Promise { + const areasCount = await this.areaRepo.countByProject(projectId); + + if (areasCount <= 1) { + throw new BaseException( + { + code: AreaErrorCodes.CANNOT_DELETE_LAST_AREA, + message: AreaErrorMessages[AreaErrorCodes.CANNOT_DELETE_LAST_AREA], + }, + HttpStatus.BAD_REQUEST, + ); + } } } diff --git a/src/area/application/use-cases/areas/get-all.query.ts b/src/area/application/use-cases/areas/get-all.query.ts index 478edfd..5006dec 100644 --- a/src/area/application/use-cases/areas/get-all.query.ts +++ b/src/area/application/use-cases/areas/get-all.query.ts @@ -10,15 +10,14 @@ export class GetAreasQuery { private readonly projectPolicy: ProjectAccessPolicy, ) {} - async execute(slug: string, dto: unknown, userId: string) { - (void this.areaRepo, this.projectPolicy); + async execute(slug: string, userId: string, _query: unknown) { + const { project } = await this.projectPolicy.ensureProjectAccess(slug, userId); + const areas = await this.areaRepo.findAll(project.id); - return { - success: true, - message: '', - slug, - dto, - userId, - }; + return areas.map((a) => ({ + ...a, + createdAt: new Date(a.createdAt).toISOString(), + updatedAt: new Date(a.updatedAt).toISOString(), + })); } } diff --git a/src/area/application/use-cases/areas/get-one.query.ts b/src/area/application/use-cases/areas/get-one.query.ts index ae6f5f9..c715f6e 100644 --- a/src/area/application/use-cases/areas/get-one.query.ts +++ b/src/area/application/use-cases/areas/get-one.query.ts @@ -1,8 +1,11 @@ +import { AreaErrorCodes, AreaErrorMessages } from '@core/area/domain/errors'; import { IAreaRepository } from '@core/area/domain/repository'; import { ProjectAccessPolicy } from '@core/projects/domain/policy'; import { HttpStatus, Inject, Injectable } from '@nestjs/common'; import { BaseException } from '@shared/error'; +export type GetOneAreaParams = { projectSlug: string; key: string } | { key: string }; + @Injectable() export class GetAreaQuery { constructor( @@ -11,13 +14,46 @@ export class GetAreaQuery { private readonly projectPolicy: ProjectAccessPolicy, ) {} - async execute(slug: string, key: string, userId: string) { + async execute(params: GetOneAreaParams, userId: string) { + if ('projectSlug' in params) { + return this.getAreaByProjectSlug(params.projectSlug, params.key, userId); + } + + return this.getAreaByKey(params.key); + } + + private async getAreaByProjectSlug(slug: string, key: string, userId: string) { const { project } = await this.projectPolicy.ensureProjectAccess(slug, userId); - const area = await this.areaRepo.findBySlug(project.slug, key); + const area = await this.areaRepo.findBySlug(key, project.id); + if (!area) { + throw new BaseException( + { + code: AreaErrorCodes.NOT_FOUND, + message: AreaErrorMessages[AreaErrorCodes.NOT_FOUND], + }, + HttpStatus.NOT_FOUND, + ); + } + + return { + ...area, + createdAt: new Date(area.createdAt).toISOString(), + updatedAt: new Date(area.updatedAt).toISOString(), + }; + } + + private async getAreaByKey(key: string) { + const area = await this.areaRepo.findBySlug(key); if (!area) { - throw new BaseException({ code: '', message: '' }, HttpStatus.NOT_FOUND); + throw new BaseException( + { + code: AreaErrorCodes.NOT_FOUND, + message: AreaErrorMessages[AreaErrorCodes.NOT_FOUND], + }, + HttpStatus.NOT_FOUND, + ); } return area; diff --git a/src/area/application/use-cases/areas/update.use-case.ts b/src/area/application/use-cases/areas/update.use-case.ts index 028b79f..1547952 100644 --- a/src/area/application/use-cases/areas/update.use-case.ts +++ b/src/area/application/use-cases/areas/update.use-case.ts @@ -1,7 +1,10 @@ import { IAreaRepository } from '@core/area/domain/repository'; -import { Inject, Injectable } from '@nestjs/common'; +import { HttpStatus, Inject, Injectable } from '@nestjs/common'; import { UpdateAreaDto } from '../../dtos'; import { ProjectAccessPolicy } from '@core/projects/domain/policy'; +import { BaseException } from '@shared/error'; +import { AreaErrorCodes, AreaErrorMessages } from '@core/area/domain/errors'; +import slugify from 'slugify'; @Injectable() export class UpdateAreaUseCase { @@ -12,14 +15,160 @@ export class UpdateAreaUseCase { ) {} async execute(slug: string, key: string, dto: UpdateAreaDto, userId: string) { - (void this.areaRepo, this.projectPolicy, dto); - - return { - success: true, - message: '', - slug, - key, - userId, - }; + try { + const { project } = await this.projectPolicy.ensureProjectAccess(slug, userId, [ + 'admin', + 'owner', + ]); + + const area = await this.areaRepo.findBySlug(project.id, key); + + if (!area) { + throw new BaseException( + { + code: AreaErrorCodes.NOT_FOUND, + message: AreaErrorMessages[AreaErrorCodes.NOT_FOUND], + }, + HttpStatus.NOT_FOUND, + ); + } + + // TODO: TMP|fix that at next patch resolve + const updateData: any = { + updatedAt: new Date().toISOString(), + updatedBy: userId, + }; + + let hasChanges = false; + + if (dto.title && dto.title !== area.title) { + updateData.title = dto.title.trim(); + hasChanges = true; + } + + if (dto.description && dto.description !== area.description) { + updateData.description = dto.description?.trim() || null; + hasChanges = true; + } + + if (dto.descriptionHtml && dto.descriptionHtml !== area.descriptionHtml) { + updateData.descriptionHtml = dto.descriptionHtml?.trim() || null; + hasChanges = true; + } + + if (dto.slug && dto.slug !== area.slug) { + let newSlug = dto.slug; + + if (newSlug) { + newSlug = slugify(newSlug, { + lower: true, + strict: true, + trim: true, + }); + + if (!newSlug) { + throw new BaseException( + { + code: AreaErrorCodes.SLUG_INVALID, + message: AreaErrorMessages[AreaErrorCodes.SLUG_INVALID], + }, + HttpStatus.BAD_REQUEST, + ); + } + + const existingArea = await this.areaRepo.findBySlug(project.id, newSlug); + if (existingArea && existingArea.id !== area.id) { + throw new BaseException( + { + code: AreaErrorCodes.SLUG_DUPLICATE, + message: AreaErrorMessages[AreaErrorCodes.SLUG_DUPLICATE], + }, + HttpStatus.CONFLICT, + ); + } + + updateData.slug = newSlug; + } else { + updateData.slug = slugify(updateData.title || area.title, { + lower: true, + strict: true, + trim: true, + }); + } + hasChanges = true; + } + + if (dto.color && dto.color !== area.color) { + updateData.color = dto.color || null; + hasChanges = true; + } + + if (dto.icon && dto.icon !== area.icon) { + updateData.icon = dto.icon || null; + hasChanges = true; + } + + if (dto.defaultView && dto.defaultView !== area.defaultView) { + updateData.defaultView = dto.defaultView; + hasChanges = true; + } + + if (dto.position && dto.position !== area.position) { + if (dto.position < 0) { + throw new BaseException( + { + code: AreaErrorCodes.POSITION_INVALID, + message: AreaErrorMessages[AreaErrorCodes.POSITION_INVALID], + }, + HttpStatus.BAD_REQUEST, + ); + } + updateData.position = dto.position; + hasChanges = true; + } + + if (dto.maxTasksLimit && dto.maxTasksLimit !== area.maxTasksLimit) { + if (dto.maxTasksLimit !== null && dto.maxTasksLimit <= 0) { + throw new BaseException( + { + code: AreaErrorCodes.MAX_TASKS_LIMIT_INVALID, + message: AreaErrorMessages[AreaErrorCodes.MAX_TASKS_LIMIT_INVALID], + }, + HttpStatus.BAD_REQUEST, + ); + } + updateData.maxTasksLimit = dto.maxTasksLimit; + hasChanges = true; + } + + if (dto.isLocked && dto.isLocked !== area.isLocked) { + updateData.isLocked = dto.isLocked; + hasChanges = true; + } + + if (!hasChanges) { + return { + success: true, + message: 'Нет изменений для обновления', + }; + } + + const result = await this.areaRepo.update(project.id, area.id, updateData); + + return { + success: result, + message: `Пространство ${dto.title || area.title} успешно обновлено`, + }; + } catch (e) { + if (e instanceof BaseException) throw e; + + throw new BaseException( + { + code: AreaErrorCodes.UPDATE_FAILED, + message: AreaErrorMessages[AreaErrorCodes.UPDATE_FAILED], + }, + HttpStatus.INTERNAL_SERVER_ERROR, + ); + } } } diff --git a/src/area/application/use-cases/states/create.use-case.ts b/src/area/application/use-cases/states/create.use-case.ts index c07b479..c8aae07 100644 --- a/src/area/application/use-cases/states/create.use-case.ts +++ b/src/area/application/use-cases/states/create.use-case.ts @@ -2,10 +2,9 @@ import { HttpStatus, Inject, Injectable } from '@nestjs/common'; import { BaseException } from '@shared/error'; import { IStateRepository } from '@core/area/domain/repository'; import { CreateStateDto } from '../../dtos'; -import { ProjectStateErrorCodes, ProjectStateErrorMessages } from '@core/area/domain/errors'; +import { StateErrorCodes, StateErrorMessages } from '@core/area/domain/errors'; import { GetAreaQuery } from '../areas'; - -const MAX_STATES_PER_PROJECT = 20; +import { MAX_STATES_PER_PROJECT } from '@core/area/infrastructure/constants'; @Injectable() export class CreateStateUseCase { @@ -16,60 +15,72 @@ export class CreateStateUseCase { ) {} async execute(slug: string, dto: CreateStateDto, userId: string) { - const area = await this.getAreaQ.execute('projectSlug', slug, userId); - - const currentCount = await this.stateRepo.countByArea(area.id); - if (currentCount >= MAX_STATES_PER_PROJECT) { - throw new BaseException( - { - code: ProjectStateErrorCodes.MAX_LIMIT_REACHED, - message: ProjectStateErrorMessages[ProjectStateErrorCodes.MAX_LIMIT_REACHED], - details: [{ current: currentCount, max: MAX_STATES_PER_PROJECT }], - }, - HttpStatus.UNPROCESSABLE_ENTITY, - ); - } - - if (dto.title) { - const existingByTitle = await this.stateRepo.findByTitle(area.id, dto.title); + try { + const area = await this.getAreaQ.execute({ key: slug }, userId); - if (existingByTitle) { + const currentCount = await this.stateRepo.countByArea(area.id); + if (currentCount >= MAX_STATES_PER_PROJECT) { throw new BaseException( { - code: ProjectStateErrorCodes.DUPLICATE_TITLE, - message: ProjectStateErrorMessages[ProjectStateErrorCodes.DUPLICATE_TITLE], - details: [{ title: dto.title }], + code: StateErrorCodes.MAX_LIMIT_REACHED, + message: StateErrorMessages[StateErrorCodes.MAX_LIMIT_REACHED], + details: [{ current: currentCount, max: MAX_STATES_PER_PROJECT }], }, - HttpStatus.CONFLICT, + HttpStatus.UNPROCESSABLE_ENTITY, ); } - } - if (dto.stateType && dto.stateType !== 'custom') { - const existingByType = await this.stateRepo.findByType(area.id, dto.stateType); + if (dto.title) { + const existingByTitle = await this.stateRepo.findByTitle(area.id, dto.title); - if (existingByType) { - throw new BaseException( - { - code: ProjectStateErrorCodes.DUPLICATE_TYPE, - message: ProjectStateErrorMessages[ProjectStateErrorCodes.DUPLICATE_TYPE], - details: [{ stateType: dto.stateType }], - }, - HttpStatus.CONFLICT, - ); + if (existingByTitle) { + throw new BaseException( + { + code: StateErrorCodes.DUPLICATE_TITLE, + message: StateErrorMessages[StateErrorCodes.DUPLICATE_TITLE], + details: [{ title: dto.title }], + }, + HttpStatus.CONFLICT, + ); + } } - } - const result = await this.stateRepo.create({ - ...dto, - areaId: area.id, - createdBy: userId, - }); + if (dto.stateType && dto.stateType !== 'custom') { + const existingByType = await this.stateRepo.findByType(area.id, dto.stateType); + + if (existingByType) { + throw new BaseException( + { + code: StateErrorCodes.DUPLICATE_TYPE, + message: StateErrorMessages[StateErrorCodes.DUPLICATE_TYPE], + details: [{ stateType: dto.stateType }], + }, + HttpStatus.CONFLICT, + ); + } + } + + const result = await this.stateRepo.create({ + ...dto, + areaId: area.id, + createdBy: userId, + }); + + return { + success: true, + message: 'Состояние успешно создано', + stateId: result.id, + }; + } catch (err) { + if (err instanceof BaseException) throw err; - return { - success: true, - message: 'Состояние успешно создано', - stateId: result.id, - }; + throw new BaseException( + { + code: StateErrorCodes.CREATE_FAILED, + message: StateErrorMessages[StateErrorCodes.CREATE_FAILED], + }, + HttpStatus.INTERNAL_SERVER_ERROR, + ); + } } } diff --git a/src/area/application/use-cases/states/delete.use-case.ts b/src/area/application/use-cases/states/delete.use-case.ts index be6ce49..855f1f6 100644 --- a/src/area/application/use-cases/states/delete.use-case.ts +++ b/src/area/application/use-cases/states/delete.use-case.ts @@ -1,4 +1,4 @@ -import { ProjectStateErrorCodes, ProjectStateErrorMessages } from '@core/area/domain/errors'; +import { StateErrorCodes, StateErrorMessages } from '@core/area/domain/errors'; import { IStateRepository } from '@core/area/domain/repository'; import { HttpStatus, Inject, Injectable } from '@nestjs/common'; import { BaseException } from '@shared/error'; @@ -13,59 +13,73 @@ export class DeleteStateUseCase { ) {} async execute(slug: string, stateId: string, userId: string) { - const area = await this.getAreaQ.execute('projectSlug', slug, userId); + try { + const area = await this.getAreaQ.execute({ key: slug }, userId); - const state = await this.stateRepo.findOne(area.id, stateId); - if (!state) { - throw new BaseException( - { - code: ProjectStateErrorCodes.NOT_FOUND, - message: ProjectStateErrorMessages[ProjectStateErrorCodes.NOT_FOUND], - }, - HttpStatus.NOT_FOUND, - ); - } + const state = await this.stateRepo.findOne(area.id, stateId); + if (!state) { + throw new BaseException( + { + code: StateErrorCodes.NOT_FOUND, + message: StateErrorMessages[StateErrorCodes.NOT_FOUND], + }, + HttpStatus.NOT_FOUND, + ); + } - if (state.stateType !== 'custom') { - throw new BaseException( - { - code: ProjectStateErrorCodes.CANNOT_DELETE_SYSTEM, - message: ProjectStateErrorMessages[ProjectStateErrorCodes.CANNOT_DELETE_SYSTEM], - details: [{ stateType: state.stateType }], - }, - HttpStatus.FORBIDDEN, - ); - } + if (state.stateType !== 'custom') { + throw new BaseException( + { + code: StateErrorCodes.CANNOT_DELETE_SYSTEM, + message: StateErrorMessages[StateErrorCodes.CANNOT_DELETE_SYSTEM], + details: [{ stateType: state.stateType }], + }, + HttpStatus.FORBIDDEN, + ); + } + + if (state.isLocked) { + throw new BaseException( + { + code: StateErrorCodes.LOCKED, + message: StateErrorMessages[StateErrorCodes.LOCKED], + }, + HttpStatus.CONFLICT, + ); + } + + // const taskCount = await this.taskRepo.countByState(state.id); + // if (taskCount > 0) { + // throw new BaseException( + // { + // code: StateErrorCodes.HAS_ACTIVE_TASKS, + // message: StateErrorMessages[StateErrorCodes.HAS_ACTIVE_TASKS], + // details: { taskCount }, + // }, + // HttpStatus.CONFLICT, + // ); + // } + + console.log(area, state); + + const result = await this.stateRepo.delete(area.id, state.id); + + return { + success: result, + message: result + ? 'Состояние успешно удалено' + : 'Не удалось удалить состояние: запись не найдена или уже удалена', + }; + } catch (err) { + if (err instanceof BaseException) throw err; - if (state.isLocked) { throw new BaseException( { - code: ProjectStateErrorCodes.LOCKED, - message: ProjectStateErrorMessages[ProjectStateErrorCodes.LOCKED], + code: StateErrorCodes.DELETE_FAILED, + message: StateErrorMessages[StateErrorCodes.DELETE_FAILED], }, - HttpStatus.CONFLICT, + HttpStatus.INTERNAL_SERVER_ERROR, ); } - - // const taskCount = await this.taskRepo.countByState(state.id); - // if (taskCount > 0) { - // throw new BaseException( - // { - // code: ProjectStateErrorCodes.HAS_ACTIVE_TASKS, - // message: ProjectStateErrorMessages[ProjectStateErrorCodes.HAS_ACTIVE_TASKS], - // details: { taskCount }, - // }, - // HttpStatus.CONFLICT, - // ); - // } - - const result = await this.stateRepo.delete(slug, stateId); - - return { - success: result, - message: result - ? 'Состояние успешно удалено' - : 'Не удалось удалить состояние: запись не найдена или уже удалена', - }; } } diff --git a/src/area/application/use-cases/states/get-all.query.ts b/src/area/application/use-cases/states/get-all.query.ts index fec73db..46af6a7 100644 --- a/src/area/application/use-cases/states/get-all.query.ts +++ b/src/area/application/use-cases/states/get-all.query.ts @@ -11,9 +11,15 @@ export class GetStatesQuery { ) {} async execute(slug: string, userId: string, query: unknown) { - const area = await this.getAreaQ.execute(slug, userId, 'viewer'); + const area = await this.getAreaQ.execute({ key: slug }, userId); const states = await this.stateRepo.find(area.id, query); - return states; + return states + .map((s) => ({ + ...s, + createdAt: new Date(s.createdAt).toISOString(), + updatedAt: new Date(s.updatedAt).toISOString(), + })) + .sort((a, b) => a.position - b.position); } } diff --git a/src/area/application/use-cases/states/get-one.query.ts b/src/area/application/use-cases/states/get-one.query.ts index a5b1526..c24ba85 100644 --- a/src/area/application/use-cases/states/get-one.query.ts +++ b/src/area/application/use-cases/states/get-one.query.ts @@ -1,32 +1,35 @@ -import { ProjectStateErrorCodes, ProjectStateErrorMessages } from '@core/area/domain/errors'; +import { StateErrorCodes, StateErrorMessages } from '@core/area/domain/errors'; import { IStateRepository } from '@core/area/domain/repository'; -import { FindProjectQuery } from '@core/projects'; import { HttpStatus, Inject, Injectable } from '@nestjs/common'; import { BaseException } from '@shared/error'; +import { GetAreaQuery } from '../areas'; @Injectable() export class GetStateQuery { constructor( @Inject('IStateRepository') private readonly stateRepo: IStateRepository, - private readonly findProjectQ: FindProjectQuery, + private readonly getAreaQ: GetAreaQuery, ) {} async execute(slug: string, stateId: string, userId: string) { - const { project } = await this.findProjectQ.execute(slug, 'teamId??', 'viewer', userId); - - const state = await this.stateRepo.findOne(project.id, stateId); + const area = await this.getAreaQ.execute({ key: slug }, userId); + const state = await this.stateRepo.findOne(area.id, stateId); if (!state) { throw new BaseException( { - code: ProjectStateErrorCodes.NOT_FOUND, - message: ProjectStateErrorMessages[ProjectStateErrorCodes.NOT_FOUND], + code: StateErrorCodes.NOT_FOUND, + message: StateErrorMessages[StateErrorCodes.NOT_FOUND], }, HttpStatus.NOT_FOUND, ); } - return state; + return { + ...state, + createdAt: new Date(state.createdAt).toISOString(), + updatedAt: new Date(state.updatedAt).toISOString(), + }; } } diff --git a/src/area/application/use-cases/states/reorder.use-case.ts b/src/area/application/use-cases/states/reorder.use-case.ts index 85e361c..063f439 100644 --- a/src/area/application/use-cases/states/reorder.use-case.ts +++ b/src/area/application/use-cases/states/reorder.use-case.ts @@ -2,7 +2,7 @@ import { HttpStatus, Inject, Injectable } from '@nestjs/common'; import { BaseException } from '@shared/error'; import { ReordersStatesDto } from '../../dtos'; import { IStateRepository } from '@core/area/domain/repository'; -import { ProjectStateErrorCodes, ProjectStateErrorMessages } from '@core/area/domain/errors'; +import { StateErrorCodes, StateErrorMessages } from '@core/area/domain/errors'; import { GetAreaQuery } from '../areas'; @Injectable() @@ -13,30 +13,40 @@ export class ReorderStateUseCase { private readonly getAreaQ: GetAreaQuery, ) {} - async execute(slug: string, dto: ReordersStatesDto, userId: string) { - const area = await this.getAreaQ.execute('projectSlug', slug, userId); + async execute(slug: string, _dto: ReordersStatesDto, userId: string) { + try { + const area = await this.getAreaQ.execute({ key: slug }, userId); - const state = await this.stateRepo.find(slug); + const state = await this.stateRepo.findOne(area.id, slug); + + if (!state) { + throw new BaseException( + { + code: StateErrorCodes.NOT_FOUND, + message: StateErrorMessages[StateErrorCodes.NOT_FOUND], + }, + HttpStatus.NOT_FOUND, + ); + } + + const result = true; + + return { + success: result, + message: result + ? 'Состояние успешно восстановлено' + : 'Не удалось восстановить состояние: запись не найдена или уже активна', + }; + } catch (err) { + if (err instanceof BaseException) throw err; - if (!state) { throw new BaseException( { - code: ProjectStateErrorCodes.NOT_FOUND, - message: ProjectStateErrorMessages[ProjectStateErrorCodes.NOT_FOUND], + code: StateErrorCodes.REORDER_FAILED, + message: StateErrorMessages[StateErrorCodes.REORDER_FAILED], }, - HttpStatus.NOT_FOUND, + HttpStatus.INTERNAL_SERVER_ERROR, ); } - - // TODO: ADD REODER STATES - (void dto, area); - const result = true; - - return { - success: result, - message: result - ? 'Состояние успешно восстановлено' - : 'Не удалось восстановить состояние: запись не найдена или уже активна', - }; } } diff --git a/src/area/application/use-cases/states/restore.use-state.ts b/src/area/application/use-cases/states/restore.use-state.ts index 9eabdbe..288cecb 100644 --- a/src/area/application/use-cases/states/restore.use-state.ts +++ b/src/area/application/use-cases/states/restore.use-state.ts @@ -1,41 +1,53 @@ -import { ProjectStateErrorCodes, ProjectStateErrorMessages } from '@core/area/domain/errors'; +import { StateErrorCodes, StateErrorMessages } from '@core/area/domain/errors'; import { IStateRepository } from '@core/area/domain/repository'; -import { FindProjectQuery } from '@core/projects'; import { HttpStatus, Inject, Injectable } from '@nestjs/common'; import { BaseException } from '@shared/error'; +import { GetAreaQuery } from '../areas'; @Injectable() export class RestoreStateUseCase { constructor( @Inject('IStateRepository') private readonly stateRepo: IStateRepository, - private readonly findProjectQ: FindProjectQuery, + private readonly getAreaQ: GetAreaQuery, ) {} async execute(slug: string, stateId: string, userId: string) { - const { project } = await this.findProjectQ.execute(slug, 'teamId??', 'admin', userId); + try { + const area = await this.getAreaQ.execute({ key: slug }, userId); - const state = await this.stateRepo.findOne(slug, stateId, true); + const state = await this.stateRepo.findOne(area.id, stateId, true); + + if (!state) { + throw new BaseException( + { + code: StateErrorCodes.NOT_FOUND, + message: StateErrorMessages[StateErrorCodes.NOT_FOUND], + }, + HttpStatus.NOT_FOUND, + ); + } + + const result = await this.stateRepo.update(area.id, stateId, { + deletedAt: null, + }); + + return { + success: result, + message: result + ? 'Состояние успешно восстановлено' + : 'Не удалось восстановить состояние: запись не найдена или уже активна', + }; + } catch (err) { + if (err instanceof BaseException) throw err; - if (!state) { throw new BaseException( { - code: ProjectStateErrorCodes.NOT_FOUND, - message: ProjectStateErrorMessages[ProjectStateErrorCodes.NOT_FOUND], + code: StateErrorCodes.RESTORE_FAILED, + message: StateErrorMessages[StateErrorCodes.RESTORE_FAILED], }, - HttpStatus.NOT_FOUND, + HttpStatus.INTERNAL_SERVER_ERROR, ); } - - const result = await this.stateRepo.update(project.id, stateId, { - deletedAt: null, - }); - - return { - success: result, - message: result - ? 'Состояние успешно восстановлено' - : 'Не удалось восстановить состояние: запись не найдена или уже активна', - }; } } diff --git a/src/area/application/use-cases/states/update.use-case.ts b/src/area/application/use-cases/states/update.use-case.ts index 42cd934..44b34d7 100644 --- a/src/area/application/use-cases/states/update.use-case.ts +++ b/src/area/application/use-cases/states/update.use-case.ts @@ -2,60 +2,71 @@ import { HttpStatus, Inject, Injectable } from '@nestjs/common'; import { BaseException } from '@shared/error'; import { IStateRepository } from '@core/area/domain/repository'; import { UpdateStateDto } from '../../dtos'; -import { FindProjectQuery } from '@core/projects'; -import { ProjectStateErrorCodes, ProjectStateErrorMessages } from '@core/area/domain/errors'; +import { StateErrorCodes, StateErrorMessages } from '@core/area/domain/errors'; +import { GetAreaQuery } from '../areas'; @Injectable() export class UpdateStateUseCase { constructor( @Inject('IStateRepository') private readonly stateRepo: IStateRepository, - private readonly findProjectQ: FindProjectQuery, + private readonly getAreaQ: GetAreaQuery, ) {} async execute(slug: string, stateId: string, dto: UpdateStateDto, userId: string) { - await this.findProjectQ.execute(slug, 'teamId??', 'admin', userId); + try { + const area = await this.getAreaQ.execute({ key: slug }, userId); - const state = await this.stateRepo.findOne(slug, stateId); + const state = await this.stateRepo.findOne(area.id, stateId); - if (!state) { - throw new BaseException( - { - code: ProjectStateErrorCodes.NOT_FOUND, - message: ProjectStateErrorMessages[ProjectStateErrorCodes.NOT_FOUND], - }, - HttpStatus.NOT_FOUND, - ); - } + if (!state) { + throw new BaseException( + { + code: StateErrorCodes.NOT_FOUND, + message: StateErrorMessages[StateErrorCodes.NOT_FOUND], + }, + HttpStatus.NOT_FOUND, + ); + } - if (state.isLocked) { - throw new BaseException( - { - code: ProjectStateErrorCodes.LOCKED, - message: ProjectStateErrorMessages[ProjectStateErrorCodes.LOCKED], - }, - HttpStatus.CONFLICT, - ); - } + if (state.isLocked) { + throw new BaseException( + { + code: StateErrorCodes.LOCKED, + message: StateErrorMessages[StateErrorCodes.LOCKED], + }, + HttpStatus.CONFLICT, + ); + } + + if (state.stateType !== 'custom' && dto.stateType === 'custom') { + throw new BaseException( + { + code: StateErrorCodes.SYSTEM_TYPE_IMMUTABLE, + message: StateErrorMessages[StateErrorCodes.SYSTEM_TYPE_IMMUTABLE], + }, + HttpStatus.UNPROCESSABLE_ENTITY, + ); + } + + const result = await this.stateRepo.update(area.id, stateId, dto); + + return { + success: result, + message: result + ? 'Состояние успешно обновлено' + : 'Не удалось обновить состояние: запись не найдена', + }; + } catch (err) { + if (err instanceof BaseException) throw err; - if (state.stateType !== 'custom' && dto.stateType === 'custom') { throw new BaseException( { - code: ProjectStateErrorCodes.SYSTEM_TYPE_IMMUTABLE, - message: - ProjectStateErrorMessages[ProjectStateErrorCodes.SYSTEM_TYPE_IMMUTABLE], + code: StateErrorCodes.UPDATE_FAILED, + message: StateErrorMessages[StateErrorCodes.UPDATE_FAILED], }, - HttpStatus.UNPROCESSABLE_ENTITY, + HttpStatus.INTERNAL_SERVER_ERROR, ); } - - const result = await this.stateRepo.update(slug, stateId, dto); - - return { - success: result, - message: result - ? 'Состояние успешно обновлено' - : 'Не удалось обновить состояние: запись не найдена', - }; } } diff --git a/src/area/domain/entities/enum.ts b/src/area/domain/entities/enum.ts new file mode 100644 index 0000000..a8ae4b2 --- /dev/null +++ b/src/area/domain/entities/enum.ts @@ -0,0 +1,36 @@ +export const STATE_TYPES = { + BACKLOG: 'backlog', + TODO: 'todo', + IN_PROGRESS: 'in_progress', + REVIEW: 'review', + DONE: 'done', + ARCHIVED: 'archived', + CUSTOM: 'custom', +} as const; + +export type StateType = (typeof STATE_TYPES)[keyof typeof STATE_TYPES]; + +export const STATE_TYPES_LIST = Object.values(STATE_TYPES); + +export const STATE_CATEGORIES = { + BACKLOG: 'backlog', + ACTIVE: 'active', + REVIEW: 'review', + COMPLETED: 'completed', + ARCHIVED: 'archived', +} as const; + +export type StateCategory = (typeof STATE_CATEGORIES)[keyof typeof STATE_CATEGORIES]; + +export const STATE_CATEGORIES_LIST = Object.values(STATE_CATEGORIES); + +export const DEFAULT_VIEWS = { + KANBAN: 'kanban', + LIST: 'list', + CALENDAR: 'calendar', + GANTT: 'gantt', +} as const; + +export type DefaultView = (typeof DEFAULT_VIEWS)[keyof typeof DEFAULT_VIEWS]; + +export const DEFAULT_VIEWS_LIST = Object.values(DEFAULT_VIEWS); diff --git a/src/area/domain/entities/index.ts b/src/area/domain/entities/index.ts index ad1baca..68a4ceb 100644 --- a/src/area/domain/entities/index.ts +++ b/src/area/domain/entities/index.ts @@ -1,2 +1,3 @@ export * from './area.domain'; export * from './state.domain'; +export * from './enum'; diff --git a/src/area/domain/errors/area.errors.ts b/src/area/domain/errors/area.errors.ts index da2f33f..10182e7 100644 --- a/src/area/domain/errors/area.errors.ts +++ b/src/area/domain/errors/area.errors.ts @@ -9,12 +9,9 @@ export const AreaErrorCodes = { ALREADY_UNLOCKED: 'AREA.ALREADY_UNLOCKED', // 400 — Bad Request - TITLE_REQUIRED: 'AREA.TITLE_REQUIRED', - TITLE_TOO_LONG: 'AREA.TITLE_TOO_LONG', SLUG_INVALID: 'AREA.SLUG_INVALID', COLOR_INVALID: 'AREA.COLOR_INVALID', ICON_INVALID: 'AREA.ICON_INVALID', - DESCRIPTION_TOO_LONG: 'AREA.DESCRIPTION_TOO_LONG', PROJECT_REQUIRED: 'AREA.PROJECT_REQUIRED', DEFAULT_VIEW_INVALID: 'AREA.DEFAULT_VIEW_INVALID', POSITION_INVALID: 'AREA.POSITION_INVALID', @@ -47,13 +44,10 @@ export const AreaErrorMessages: Record = { [AreaErrorCodes.ALREADY_LOCKED]: 'Область уже заблокирована', [AreaErrorCodes.ALREADY_UNLOCKED]: 'Область уже разблокирована', - [AreaErrorCodes.TITLE_REQUIRED]: 'Название области не может быть пустым', - [AreaErrorCodes.TITLE_TOO_LONG]: 'Название области слишком длинное (максимум 255 символов)', [AreaErrorCodes.SLUG_INVALID]: 'Ключ области должен быть в формате kebab-case: строчные латинские буквы, цифры и дефисы', [AreaErrorCodes.COLOR_INVALID]: 'Цвет должен быть в формате HEX (например, #3b82f6)', [AreaErrorCodes.ICON_INVALID]: 'Иконка слишком длинная (максимум 20 символов)', - [AreaErrorCodes.DESCRIPTION_TOO_LONG]: 'Описание слишком длинное (максимум 5000 символов)', [AreaErrorCodes.PROJECT_REQUIRED]: 'ID проекта обязателен', [AreaErrorCodes.DEFAULT_VIEW_INVALID]: 'Недопустимый вид отображения по умолчанию', [AreaErrorCodes.POSITION_INVALID]: 'Позиция должна быть неотрицательным целым числом', diff --git a/src/area/domain/errors/state.errors.ts b/src/area/domain/errors/state.errors.ts index 9a3e2cc..acf9b80 100644 --- a/src/area/domain/errors/state.errors.ts +++ b/src/area/domain/errors/state.errors.ts @@ -1,4 +1,4 @@ -export const ProjectStateErrorCodes = { +export const StateErrorCodes = { NOT_FOUND: 'STATE.NOT_FOUND', UPDATE_FAILED: 'STATE.UPDATE_FAILED', DUPLICATE_TITLE: 'STATE.DUPLICATE_TITLE', @@ -55,76 +55,65 @@ export const ProjectStateErrorCodes = { CIRCULAR_DEPENDENCY: 'STATE.CIRCULAR_DEPENDENCY', } as const; -export type ProjectStateErrorCode = - (typeof ProjectStateErrorCodes)[keyof typeof ProjectStateErrorCodes]; - -export const ProjectStateErrorMessages: Record = { - [ProjectStateErrorCodes.NOT_FOUND]: 'Состояние проекта не найдено', - [ProjectStateErrorCodes.UPDATE_FAILED]: 'Не удалось обновить состояние', - [ProjectStateErrorCodes.DUPLICATE_TITLE]: - 'Состояние с таким названием уже существует в проекте', - [ProjectStateErrorCodes.DUPLICATE_TYPE]: 'Системный тип состояния уже используется в проекте', - [ProjectStateErrorCodes.SYSTEM_TYPE_IMMUTABLE]: - 'Нельзя изменить тип системного состояния на custom', - [ProjectStateErrorCodes.MAX_LIMIT_REACHED]: 'Достигнут лимит состояний в проекте', - [ProjectStateErrorCodes.INVALID_TRANSITION]: 'Недопустимый переход между состояниями', - [ProjectStateErrorCodes.LOCKED]: 'Состояние заблокировано и не может быть изменено', - - [ProjectStateErrorCodes.CREATE_FAILED]: 'Не удалось создать состояние', - [ProjectStateErrorCodes.TITLE_REQUIRED]: 'Название состояния не может быть пустым', - [ProjectStateErrorCodes.TITLE_TOO_LONG]: - 'Название состояния слишком длинное (максимум 255 символов)', - [ProjectStateErrorCodes.PROJECT_REQUIRED]: 'ID проекта обязателен', - [ProjectStateErrorCodes.SLUG_INVALID]: +export type StateErrorCode = (typeof StateErrorCodes)[keyof typeof StateErrorCodes]; + +export const StateErrorMessages: Record = { + [StateErrorCodes.NOT_FOUND]: 'Состояние проекта не найдено', + [StateErrorCodes.UPDATE_FAILED]: 'Не удалось обновить состояние', + [StateErrorCodes.DUPLICATE_TITLE]: 'Состояние с таким названием уже существует в проекте', + [StateErrorCodes.DUPLICATE_TYPE]: 'Системный тип состояния уже используется в проекте', + [StateErrorCodes.SYSTEM_TYPE_IMMUTABLE]: 'Нельзя изменить тип системного состояния на custom', + [StateErrorCodes.MAX_LIMIT_REACHED]: 'Достигнут лимит состояний в проекте', + [StateErrorCodes.INVALID_TRANSITION]: 'Недопустимый переход между состояниями', + [StateErrorCodes.LOCKED]: 'Состояние заблокировано и не может быть изменено', + + [StateErrorCodes.CREATE_FAILED]: 'Не удалось создать состояние', + [StateErrorCodes.TITLE_REQUIRED]: 'Название состояния не может быть пустым', + [StateErrorCodes.TITLE_TOO_LONG]: 'Название состояния слишком длинное (максимум 255 символов)', + [StateErrorCodes.PROJECT_REQUIRED]: 'ID проекта обязателен', + [StateErrorCodes.SLUG_INVALID]: 'Ключ должен содержать только строчные латинские буквы, цифры и _ (до 50 символов)', - [ProjectStateErrorCodes.SLUG_DUPLICATE]: 'Состояние с таким ключом уже существует в проекте', - [ProjectStateErrorCodes.COLOR_INVALID]: 'Цвет должен быть в формате HEX (например, #FFFFFF)', - [ProjectStateErrorCodes.ICON_INVALID]: 'Иконка слишком длинная (максимум 20 символов)', - [ProjectStateErrorCodes.DESCRIPTION_TOO_LONG]: - 'Описание слишком длинное (максимум 2000 символов)', - [ProjectStateErrorCodes.ORDER_INDEX_INVALID]: 'Недопустимый индекс порядка', - [ProjectStateErrorCodes.MAX_TASKS_LIMIT_INVALID]: - 'Лимит задач должен быть положительным числом', - [ProjectStateErrorCodes.AUTO_TRANSITION_INVALID]: 'Недопустимое состояние для автоперехода', - [ProjectStateErrorCodes.SYSTEM_TYPE_REQUIRED]: + [StateErrorCodes.SLUG_DUPLICATE]: 'Состояние с таким ключом уже существует в проекте', + [StateErrorCodes.COLOR_INVALID]: 'Цвет должен быть в формате HEX (например, #FFFFFF)', + [StateErrorCodes.ICON_INVALID]: 'Иконка слишком длинная (максимум 20 символов)', + [StateErrorCodes.DESCRIPTION_TOO_LONG]: 'Описание слишком длинное (максимум 2000 символов)', + [StateErrorCodes.ORDER_INDEX_INVALID]: 'Недопустимый индекс порядка', + [StateErrorCodes.MAX_TASKS_LIMIT_INVALID]: 'Лимит задач должен быть положительным числом', + [StateErrorCodes.AUTO_TRANSITION_INVALID]: 'Недопустимое состояние для автоперехода', + [StateErrorCodes.SYSTEM_TYPE_REQUIRED]: 'Для проекта должен быть хотя бы один системный тип каждого вида (todo, in_progress, done)', - [ProjectStateErrorCodes.DELETE_FAILED]: 'Не удалось удалить состояние', - [ProjectStateErrorCodes.CANNOT_DELETE_SYSTEM]: 'Нельзя удалить системное состояние', - [ProjectStateErrorCodes.CANNOT_DELETE_LAST_ACTIVE]: + [StateErrorCodes.DELETE_FAILED]: 'Не удалось удалить состояние', + [StateErrorCodes.CANNOT_DELETE_SYSTEM]: 'Нельзя удалить системное состояние', + [StateErrorCodes.CANNOT_DELETE_LAST_ACTIVE]: 'Нельзя удалить последнее активное состояние проекта', - [ProjectStateErrorCodes.HAS_ACTIVE_TASKS]: 'Нельзя удалить состояние, в котором есть задачи', - [ProjectStateErrorCodes.ALREADY_DELETED]: 'Состояние уже удалено', + [StateErrorCodes.HAS_ACTIVE_TASKS]: 'Нельзя удалить состояние, в котором есть задачи', + [StateErrorCodes.ALREADY_DELETED]: 'Состояние уже удалено', - [ProjectStateErrorCodes.RESTORE_FAILED]: 'Не удалось восстановить состояние', - [ProjectStateErrorCodes.NOT_DELETED]: 'Состояние не удалено, восстановление не требуется', + [StateErrorCodes.RESTORE_FAILED]: 'Не удалось восстановить состояние', + [StateErrorCodes.NOT_DELETED]: 'Состояние не удалено, восстановление не требуется', - [ProjectStateErrorCodes.REORDER_FAILED]: 'Не удалось изменить порядок состояний', - [ProjectStateErrorCodes.CANNOT_REORDER_SYSTEM]: 'Нельзя изменить порядок системных состояний', + [StateErrorCodes.REORDER_FAILED]: 'Не удалось изменить порядок состояний', + [StateErrorCodes.CANNOT_REORDER_SYSTEM]: 'Нельзя изменить порядок системных состояний', - [ProjectStateErrorCodes.CATEGORY_IMMUTABLE]: 'Нельзя изменить категорию системного состояния', - [ProjectStateErrorCodes.INVALID_CATEGORY]: 'Недопустимая категория состояния', + [StateErrorCodes.CATEGORY_IMMUTABLE]: 'Нельзя изменить категорию системного состояния', + [StateErrorCodes.INVALID_CATEGORY]: 'Недопустимая категория состояния', - [ProjectStateErrorCodes.CANNOT_HIDE_SYSTEM]: 'Нельзя скрыть системное состояние', + [StateErrorCodes.CANNOT_HIDE_SYSTEM]: 'Нельзя скрыть системное состояние', - [ProjectStateErrorCodes.NOTIFY_ON_ENTER_INVALID]: - 'Некорректная настройка уведомления при входе', - [ProjectStateErrorCodes.NOTIFY_ON_EXIT_INVALID]: - 'Некорректная настройка уведомления при выходе', + [StateErrorCodes.NOTIFY_ON_ENTER_INVALID]: 'Некорректная настройка уведомления при входе', + [StateErrorCodes.NOTIFY_ON_EXIT_INVALID]: 'Некорректная настройка уведомления при выходе', - [ProjectStateErrorCodes.VERSION_CONFLICT]: + [StateErrorCodes.VERSION_CONFLICT]: 'Состояние было изменено другим пользователем. Обновите страницу и попробуйте снова', - [ProjectStateErrorCodes.VERSION_REQUIRED]: 'Версия состояния обязательна для обновления', + [StateErrorCodes.VERSION_REQUIRED]: 'Версия состояния обязательна для обновления', - [ProjectStateErrorCodes.WIP_LIMIT_EXCEEDED]: 'Достигнут лимит задач в этом состоянии', - [ProjectStateErrorCodes.WIP_LIMIT_NEGATIVE]: 'Лимит задач не может быть отрицательным', + [StateErrorCodes.WIP_LIMIT_EXCEEDED]: 'Достигнут лимит задач в этом состоянии', + [StateErrorCodes.WIP_LIMIT_NEGATIVE]: 'Лимит задач не может быть отрицательным', - [ProjectStateErrorCodes.AUTO_TRANSITION_SELF]: - 'Нельзя настроить автопереход состояния на само себя', - [ProjectStateErrorCodes.AUTO_TRANSITION_NOT_FOUND]: - 'Целевое состояние для автоперехода не найдено', + [StateErrorCodes.AUTO_TRANSITION_SELF]: 'Нельзя настроить автопереход состояния на само себя', + [StateErrorCodes.AUTO_TRANSITION_NOT_FOUND]: 'Целевое состояние для автоперехода не найдено', - [ProjectStateErrorCodes.PARENT_STATE_NOT_FOUND]: 'Родительское состояние не найдено', - [ProjectStateErrorCodes.CIRCULAR_DEPENDENCY]: - 'Обнаружена циклическая зависимость между состояниями', + [StateErrorCodes.PARENT_STATE_NOT_FOUND]: 'Родительское состояние не найдено', + [StateErrorCodes.CIRCULAR_DEPENDENCY]: 'Обнаружена циклическая зависимость между состояниями', }; diff --git a/src/area/domain/repository/area.repository.interface.ts b/src/area/domain/repository/area.repository.interface.ts index 8ba9233..dc21dc1 100644 --- a/src/area/domain/repository/area.repository.interface.ts +++ b/src/area/domain/repository/area.repository.interface.ts @@ -1,10 +1,12 @@ import type { Area, NewArea } from '../entities'; export interface IAreaRepository { - create(dto: NewArea): Promise<{ id: string }>; + create(dto: NewArea): Promise<{ slug: string }>; update(projectId: string, areaId: string, dto: Partial): Promise; delete(projectId: string, areaId: string): Promise; findOne(projectId: string, areaId: string, includeDeleted?: boolean): Promise; findAll(projectId: string, includeDeleted?: boolean): Promise; - findBySlug(projectId: string, slug: string): Promise; + findBySlug(slug: string, projectId?: string): Promise; + + countByProject(projectId: string): Promise; } diff --git a/src/area/infrastructure/constants/index.ts b/src/area/infrastructure/constants/index.ts new file mode 100644 index 0000000..204598d --- /dev/null +++ b/src/area/infrastructure/constants/index.ts @@ -0,0 +1,11 @@ +export const MAX_AREAS_PER_PROJECT = 50; +export const MAX_STATES_PER_PROJECT = 20; + +export const DEFAULT_STATES = [ + { title: 'Бэклог', type: 'backlog', category: 'backlog', position: 0, color: '#94A3B8' }, + { title: 'К выполнению', type: 'todo', category: 'active', position: 1, color: '#3B82F6' }, + { title: 'В работе', type: 'in_progress', category: 'active', position: 2, color: '#F59E0B' }, + { title: 'На ревью', type: 'review', category: 'review', position: 3, color: '#8B5CF6' }, + { title: 'Готово', type: 'done', category: 'completed', position: 4, color: '#10B981' }, + { title: 'Архив', type: 'archived', category: 'archived', position: 5, color: '#6B7280' }, +] as const; diff --git a/src/area/infrastructure/persistence/models/area.model.ts b/src/area/infrastructure/persistence/models/area.model.ts index 4f2faf2..8341d43 100644 --- a/src/area/infrastructure/persistence/models/area.model.ts +++ b/src/area/infrastructure/persistence/models/area.model.ts @@ -1,15 +1,6 @@ -import { - text, - boolean, - varchar, - timestamp, - integer, - uniqueIndex, - index, -} from 'drizzle-orm/pg-core'; +import { text, boolean, varchar, timestamp, integer, index } from 'drizzle-orm/pg-core'; import { createId } from '@paralleldrive/cuid2'; import { isNotNull, isNull } from 'drizzle-orm'; -import { stateCategoryEnum, stateTypeEnum } from './enum'; import { baseSchema, projects, users } from '@shared/entities'; export const areas = baseSchema.table( @@ -48,41 +39,3 @@ export const areas = baseSchema.table( deletedAtIdx: index('idx_areas_deleted_at').on(t.deletedAt).where(isNotNull(t.deletedAt)), }), ); - -export const states = baseSchema.table( - 'states', - { - id: text('id') - .primaryKey() - .$defaultFn(() => createId()), - areaId: text('area_id').references(() => areas.id, { onDelete: 'cascade' }), - title: text('title').notNull(), - description: text('description'), - stateType: stateTypeEnum('state_type').notNull().default('custom'), - category: stateCategoryEnum('category').notNull().default('active'), - color: varchar('color', { length: 10 }), - icon: varchar('icon', { length: 20 }), - position: integer('position').notNull().default(0), - isVisible: boolean('is_visible').notNull().default(true), - maxTasksLimit: integer('max_tasks_limit'), - autoTransitionTo: text('auto_transition_to'), - notifyOnEnter: boolean('notify_on_enter').default(false), - notifyOnExit: boolean('notify_on_exit').default(false), - isLocked: boolean('is_locked').default(false), - createdAt: timestamp('created_at', { withTimezone: true, mode: 'string' }) - .notNull() - .defaultNow(), - updatedAt: timestamp('updated_at', { withTimezone: true, mode: 'string' }) - .notNull() - .defaultNow(), - createdBy: text('created_by').references(() => users.id), - deletedAt: timestamp('deleted_at', { withTimezone: true, mode: 'string' }), - }, - (t) => ({ - statePositionIdx: index('idx_states_position').on(t.areaId, t.position), - uniqueStateTitle: uniqueIndex('idx_states_unique_title') - .on(t.areaId, t.title) - .where(isNull(t.deletedAt)), - deletedAtIdx: index('idx_states_deleted_at').on(t.deletedAt).where(isNotNull(t.deletedAt)), - }), -); diff --git a/src/area/infrastructure/persistence/models/enum.ts b/src/area/infrastructure/persistence/models/enum.ts index ba3e9d9..9336290 100644 --- a/src/area/infrastructure/persistence/models/enum.ts +++ b/src/area/infrastructure/persistence/models/enum.ts @@ -1,19 +1,5 @@ +import { STATE_CATEGORIES, STATE_TYPES } from '@core/area/domain/entities'; import { baseSchema } from '@shared/entities'; -export const stateTypeEnum = baseSchema.enum('state_type', [ - 'backlog', - 'todo', - 'in_progress', - 'review', - 'done', - 'archived', - 'custom', -]); - -export const stateCategoryEnum = baseSchema.enum('state_category', [ - 'backlog', - 'active', - 'review', - 'completed', - 'archived', -]); +export const stateTypeEnum = baseSchema.enum('state_type', STATE_TYPES); +export const stateCategoryEnum = baseSchema.enum('state_category', STATE_CATEGORIES); diff --git a/src/area/infrastructure/persistence/models/index.ts b/src/area/infrastructure/persistence/models/index.ts index f8325d4..9c8a44f 100644 --- a/src/area/infrastructure/persistence/models/index.ts +++ b/src/area/infrastructure/persistence/models/index.ts @@ -1,2 +1,3 @@ -export { areas, states } from './area.model'; +export { areas } from './area.model'; +export { states } from './state.model'; export { stateCategoryEnum, stateTypeEnum } from './enum'; diff --git a/src/area/infrastructure/persistence/models/state.model.ts b/src/area/infrastructure/persistence/models/state.model.ts new file mode 100644 index 0000000..44a6508 --- /dev/null +++ b/src/area/infrastructure/persistence/models/state.model.ts @@ -0,0 +1,55 @@ +import { + text, + boolean, + varchar, + timestamp, + integer, + uniqueIndex, + index, +} from 'drizzle-orm/pg-core'; +import { createId } from '@paralleldrive/cuid2'; +import { isNotNull, isNull } from 'drizzle-orm'; +import { stateCategoryEnum, stateTypeEnum } from './enum'; +import { baseSchema, users } from '@shared/entities'; +import { areas } from './area.model'; + +export const states = baseSchema.table( + 'states', + { + id: text('id') + .primaryKey() + .$defaultFn(() => createId()), + areaId: text('area_id').references(() => areas.id, { onDelete: 'cascade' }), + title: text('title').notNull(), + description: text('description'), + stateType: stateTypeEnum('state_type').notNull().default('custom'), + category: stateCategoryEnum('category').notNull().default('active'), + color: varchar('color', { length: 10 }), + icon: varchar('icon', { length: 20 }), + position: integer('position').notNull().default(0), + isVisible: boolean('is_visible').notNull().default(true), + maxTasksLimit: integer('max_tasks_limit'), + autoTransitionTo: text('auto_transition_to'), + notifyOnEnter: boolean('notify_on_enter').default(false), + notifyOnExit: boolean('notify_on_exit').default(false), + isLocked: boolean('is_locked').default(false), + createdAt: timestamp('created_at', { withTimezone: true, mode: 'string' }) + .notNull() + .defaultNow(), + updatedAt: timestamp('updated_at', { withTimezone: true, mode: 'string' }) + .notNull() + .defaultNow(), + createdBy: text('created_by').references(() => users.id), + deletedAt: timestamp('deleted_at', { withTimezone: true, mode: 'string' }), + }, + (t) => ({ + statePositionIdx: index('idx_states_position').on(t.areaId, t.position), + stateTitleIdx: index('idx_states_title').on(t.areaId, t.title), + stateCreatedAtIdx: index('idx_states_created_at').on(t.areaId, t.createdAt), + searchIdx: index('idx_states_search').on(t.areaId, t.title), + uniqueStateTitle: uniqueIndex('idx_states_unique_title') + .on(t.areaId, t.title) + .where(isNull(t.deletedAt)), + deletedAtIdx: index('idx_states_deleted_at').on(t.deletedAt).where(isNotNull(t.deletedAt)), + }), +); diff --git a/src/area/infrastructure/persistence/repositories/area.repository.ts b/src/area/infrastructure/persistence/repositories/area.repository.ts index 31debbb..3bc9aae 100644 --- a/src/area/infrastructure/persistence/repositories/area.repository.ts +++ b/src/area/infrastructure/persistence/repositories/area.repository.ts @@ -1,9 +1,10 @@ import { Inject, Injectable } from '@nestjs/common'; import { DATABASE_SERVICE, DatabaseService } from '@libs/database'; import * as schema from '../models'; -import { and, eq, isNotNull, isNull } from 'drizzle-orm'; +import { and, count, eq, isNotNull, isNull } from 'drizzle-orm'; import { IAreaRepository } from '@core/area/domain/repository'; import type { NewArea } from '@core/area/domain/entities'; +import { DEFAULT_STATES } from '../../constants'; @Injectable() export class AreaRepository implements IAreaRepository { @@ -13,10 +14,26 @@ export class AreaRepository implements IAreaRepository { ) {} public async create(data: NewArea) { - const [result] = await this.db - .insert(schema.areas) - .values(data) - .returning({ id: schema.areas.id }); + const result = await this.db.transaction(async (tx) => { + const [area] = await tx + .insert(schema.areas) + .values(data) + .returning({ id: schema.areas.id, slug: schema.areas.slug }); + + const statesData = DEFAULT_STATES.map((state) => ({ + areaId: area.id, + title: state.title, + type: state.type, + category: state.category, + position: state.position, + color: state.color, + createdBy: data.createdBy, + })); + + await tx.insert(schema.states).values(statesData); + + return { slug: area.slug }; + }); return result; } @@ -65,7 +82,7 @@ export class AreaRepository implements IAreaRepository { ), ); - return result ?? null; + return result || null; } public async findAll(projectId: string, includeDeleted = false) { @@ -83,18 +100,27 @@ export class AreaRepository implements IAreaRepository { .orderBy(schema.areas.position); } - public async findBySlug(projectId: string, slug: string) { + public async findBySlug(slug: string, projectId?: string) { const [result] = await this.db .select() .from(schema.areas) .where( and( - eq(schema.areas.projectId, projectId), + projectId ? eq(schema.areas.projectId, projectId) : undefined, eq(schema.areas.slug, slug), isNull(schema.areas.deletedAt), ), ); - return result ?? null; + return result || null; + } + + public async countByProject(projectId: string): Promise { + const [result] = await this.db + .select({ count: count().mapWith(Number) }) + .from(schema.areas) + .where(and(eq(schema.areas.projectId, projectId), isNull(schema.areas.deletedAt))); + + return result?.count ?? 0; } } diff --git a/src/area/infrastructure/persistence/repositories/state.repository.ts b/src/area/infrastructure/persistence/repositories/state.repository.ts index 103c240..066168b 100644 --- a/src/area/infrastructure/persistence/repositories/state.repository.ts +++ b/src/area/infrastructure/persistence/repositories/state.repository.ts @@ -23,21 +23,24 @@ export class StateRepository implements IStateRepository { public async delete(areaId: string, stateId: string) { const result = await this.db - .delete(schema.states) + .update(schema.states) + .set({ deletedAt: new Date().toISOString() }) .where( and( eq(schema.states.id, stateId), eq(schema.states.areaId, areaId), - isNotNull(schema.states.deletedAt), + isNull(schema.states.deletedAt), ), ); return (result.count ?? 0) > 0; } - public async find(query: unknown) { - void query; - return this.db.select().from(schema.states); + public async find(areaId: string, _query: unknown) { + return this.db + .select() + .from(schema.states) + .where(and(eq(schema.states.areaId, areaId), isNull(schema.states.deletedAt))); } public async findOne(areaId: string, stateId: string, deleted?: boolean) { diff --git a/src/projects/application/use-cases/member/add.use-case.ts b/src/projects/application/use-cases/member/add.use-case.ts index 0816a0c..1f2ee28 100644 --- a/src/projects/application/use-cases/member/add.use-case.ts +++ b/src/projects/application/use-cases/member/add.use-case.ts @@ -3,11 +3,9 @@ import { IMemberRepository } from '@core/projects/domain/repository'; import { HttpStatus, Inject, Injectable } from '@nestjs/common'; import { BaseException } from '@shared/error'; import { AddProjectMemberDto } from '../../dtos'; -import { MemberErrorCodes, MemberErrorMessages } from '@core/projects/domain/errors/member.errors'; +import { MemberErrorCodes, MemberErrorMessages } from '@core/projects/domain/errors'; import { FindTeamMemberQuery } from '@core/teams'; - -// TODO: at feature migrate to dynamic field at project -const MAX_MEMBERS_PROJECT = 10; +import { MAX_MEMBERS_PER_PROJECT } from '@core/projects/infrastructure/constants'; @Injectable() export class AddProjectMemberUseCase { @@ -63,8 +61,8 @@ export class AddProjectMemberUseCase { } const currentCount = await this.memberRepo.countByProject(project.id); - // TODO: project.settings?.maxMembers ?? MAX_MEMBERS_PROJECT - if (currentCount >= MAX_MEMBERS_PROJECT) { + // TODO: project.settings?.maxMembers ?? MAX_MEMBERS_PER_PROJECT + if (currentCount >= MAX_MEMBERS_PER_PROJECT) { throw new BaseException( { code: MemberErrorCodes.LIMIT_REACHED, diff --git a/src/projects/application/use-cases/member/delete.use-case.ts b/src/projects/application/use-cases/member/delete.use-case.ts index ebf97c7..e19996d 100644 --- a/src/projects/application/use-cases/member/delete.use-case.ts +++ b/src/projects/application/use-cases/member/delete.use-case.ts @@ -1,4 +1,4 @@ -import { MemberErrorCodes, MemberErrorMessages } from '@core/projects/domain/errors/member.errors'; +import { MemberErrorCodes, MemberErrorMessages } from '@core/projects/domain/errors'; import { ProjectAccessPolicy } from '@core/projects/domain/policy'; import { IMemberRepository } from '@core/projects/domain/repository'; import { HttpStatus, Inject, Injectable } from '@nestjs/common'; diff --git a/src/projects/application/use-cases/member/update.use-case.ts b/src/projects/application/use-cases/member/update.use-case.ts index 7da7a1e..ca66d6a 100644 --- a/src/projects/application/use-cases/member/update.use-case.ts +++ b/src/projects/application/use-cases/member/update.use-case.ts @@ -3,7 +3,7 @@ import { IMemberRepository } from '@core/projects/domain/repository'; import { HttpStatus, Inject, Injectable } from '@nestjs/common'; import { BaseException } from '@shared/error'; import { UpdateProjectMemberDto } from '../../dtos'; -import { MemberErrorCodes, MemberErrorMessages } from '@core/projects/domain/errors/member.errors'; +import { MemberErrorCodes, MemberErrorMessages } from '@core/projects/domain/errors'; @Injectable() export class UpdateProjectMemberUseCase { diff --git a/src/projects/application/use-cases/project/create.use-case.ts b/src/projects/application/use-cases/project/create.use-case.ts index ec52494..cc0dadf 100644 --- a/src/projects/application/use-cases/project/create.use-case.ts +++ b/src/projects/application/use-cases/project/create.use-case.ts @@ -6,9 +6,7 @@ import { ProjectAccessPolicy } from '@core/projects/domain/policy'; import { BaseException } from '@shared/error'; import { ProjectErrorCodes, ProjectErrorMessages } from '@core/projects/domain/errors'; import slugify from 'slugify'; - -// TODO: at feature migrate to dynamic field at team -const MAX_PROJECTS_PER_TEAM = 20; +import { MAX_PROJECTS_PER_TEAM } from '@core/projects/infrastructure/constants'; @Injectable() export class CreateProjectUseCase { @@ -21,7 +19,8 @@ export class CreateProjectUseCase { const { settings, ...project } = dto; const { team } = await this.policy.ensureTeamAccess(teamId, userId, 'admin'); - console.log(settings); + // TODO: TMP VAR + console.debug(settings); const currentSlug = slugify(project?.slug ? project.slug : project.name, { lower: true, diff --git a/src/projects/application/use-cases/project/generate-share-token.use-case.ts b/src/projects/application/use-cases/project/generate-share-token.use-case.ts index b991632..4490059 100644 --- a/src/projects/application/use-cases/project/generate-share-token.use-case.ts +++ b/src/projects/application/use-cases/project/generate-share-token.use-case.ts @@ -5,12 +5,14 @@ import { BaseException } from '@shared/error'; import { ProjectAccessPolicy } from '@core/projects/domain/policy'; import { IProjectRepository } from '@core/projects/domain/repository'; import { ProjectErrorCodes, ProjectErrorMessages } from '@core/projects/domain/errors'; +import { + SHARE_LINK_LENGTH, + SHARE_LINK_PREFIX, + SHARE_LINK_TTL_MONTHS, +} from '@core/projects/infrastructure/constants'; @Injectable() export class GenerateShareTokenUseCase { - private readonly TOKEN_PREFIX = 'st_'; - private readonly DEFAULT_TTL_MONTHS = 3; - constructor( @Inject('IProjectRepository') private readonly projectsRepo: IProjectRepository, @@ -99,12 +101,12 @@ export class GenerateShareTokenUseCase { } const date = new Date(); - date.setMonth(date.getMonth() + this.DEFAULT_TTL_MONTHS); + date.setMonth(date.getMonth() + SHARE_LINK_TTL_MONTHS); return date; } private generateToken(): string { - return `${this.TOKEN_PREFIX}${randomBytes(32).toString('hex')}`; + return `${SHARE_LINK_PREFIX}${randomBytes(SHARE_LINK_LENGTH).toString('hex')}`; } private hashToken(token: string): string { diff --git a/src/projects/domain/errors/index.ts b/src/projects/domain/errors/index.ts index a435f03..0e10ef2 100644 --- a/src/projects/domain/errors/index.ts +++ b/src/projects/domain/errors/index.ts @@ -1 +1,2 @@ export * from './project.errors'; +export * from './member.errors'; diff --git a/src/projects/infrastructure/constants/index.ts b/src/projects/infrastructure/constants/index.ts new file mode 100644 index 0000000..555e1cb --- /dev/null +++ b/src/projects/infrastructure/constants/index.ts @@ -0,0 +1,6 @@ +export const MAX_PROJECTS_PER_TEAM = 20; +export const MAX_MEMBERS_PER_PROJECT = 10; + +export const SHARE_LINK_TTL_MONTHS = 3; +export const SHARE_LINK_PREFIX = 'st_'; +export const SHARE_LINK_LENGTH = 16; diff --git a/src/shared/decorators/index.ts b/src/shared/decorators/index.ts index 0fff3c2..28d4e73 100644 --- a/src/shared/decorators/index.ts +++ b/src/shared/decorators/index.ts @@ -2,3 +2,4 @@ export * from './api-controller.decorator'; export * from './public.decorator'; export * from './user.decorator'; export * from './skip-zod-validation.decorator'; +export * from './query-list.decorator'; diff --git a/src/shared/decorators/query-list.decorator.ts b/src/shared/decorators/query-list.decorator.ts new file mode 100644 index 0000000..4bec7b5 --- /dev/null +++ b/src/shared/decorators/query-list.decorator.ts @@ -0,0 +1,109 @@ +import { applyDecorators } from '@nestjs/common'; +import { ApiQuery } from '@nestjs/swagger'; + +export interface SortableFields { + fields: string[]; + defaultField?: string; + defaultOrder?: 'asc' | 'desc'; +} + +export interface ListQueryOptions { + sortableFields: string[]; + defaultSortField?: string; + defaultSortOrder?: 'asc' | 'desc'; + withSearch?: boolean; + withDateRange?: boolean; +} + +export const ApiPagination = () => + applyDecorators( + ApiQuery({ + name: 'page', + required: false, + type: Number, + description: 'Номер страницы (начиная с 1)', + example: 1, + }), + ApiQuery({ + name: 'limit', + required: false, + type: Number, + description: 'Количество записей на странице (макс. 100)', + example: 20, + }), + ApiQuery({ + name: 'offset', + required: false, + type: Number, + description: 'Смещение для пагинации (альтернатива page)', + example: 0, + }), + ); + +export const ApiSorting = (options: SortableFields) => + applyDecorators( + ApiQuery({ + name: 'sortBy', + required: false, + enum: options.fields, + description: `Поле для сортировки. Доступные поля: ${options.fields.join(', ')}`, + example: options.defaultField || options.fields[0], + }), + ApiQuery({ + name: 'sortOrder', + required: false, + enum: ['asc', 'desc'], + description: 'Направление сортировки', + example: options.defaultOrder || 'asc', + }), + ); + +export const ApiDateRangeFilter = () => + applyDecorators( + ApiQuery({ + name: 'fromDate', + required: false, + type: String, + description: 'Начальная дата (ISO 8601)', + example: '2024-01-01T00:00:00Z', + }), + ApiQuery({ + name: 'toDate', + required: false, + type: String, + description: 'Конечная дата (ISO 8601)', + example: '2024-12-31T23:59:59Z', + }), + ); + +export const ApiSearchFilter = () => + applyDecorators( + ApiQuery({ + name: 'search', + required: false, + type: String, + description: 'Поиск по тексту', + example: 'keyword', + }), + ); + +export const ApiListQuery = (options: ListQueryOptions) => { + const decorators = [ + ApiPagination(), + ApiSorting({ + fields: options.sortableFields, + defaultField: options.defaultSortField, + defaultOrder: options.defaultSortOrder, + }), + ]; + + if (options.withSearch) { + decorators.push(ApiSearchFilter()); + } + + if (options.withDateRange) { + decorators.push(ApiDateRangeFilter()); + } + + return applyDecorators(...decorators); +}; diff --git a/src/shared/error/schema.ts b/src/shared/error/schema.ts index 7c0129c..734c926 100644 --- a/src/shared/error/schema.ts +++ b/src/shared/error/schema.ts @@ -1,4 +1,4 @@ -import { z } from 'zod'; +import { z } from 'zod/v4'; import { createZodDto } from 'nestjs-zod'; const ErrorDetailSchema = z.object({ diff --git a/src/shared/schemas/datetime.schema.ts b/src/shared/schemas/datetime.schema.ts new file mode 100644 index 0000000..f3c84e5 --- /dev/null +++ b/src/shared/schemas/datetime.schema.ts @@ -0,0 +1,28 @@ +import { z } from 'zod/v4'; + +export const DateRangeFilterSchema = z + .object({ + fromDate: z + .string() + .datetime({ offset: true }) + .optional() + .describe('Начальная дата (ISO 8601)'), + + toDate: z + .string() + .datetime({ offset: true }) + .optional() + .describe('Конечная дата (ISO 8601)'), + }) + .refine( + (data) => { + if (data.fromDate && data.toDate) { + return new Date(data.fromDate) <= new Date(data.toDate); + } + return true; + }, + { + message: 'Дата начала не может быть позже даты окончания', + path: ['fromDate'], + }, + ); diff --git a/src/shared/schemas/index.ts b/src/shared/schemas/index.ts index f1d115d..8b946b2 100644 --- a/src/shared/schemas/index.ts +++ b/src/shared/schemas/index.ts @@ -1,2 +1,5 @@ -export * from './pagination-response.schema'; +export * from './pagination.schema'; export * from './avatar-response.schema'; +export * from './datetime.schema'; +export * from './search.schema'; +export * from './sorting.schema'; diff --git a/src/shared/schemas/pagination-response.schema.ts b/src/shared/schemas/pagination.schema.ts similarity index 53% rename from src/shared/schemas/pagination-response.schema.ts rename to src/shared/schemas/pagination.schema.ts index ddf94d8..400d5c1 100644 --- a/src/shared/schemas/pagination-response.schema.ts +++ b/src/shared/schemas/pagination.schema.ts @@ -1,5 +1,42 @@ import { z } from 'zod/v4'; +export const PaginationBaseSchema = z.object({ + page: z.coerce + .number() + .int() + .positive('Страница должна быть положительным числом') + .optional() + .default(1) + .describe('Номер страницы (начиная с 1)'), + + offset: z.coerce + .number() + .int() + .min(0, 'Смещение не может быть отрицательным') + .optional() + .default(0) + .describe('Смещение для пагинации (альтернатива page)'), + + limit: z.coerce + .number() + .int() + .min(1, 'Лимит должен быть не менее 1') + .max(100, 'Лимит не может превышать 100') + .optional() + .default(20) + .describe('Количество записей на странице'), +}); + +export const PaginationSchema = PaginationBaseSchema.transform((data) => { + if (data.page > 1 && data.offset === 0) { + return { + ...data, + offset: (data.page - 1) * (data.limit || 20), + }; + } + return data; +}); + export const paginationResponseSchema = z.object({ hasNextPage: z .boolean() diff --git a/src/shared/schemas/search.schema.ts b/src/shared/schemas/search.schema.ts new file mode 100644 index 0000000..8ca2f52 --- /dev/null +++ b/src/shared/schemas/search.schema.ts @@ -0,0 +1,11 @@ +import { z } from 'zod/v4'; + +export const SearchFilterSchema = z.object({ + search: z + .string() + .trim() + .min(1, 'Поисковый запрос не может быть пустым') + .max(100, 'Поисковый запрос слишком длинный') + .optional() + .describe('Поиск по тексту'), +}); diff --git a/src/shared/schemas/sorting.schema.ts b/src/shared/schemas/sorting.schema.ts new file mode 100644 index 0000000..1c2f70a --- /dev/null +++ b/src/shared/schemas/sorting.schema.ts @@ -0,0 +1,28 @@ +import { z } from 'zod/v4'; + +export const createSortingSchema = ( + fields: T, + defaultField?: T[number], + defaultOrder: 'asc' | 'desc' = 'asc', +) => { + return z.object({ + sortBy: z + .enum(fields) + .optional() + /** + * Приведение as any обусловлено ограничением системы типов TypeScript: + * тип fields[0 выводится как string, а Zod ожидает конкретный литеральный тип + * из объединения T[number]. В рантайме значение гарантированно валидно, + * так как массив fields используется для создания enum. + * as any безопасно подавляет ошибку, не расширяя тип за пределы этой строки. + */ + .default(defaultField ?? (fields[0] as any)) + .describe(`Поле для сортировки. Доступно: ${fields.join(', ')}`), + + sortOrder: z + .enum(['asc', 'desc']) + .optional() + .default(() => defaultOrder) + .describe('Направление сортировки: asc - по возрастанию, desc - по убыванию'), + }); +}; From a71076b6d23aeb4ec7f92d555791ca2f9b629669 Mon Sep 17 00:00:00 2001 From: soorq Date: Sun, 14 Jun 2026 20:44:27 +0300 Subject: [PATCH 5/5] fix: errors with merge --- libs/metrics/src/metrics.controller.ts | 4 ++-- src/app.module.ts | 2 ++ src/auth/domain/events/index.ts | 5 +++-- src/auth/infrastructure/workers/user.processor.ts | 11 ++++++++--- src/projects/projects.module.ts | 4 ++-- 5 files changed, 17 insertions(+), 9 deletions(-) diff --git a/libs/metrics/src/metrics.controller.ts b/libs/metrics/src/metrics.controller.ts index a7587be..7ef1d6a 100644 --- a/libs/metrics/src/metrics.controller.ts +++ b/libs/metrics/src/metrics.controller.ts @@ -1,12 +1,12 @@ import { Controller, Get, Res } from '@nestjs/common'; import * as client from 'prom-client'; import { FastifyReply } from 'fastify'; -import { SkipZodValidation } from '@shared/decorators'; +import { SkipContractHandle } from '@shared/decorators'; @Controller('metrics') export class MetricsController { @Get() - @SkipZodValidation() + @SkipContractHandle() async getMetrics(@Res() reply: FastifyReply) { const metrics = await client.register.metrics(); reply.type(client.register.contentType).send(metrics); diff --git a/src/app.module.ts b/src/app.module.ts index 518cd25..50bbc34 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -22,6 +22,7 @@ import { ICacheService } from '@shared/adapters/cache/ports'; import { DatabaseHealthService } from '@libs/database'; import { ZodValidationInterceptor } from '@shared/interceptors'; import { AreaModule } from './area'; +import { MetricsModule } from '@libs/metrics'; @Module({ imports: [ @@ -57,6 +58,7 @@ import { AreaModule } from './area'; TeamsModule, ProjectsModule, AreaModule, + MetricsModule, HealthModule.registerAsync({ inject: [DatabaseHealthService, S3Service, CACHE_SERVICE], useFactory: (db: DatabaseHealthService, s3: S3Service, cache: ICacheService) => { diff --git a/src/auth/domain/events/index.ts b/src/auth/domain/events/index.ts index 61a6360..9d7e25f 100644 --- a/src/auth/domain/events/index.ts +++ b/src/auth/domain/events/index.ts @@ -1,2 +1,3 @@ -export { RegisterCodeEvent } from './register-code.event'; -export { ResetPasswordEvent } from './reset-password.event'; +export * from './register-code.event'; +export * from './reset-password.event'; +export * from './create-user-workspace.event'; diff --git a/src/auth/infrastructure/workers/user.processor.ts b/src/auth/infrastructure/workers/user.processor.ts index 82200ee..174eb12 100644 --- a/src/auth/infrastructure/workers/user.processor.ts +++ b/src/auth/infrastructure/workers/user.processor.ts @@ -4,7 +4,8 @@ import { Job } from 'bullmq'; import { CreateTeamUseCase } from '@core/teams/application/use-cases'; import { CreateProjectUseCase } from '@core/projects/application/use-cases'; import { AuthUserJobs } from '@core/auth/domain/enums/auth-jobs.enum'; -import { CreateUserWorkspaceEvent } from '@core/auth/domain/events/create-user-workspace.event'; +import { CreateUserWorkspaceEvent } from '@core/auth/domain/events'; +import slugify from 'slugify'; @Processor(AuthQueues.AUTH_USER) export class UserProcessor extends WorkerHost { @@ -31,7 +32,7 @@ export class UserProcessor extends WorkerHost { await job.log(`[DONE] Job ${job.id} processed`); } catch (error) { - await job.log(error); + await job.log(String(error)); throw error; } @@ -51,7 +52,11 @@ export class UserProcessor extends WorkerHost { await this.createProjectUseCase.execute(userId, team.teamId, { name: `${username}'s Project`, description: `Personal project for ${username}`, - key: username.slice(0, 10).toUpperCase(), + slug: slugify(username.slice(0, 10), { + lower: true, + strict: true, + }), + status: 'active', visibility: 'private', }); diff --git a/src/projects/projects.module.ts b/src/projects/projects.module.ts index 133b9cf..b219976 100644 --- a/src/projects/projects.module.ts +++ b/src/projects/projects.module.ts @@ -1,7 +1,7 @@ import { forwardRef, Module } from '@nestjs/common'; import { TeamsModule } from '@core/teams'; import { CONTROLLERS } from './application/controller'; -import { FindProjectQuery, USE_CASES } from './application/use-cases'; +import { CreateProjectUseCase, FindProjectQuery, USE_CASES } from './application/use-cases'; import { POLICIES, ProjectAccessPolicy } from './domain/policy'; import { ProjectFacade } from './application/project.facade'; import { REPOSITORIES } from './infrastructure/persistence/repositories'; @@ -11,6 +11,6 @@ import { UserModule } from '@core/user'; imports: [UserModule, forwardRef(() => TeamsModule)], controllers: CONTROLLERS, providers: [...REPOSITORIES, ...POLICIES, ...USE_CASES, ProjectFacade], - exports: [FindProjectQuery, ProjectAccessPolicy], + exports: [FindProjectQuery, ProjectAccessPolicy, CreateProjectUseCase], }) export class ProjectsModule {}