diff --git a/src/app.module.ts b/src/app.module.ts index 0d82338..a73784d 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -16,7 +16,7 @@ import { BullModule } from '@nestjs/bullmq'; import { MailModule } from '@shared/adapters/mail'; import { MigrationService } from '@shared/migration'; import { TeamsModule } from './teams'; -import { ProjectsModule } from './modules/projects'; +import { ProjectsModule } from './projects'; @Module({ imports: [ diff --git a/src/modules/projects/commands/find-project.command.ts b/src/modules/projects/commands/find-project.command.ts deleted file mode 100644 index a1d358b..0000000 --- a/src/modules/projects/commands/find-project.command.ts +++ /dev/null @@ -1,90 +0,0 @@ -import { HttpStatus, Inject, Injectable } from '@nestjs/common'; -import { IProjectsRepository } from '../repository'; -import { FindTeamMemberQuery } from '@core/teams'; -import { createHash } from 'crypto'; -import type { Project } from '../entities'; -import { BaseException } from '@shared/error'; - -@Injectable() -export class FindProjectCommand { - constructor( - @Inject('IProjectsRepository') - private readonly projectsRepo: IProjectsRepository, - private readonly findTeamMemberQ: FindTeamMemberQuery, - ) {} - - public async execute(projectId: string, userId?: string, shareToken?: string) { - const project = await this.projectsRepo.findOne(projectId); - - if (!project) { - throw new BaseException( - { - code: 'PROJECT_NOT_FOUND', - message: 'Проект не найден или доступ ограничен', - details: [{ target: 'projectId', value: projectId }], - }, - HttpStatus.NOT_FOUND, - ); - } - - if (shareToken) { - return this.findPublic(project, shareToken); - } - - return this.findPrivate(project, userId); - } - - private findPrivate = async (project: Project, userId?: string) => { - if (!userId) { - throw new BaseException( - { - code: 'AUTH_REQUIRED', - message: 'Для доступа к приватному проекту нужна авторизация', - }, - HttpStatus.UNAUTHORIZED, - ); - } - - const member = await this.findTeamMemberQ.execute(project.teamId, userId); - - if (!member) { - throw new BaseException( - { - code: 'ACCESS_DENIED', - message: 'У вас нет прав для просмотра этого проекта', - details: [{ target: 'teamId', value: project.teamId }], - }, - HttpStatus.FORBIDDEN, - ); - } - - return { project, member }; - }; - - private findPublic = async (project: Project, token: string) => { - if (project.visibility !== 'public') { - throw new BaseException( - { - code: 'PROJECT_NOT_PUBLIC', - message: 'Этот проект не является публичным', - }, - HttpStatus.FORBIDDEN, - ); - } - - const hashedToken = createHash('sha256').update(token).digest('hex'); - const isValidToken = await this.projectsRepo.hasValidShareToken(project.id, hashedToken); - - if (!isValidToken) { - throw new BaseException( - { - code: 'SHARE_LINK_INVALID', - message: 'Ссылка недействительна или срок её действия истек', - }, - HttpStatus.GONE, - ); - } - - return { project, member: null }; - }; -} diff --git a/src/modules/projects/commands/index.ts b/src/modules/projects/commands/index.ts deleted file mode 100644 index d79925b..0000000 --- a/src/modules/projects/commands/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { FindProjectCommand } from './find-project.command'; diff --git a/src/modules/projects/controller/index.ts b/src/modules/projects/controller/index.ts deleted file mode 100644 index 19a0d95..0000000 --- a/src/modules/projects/controller/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { ProjectsController } from './projects.controller'; diff --git a/src/modules/projects/entities/index.ts b/src/modules/projects/entities/index.ts deleted file mode 100644 index 4dd5b24..0000000 --- a/src/modules/projects/entities/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export { projects, projectShares } from './projects.entity'; -export { projectStatusEnum, projectVisibilityEnum } from './enums'; -export * from './entities.domain'; diff --git a/src/modules/projects/projects.module.ts b/src/modules/projects/projects.module.ts deleted file mode 100644 index fb78d1b..0000000 --- a/src/modules/projects/projects.module.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { forwardRef, Module } from '@nestjs/common'; -import { ProjectsService } from './services'; -import { ProjectsController } from './controller'; -import { ProjectsRepository } from './repository'; -import { TeamsModule } from '../../teams'; -import { FindProjectCommand } from './commands'; - -const REPOSITORY = { - provide: 'IProjectsRepository', - useClass: ProjectsRepository, -}; - -@Module({ - imports: [forwardRef(() => TeamsModule)], - controllers: [ProjectsController], - providers: [REPOSITORY, FindProjectCommand, ProjectsService], - exports: [FindProjectCommand], -}) -export class ProjectsModule {} diff --git a/src/modules/projects/services/index.ts b/src/modules/projects/services/index.ts deleted file mode 100644 index e46b58b..0000000 --- a/src/modules/projects/services/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { ProjectsService } from './projects.service'; diff --git a/src/modules/projects/services/projects.service.ts b/src/modules/projects/services/projects.service.ts deleted file mode 100644 index f15f6ce..0000000 --- a/src/modules/projects/services/projects.service.ts +++ /dev/null @@ -1,327 +0,0 @@ -import { HttpStatus, Inject, Injectable } from '@nestjs/common'; -import { IProjectsRepository } from '../repository'; -import type { CreateProjectDto, CreateShareTokenDto, UpdateProjectDto } from '../dtos'; -import { ROLE_PRIORITY } from '@shared/constants'; -import { ProjectStatus } from '../entities'; -import { ProjectsMapper } from '../mappers'; -import { createHash, randomBytes } from 'crypto'; -import { BaseException } from '@shared/error'; -import { FindTeamMemberQuery, FindTeamQuery } from '@core/teams'; - -@Injectable() -export class ProjectsService { - constructor( - @Inject('IProjectsRepository') - private readonly projectsRepo: IProjectsRepository, - private readonly findTeamQ: FindTeamQuery, - private readonly findTeamMemberQ: FindTeamMemberQuery, - ) {} - - public create = async (userId: string, slug: string, dto: CreateProjectDto) => { - const { team } = await this.ensureTeamAccess(slug, userId, 'admin'); - - const data = { - ...dto, - teamId: team.id, - ownerId: userId, - key: dto.key.toUpperCase(), - status: ProjectStatus.Active, - }; - - const { result, id } = await this.projectsRepo.create(data); - - return { - success: result, - message: `Проект ${dto.name} успешно создан`, - projectId: id, - }; - }; - - public generateToken = async ( - id: string, - slug: string, - userId: string, - dto: CreateShareTokenDto, - ) => { - const project = await this.validateAccess(id, slug, 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, - 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(), - }, - }; - }; - - public delete = async (id: string, slug: string, userId: string) => { - const project = await this.validateAccess(id, slug, userId); - const result = await this.projectsRepo.delete(project.id); - - if (!result) { - throw new BaseException( - { - code: 'DELETE_FAILED', - message: 'Не удалось удалить проект', - }, - HttpStatus.SERVICE_UNAVAILABLE, - ); - } - - return { - success: result, - message: result - ? `Проект ${project.name} успешно перемещен в корзину` - : 'Не удалось удалить проект, попробуйте позже', - }; - }; - - public update = async (id: string, slug: string, userId: string, dto: UpdateProjectDto) => { - const project = await this.validateAccess(id, slug, userId); - const { isPublic, key, ...data } = dto; - - const result = await this.projectsRepo.update(project.id, { - ...data, - ...(key && { key: key.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 ? 'Настройки проекта успешно обновлены' : 'Изменения не были применены', - }; - }; - - public findOne = async (id: string, slug: string, userId: string, token: string) => { - const project = await this.projectsRepo.findOne(id); - - if (!project) { - throw new BaseException( - { - code: 'PROJECT_NOT_FOUND', - message: 'Проект не найден', - }, - HttpStatus.NOT_FOUND, - ); - } - - if (token) { - const hashedToken = this.hash(token); - const isValidAccess = await this.projectsRepo.hasValidShareToken( - project.id, - hashedToken, - ); - - if (!isValidAccess) { - throw new BaseException( - { - code: 'INVALID_TOKEN', - message: 'Ссылка недействительна или срок её действия истек', - }, - HttpStatus.GONE, - ); - } - - return ProjectsMapper.toDetailResponse(project, null, token); - } - - if (!userId) { - throw new BaseException( - { code: 'AUTH_REQUIRED', message: 'Требуется авторизация' }, - HttpStatus.UNAUTHORIZED, - ); - } - - const { member, team } = await this.ensureTeamAccess(slug, userId, 'viewer'); - - if (team.id !== project.teamId) { - throw new BaseException( - { code: 'PROJECT_MISMATCH', message: 'Проект не принадлежит этой команде' }, - HttpStatus.BAD_REQUEST, - ); - } - - return ProjectsMapper.toDetailResponse(project, member); - }; - - public findByTeam = async (slug: string, userId: string) => { - const { team, member } = await this.ensureTeamAccess(slug, userId, 'viewer'); - const projects = await this.projectsRepo.findByTeam(team.id); - - return { - team: { - id: team.id, - name: team.name, - slug: team.slug, - role: member.role, - }, - items: projects.map((p) => ProjectsMapper.toListResponse(p, member)), - meta: { - total: projects.length, - }, - }; - }; - - public setStatus = async (id: string, slug: string, userId: string, status: ProjectStatus) => { - const project = await this.validateAccess(id, slug, 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}» изменен`, - }; - }; - - private async ensureTeamAccess( - slug: string, - userId: string, - minRole: keyof typeof ROLE_PRIORITY = 'viewer', - ) { - const team = await this.findTeamQ.execute(slug); - if (!team) { - throw new BaseException( - { - code: 'TEAM_NOT_FOUND', - message: 'Команда не найдена', - }, - HttpStatus.NOT_FOUND, - ); - } - - const member = await this.findTeamMemberQ.execute(team.id, userId); - if (!member) { - throw new BaseException( - { - code: 'NOT_TEAM_MEMBER', - message: 'Вы не являетесь участником этой команды', - }, - HttpStatus.FORBIDDEN, - ); - } - - if (ROLE_PRIORITY[member.role] < ROLE_PRIORITY[minRole]) { - throw new BaseException( - { - code: 'INSUFFICIENT_PERMISSIONS', - message: `Только ${minRole} и выше могут выполнять это действие`, - details: [ - { - target: 'role', - message: `Current role: ${member.role}, Required: ${minRole}`, - }, - ], - }, - HttpStatus.FORBIDDEN, - ); - } - - return { team, member }; - } - - private async validateAccess( - id: string, - slug: string, - userId: string, - minRole: keyof typeof ROLE_PRIORITY = 'admin', - ) { - const { team } = await this.ensureTeamAccess(slug, userId, minRole); - - const project = await this.projectsRepo.findOne(id); - if (!project || project.teamId !== team.id) { - throw new BaseException( - { - code: 'PROJECT_NOT_FOUND', - message: 'Проект не найден в этой команде', - }, - HttpStatus.NOT_FOUND, - ); - } - - return project; - } - - 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/controller/index.ts b/src/projects/application/controller/index.ts new file mode 100644 index 0000000..4c96d63 --- /dev/null +++ b/src/projects/application/controller/index.ts @@ -0,0 +1 @@ +export { ProjectsController } from './projects/controller'; diff --git a/src/modules/projects/controller/projects.controller.ts b/src/projects/application/controller/projects/controller.ts similarity index 84% rename from src/modules/projects/controller/projects.controller.ts rename to src/projects/application/controller/projects/controller.ts index c3e41ba..390a31f 100644 --- a/src/modules/projects/controller/projects.controller.ts +++ b/src/projects/application/controller/projects/controller.ts @@ -1,5 +1,4 @@ import { ApiBaseController, GetUserId, Public } from '@shared/decorators'; -import { ProjectsService } from '../services'; import { Body, Delete, Get, Param, Patch, Post, Query } from '@nestjs/common'; import { ArchiveProjectSwagger, @@ -9,18 +8,19 @@ import { FindOneProjectSwagger, RemoveProjectSwagger, UpdateProjectSwagger, -} from './projects.swagger'; -import { CreateProjectDto, CreateShareTokenDto, UpdateProjectDto } from '../dtos'; -import { ProjectStatus } from '../entities'; +} from './swagger'; +import { CreateProjectDto, CreateShareTokenDto, UpdateProjectDto } from '../../dtos'; +import { ProjectStatus } from '@core/projects/domain/entities'; +import { ProjectsFacade } from '../../projects.facade'; @ApiBaseController('teams/:slug/projects', 'Team Projects', true) export class ProjectsController { - constructor(private readonly facade: ProjectsService) {} + constructor(private readonly facade: ProjectsFacade) {} @Get() @FindAllProjectsSwagger() async findAll(@Param('slug') slug: string, @GetUserId() userId: string) { - return this.facade.findByTeam(slug, userId); + return this.facade.getTeamProjects(slug, userId); } @Get(':id') @@ -32,7 +32,7 @@ export class ProjectsController { @GetUserId() userId?: string, @Query('token') token?: string, ) { - return this.facade.findOne(id, slug, userId, token); + return this.facade.getDetail(id, slug, userId, token); } @Post(':id/share') @@ -43,7 +43,7 @@ export class ProjectsController { @GetUserId() userId: string, @Body() dto: CreateShareTokenDto, ) { - return this.facade.generateToken(id, slug, userId, dto); + return this.facade.generateShareToken(id, slug, userId, dto); } @Post(':id/archive') diff --git a/src/modules/projects/controller/projects.swagger.ts b/src/projects/application/controller/projects/swagger.ts similarity index 99% rename from src/modules/projects/controller/projects.swagger.ts rename to src/projects/application/controller/projects/swagger.ts index 09f184c..55e1e1a 100644 --- a/src/modules/projects/controller/projects.swagger.ts +++ b/src/projects/application/controller/projects/swagger.ts @@ -7,7 +7,7 @@ import { CreateProjectResponse, CreateShareTokenDto, UpdateProjectDto, -} from '../dtos'; +} from '../../dtos'; export const CreateProjectSwagger = () => applyDecorators( diff --git a/src/modules/projects/dtos/index.ts b/src/projects/application/dtos/index.ts similarity index 100% rename from src/modules/projects/dtos/index.ts rename to src/projects/application/dtos/index.ts diff --git a/src/modules/projects/dtos/projects.dto.ts b/src/projects/application/dtos/projects.dto.ts similarity index 97% rename from src/modules/projects/dtos/projects.dto.ts rename to src/projects/application/dtos/projects.dto.ts index 042444f..82375e8 100644 --- a/src/modules/projects/dtos/projects.dto.ts +++ b/src/projects/application/dtos/projects.dto.ts @@ -1,7 +1,7 @@ import { z } from 'zod/v4'; import { createZodDto } from 'nestjs-zod'; -import { ProjectStatus } from '../entities'; import { ActionResponseSchema } from '@shared/dtos'; +import { ProjectStatus } from '@core/projects/domain/entities'; export const CreateProjectSchema = z.object({ name: z diff --git a/src/modules/projects/mappers/index.ts b/src/projects/application/mappers/index.ts similarity index 100% rename from src/modules/projects/mappers/index.ts rename to src/projects/application/mappers/index.ts diff --git a/src/modules/projects/mappers/projects.mapper.ts b/src/projects/application/mappers/projects.mapper.ts similarity index 96% rename from src/modules/projects/mappers/projects.mapper.ts rename to src/projects/application/mappers/projects.mapper.ts index 708c1a2..4aa1a12 100644 --- a/src/modules/projects/mappers/projects.mapper.ts +++ b/src/projects/application/mappers/projects.mapper.ts @@ -1,6 +1,6 @@ -import type { Project } from '@shared/entities'; import { ROLE_PRIORITY } from '@shared/constants'; import { RawMemberRow } from '@core/teams/domain/repository'; +import { Project } from '@core/projects/domain/entities'; export class ProjectsMapper { public static toDetailResponse(project: Project, member?: RawMemberRow, token?: string) { diff --git a/src/projects/application/projects.facade.ts b/src/projects/application/projects.facade.ts new file mode 100644 index 0000000..3fb6dc9 --- /dev/null +++ b/src/projects/application/projects.facade.ts @@ -0,0 +1,58 @@ +import { Injectable } from '@nestjs/common'; +import { ProjectStatus } from '../domain/entities'; +import type { CreateProjectDto, CreateShareTokenDto, UpdateProjectDto } from './dtos'; +import { + CreateProjectUseCase, + DeleteProjectUseCase, + GenerateShareTokenUseCase, + SetProjectStatusUseCase, + UpdateProjectUseCase, + FindProjectsByTeamQuery, + GetProjectDetailQuery, +} from './use-cases'; + +@Injectable() +export class ProjectsFacade { + constructor( + 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, + ) {} + + public async create(userId: string, slug: string, dto: CreateProjectDto) { + return this.createProjectUC.execute(userId, slug, dto); + } + + public async update(id: string, slug: string, userId: string, dto: UpdateProjectDto) { + return this.updateProjectUC.execute(id, slug, userId, dto); + } + + public async delete(id: string, slug: string, userId: string) { + return this.deleteProjectUC.execute(id, slug, userId); + } + + public async setStatus(id: string, slug: string, userId: string, status: ProjectStatus) { + return this.setStatusUC.execute(id, slug, userId, status); + } + + public async generateShareToken( + id: string, + slug: string, + userId: string, + dto: CreateShareTokenDto, + ) { + return this.generateTokenUC.execute(id, slug, userId, dto); + } + + public async getDetail(id: string, slug: string, userId?: string, token?: string) { + return this.getDetailQ.execute(id, slug, userId, token); + } + + public async getTeamProjects(slug: string, userId: string) { + return this.findByTeamQ.execute(slug, 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 new file mode 100644 index 0000000..a2cc6de --- /dev/null +++ b/src/projects/application/use-cases/create-project.use-case.ts @@ -0,0 +1,34 @@ +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, slug: string, dto: CreateProjectDto) { + const { team } = await this.policy.ensureTeamAccess(slug, userId, 'admin'); + + const data = { + ...dto, + teamId: team.id, + ownerId: userId, + key: dto.key.toUpperCase(), + status: ProjectStatus.Active, + }; + + const { result, id } = await this.projectsRepo.create(data); + + return { + success: result, + message: `Проект ${dto.name} успешно создан`, + projectId: id, + }; + } +} diff --git a/src/projects/application/use-cases/delete-project.use-case.ts b/src/projects/application/use-cases/delete-project.use-case.ts new file mode 100644 index 0000000..b5d3e71 --- /dev/null +++ b/src/projects/application/use-cases/delete-project.use-case.ts @@ -0,0 +1,35 @@ +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(id: string, slug: string, userId: string) { + const { project } = await this.policy.validateProjectAccess(id, slug, 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/find-project.query.ts b/src/projects/application/use-cases/find-project.query.ts new file mode 100644 index 0000000..c5b41d9 --- /dev/null +++ b/src/projects/application/use-cases/find-project.query.ts @@ -0,0 +1,116 @@ +import { HttpStatus, Inject, Injectable } from '@nestjs/common'; +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 type { Project } from '@core/projects/domain/entities'; + +@Injectable() +export class FindProjectQuery { + constructor( + @Inject('IProjectsRepository') + private readonly projectsRepo: IProjectsRepository, + private readonly findTeamQ: FindTeamQuery, + private readonly findTeamMemberQ: FindTeamMemberQuery, + ) {} + + /** + * Точка входа для получения проекта с проверкой прав. + */ + public async execute( + projectId: string, + slug: string, + userId?: string, + shareToken?: string, + minRole: keyof typeof ROLE_PRIORITY = 'viewer', + ) { + const project = await this.projectsRepo.findOne(projectId); + + if (!project) { + throw new BaseException( + { + code: 'PROJECT_NOT_FOUND', + message: 'Проект не найден', + details: [{ target: 'projectId', value: projectId }], + }, + HttpStatus.NOT_FOUND, + ); + } + + if (shareToken) { + return this.findPublic(project, shareToken); + } + + return this.findPrivate(project, slug, userId, minRole); + } + + private findPrivate = async ( + project: Project, + slug: string, + userId?: string, + minRole: keyof typeof ROLE_PRIORITY = 'viewer', + ) => { + if (!userId) { + throw new BaseException( + { + code: 'AUTH_REQUIRED', + message: 'Требуется авторизация для доступа к приватному проекту', + }, + HttpStatus.UNAUTHORIZED, + ); + } + + const team = await this.findTeamQ.execute(slug); + if (!team || team.id !== project.teamId) { + throw new BaseException( + { + code: 'PROJECT_TEAM_MISMATCH', + message: 'Проект не принадлежит указанной команде', + }, + HttpStatus.BAD_REQUEST, + ); + } + + const member = await this.findTeamMemberQ.execute(team.id, userId); + if (!member) { + throw new BaseException( + { code: 'ACCESS_DENIED', message: 'Вы не являетесь участником этой команды' }, + HttpStatus.FORBIDDEN, + ); + } + + if (ROLE_PRIORITY[member.role] < ROLE_PRIORITY[minRole]) { + throw new BaseException( + { + code: 'INSUFFICIENT_PERMISSIONS', + message: `Для этого действия необходимы права: ${minRole}`, + }, + HttpStatus.FORBIDDEN, + ); + } + + return { project, member, team }; + }; + + private findPublic = async (project: Project, token: string) => { + if (project.visibility !== 'public') { + throw new BaseException( + { code: 'PROJECT_NOT_PUBLIC', message: 'Публичный доступ к проекту ограничен' }, + HttpStatus.FORBIDDEN, + ); + } + + const hashedToken = createHash('sha256').update(token).digest('hex'); + const isValidToken = await this.projectsRepo.hasValidShareToken(project.id, hashedToken); + + if (!isValidToken) { + throw new BaseException( + { code: 'SHARE_LINK_INVALID', message: 'Ссылка недействительна или истекла' }, + HttpStatus.GONE, + ); + } + + return { project, member: null, team: null }; + }; +} 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 new file mode 100644 index 0000000..7229508 --- /dev/null +++ b/src/projects/application/use-cases/find-projects-by-team.query.ts @@ -0,0 +1,31 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { ProjectsMapper } from '../mappers'; +import { IProjectsRepository } from '@core/projects/domain/repository'; +import { ProjectAccessPolicy } from '@core/projects/domain/policy'; + +@Injectable() +export class FindProjectsByTeamQuery { + constructor( + @Inject('IProjectsRepository') + private readonly projectsRepo: IProjectsRepository, + private readonly policy: ProjectAccessPolicy, + ) {} + + public async execute(slug: string, userId: string) { + const { team, member } = await this.policy.ensureTeamAccess(slug, userId, 'viewer'); + const projects = await this.projectsRepo.findByTeam(team.id); + + return { + team: { + id: team.id, + name: team.name, + slug: team.slug, + role: member.role, + }, + items: projects.map((p) => ProjectsMapper.toListResponse(p, member)), + meta: { + total: projects.length, + }, + }; + } +} 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 new file mode 100644 index 0000000..deb68da --- /dev/null +++ b/src/projects/application/use-cases/generate-share-token.use-case.ts @@ -0,0 +1,82 @@ +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(id: string, slug: string, userId: string, dto: CreateShareTokenDto) { + const { project } = await this.policy.validateProjectAccess(id, slug, 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, + 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/get-project-detail.query.ts b/src/projects/application/use-cases/get-project-detail.query.ts new file mode 100644 index 0000000..d69ec4e --- /dev/null +++ b/src/projects/application/use-cases/get-project-detail.query.ts @@ -0,0 +1,20 @@ +import { Injectable } from '@nestjs/common'; +import { ProjectsMapper } from '../mappers'; +import { FindProjectQuery } from './find-project.query'; + +@Injectable() +export class GetProjectDetailQuery { + constructor(private readonly findProjectQuery: FindProjectQuery) {} + + public async execute(id: string, slug: string, userId?: string, token?: string) { + const { project, member } = await this.findProjectQuery.execute( + id, + slug, + userId, + token, + 'viewer', + ); + + return ProjectsMapper.toDetailResponse(project, member); + } +} diff --git a/src/projects/application/use-cases/index.ts b/src/projects/application/use-cases/index.ts new file mode 100644 index 0000000..e7fb41c --- /dev/null +++ b/src/projects/application/use-cases/index.ts @@ -0,0 +1,27 @@ +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'; + +export * from './create-project.use-case'; +export * from './delete-project.use-case'; +export * from './generate-share-token.use-case'; +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 const ProjectUseCases = [ + CreateProjectUseCase, + DeleteProjectUseCase, + GenerateShareTokenUseCase, + SetProjectStatusUseCase, + UpdateProjectUseCase, +]; + +export const ProjectQueries = [FindProjectsByTeamQuery, GetProjectDetailQuery, FindProjectQuery]; 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 new file mode 100644 index 0000000..9e5240e --- /dev/null +++ b/src/projects/application/use-cases/set-project-status.use-case.ts @@ -0,0 +1,41 @@ +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(id: string, slug: string, userId: string, status: ProjectStatus) { + const { project } = await this.policy.validateProjectAccess(id, slug, 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/update-project.use-case.ts b/src/projects/application/use-cases/update-project.use-case.ts new file mode 100644 index 0000000..eaf8a7b --- /dev/null +++ b/src/projects/application/use-cases/update-project.use-case.ts @@ -0,0 +1,43 @@ +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(id: string, slug: string, userId: string, dto: UpdateProjectDto) { + const { project } = await this.policy.validateProjectAccess(id, slug, userId); + const { isPublic, key, ...data } = dto; + + const result = await this.projectsRepo.update(project.id, { + ...data, + ...(key && { key: key.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/modules/projects/entities/entities.domain.ts b/src/projects/domain/entities/entities.domain.ts similarity index 88% rename from src/modules/projects/entities/entities.domain.ts rename to src/projects/domain/entities/entities.domain.ts index 6170b73..4df40c2 100644 --- a/src/modules/projects/entities/entities.domain.ts +++ b/src/projects/domain/entities/entities.domain.ts @@ -1,5 +1,5 @@ import type { InferInsertModel, InferSelectModel } from 'drizzle-orm'; -import { projects, projectShares } from './projects.entity'; +import { projects, projectShares } from '../../infrastructure/persistence/models/projects.model'; export enum ProjectStatus { Active = 'active', diff --git a/src/projects/domain/entities/index.ts b/src/projects/domain/entities/index.ts new file mode 100644 index 0000000..1481834 --- /dev/null +++ b/src/projects/domain/entities/index.ts @@ -0,0 +1 @@ +export * from './entities.domain'; diff --git a/src/projects/domain/policy/index.ts b/src/projects/domain/policy/index.ts new file mode 100644 index 0000000..cc90b6c --- /dev/null +++ b/src/projects/domain/policy/index.ts @@ -0,0 +1,5 @@ +import { ProjectAccessPolicy } from './project-access.policy'; + +export * from './project-access.policy'; + +export const POLICIES = [ProjectAccessPolicy]; diff --git a/src/projects/domain/policy/project-access.policy.ts b/src/projects/domain/policy/project-access.policy.ts new file mode 100644 index 0000000..7001ebf --- /dev/null +++ b/src/projects/domain/policy/project-access.policy.ts @@ -0,0 +1,78 @@ +import { HttpStatus, Inject, Injectable } from '@nestjs/common'; +import { IProjectsRepository } from '../repository'; +import { BaseException } from '@shared/error'; +import { FindTeamMemberQuery, FindTeamQuery } from '@core/teams'; +import { ROLE_PRIORITY } from '@shared/constants'; + +@Injectable() +export class ProjectAccessPolicy { + constructor( + @Inject('IProjectsRepository') + private readonly projectsRepo: IProjectsRepository, + private readonly findTeamQ: FindTeamQuery, + private readonly findTeamMemberQ: FindTeamMemberQuery, + ) {} + + /** + * Проверка доступа к команде (используется, например, при создании проекта) + */ + public async ensureTeamAccess( + slug: string, + userId: string, + minRole: keyof typeof ROLE_PRIORITY = 'viewer', + ) { + const team = await this.findTeamQ.execute(slug); + if (!team) { + throw new BaseException( + { code: 'TEAM_NOT_FOUND', message: 'Команда не найдена' }, + HttpStatus.NOT_FOUND, + ); + } + + const member = await this.findTeamMemberQ.execute(team.id, userId); + if (!member) { + throw new BaseException( + { code: 'NOT_TEAM_MEMBER', message: 'Вы не участник команды' }, + HttpStatus.FORBIDDEN, + ); + } + + if (ROLE_PRIORITY[member.role] < ROLE_PRIORITY[minRole]) { + throw new BaseException( + { + code: 'INSUFFICIENT_PERMISSIONS', + message: `Требуется роль ${minRole} или выше`, + details: [{ target: 'role', current: member.role, required: minRole }], + }, + HttpStatus.FORBIDDEN, + ); + } + + return { team, member }; + } + + /** + * Полная проверка доступа к конкретному проекту внутри команды + */ + public async validateProjectAccess( + projectId: string, + slug: string, + userId: string, + minRole: keyof typeof ROLE_PRIORITY = 'admin', + ) { + const { team, member } = await this.ensureTeamAccess(slug, userId, minRole); + + const project = await this.projectsRepo.findOne(projectId); + if (!project || project.teamId !== team.id) { + throw new BaseException( + { + code: 'PROJECT_NOT_FOUND', + message: 'Проект не найден в этой команде', + }, + HttpStatus.NOT_FOUND, + ); + } + + return { project, member, team }; + } +} diff --git a/src/modules/projects/repository/index.ts b/src/projects/domain/repository/index.ts similarity index 54% rename from src/modules/projects/repository/index.ts rename to src/projects/domain/repository/index.ts index 8aec19a..aea7492 100644 --- a/src/modules/projects/repository/index.ts +++ b/src/projects/domain/repository/index.ts @@ -1,2 +1 @@ -export { ProjectsRepository } from './projects.repository'; export { IProjectsRepository } from './projects.repository.interface'; diff --git a/src/modules/projects/repository/projects.repository.interface.ts b/src/projects/domain/repository/projects.repository.interface.ts similarity index 100% rename from src/modules/projects/repository/projects.repository.interface.ts rename to src/projects/domain/repository/projects.repository.interface.ts diff --git a/src/modules/projects/index.ts b/src/projects/index.ts similarity index 100% rename from src/modules/projects/index.ts rename to src/projects/index.ts diff --git a/src/modules/projects/entities/enums.ts b/src/projects/infrastructure/persistence/models/enums.ts similarity index 100% rename from src/modules/projects/entities/enums.ts rename to src/projects/infrastructure/persistence/models/enums.ts diff --git a/src/projects/infrastructure/persistence/models/index.ts b/src/projects/infrastructure/persistence/models/index.ts new file mode 100644 index 0000000..ed46b14 --- /dev/null +++ b/src/projects/infrastructure/persistence/models/index.ts @@ -0,0 +1,2 @@ +export { projectStatusEnum, projectVisibilityEnum } from './enums'; +export { projectShares, projects } from './projects.model'; diff --git a/src/modules/projects/entities/projects.entity.ts b/src/projects/infrastructure/persistence/models/projects.model.ts similarity index 100% rename from src/modules/projects/entities/projects.entity.ts rename to src/projects/infrastructure/persistence/models/projects.model.ts diff --git a/src/projects/infrastructure/persistence/repositories/index.ts b/src/projects/infrastructure/persistence/repositories/index.ts new file mode 100644 index 0000000..5c64093 --- /dev/null +++ b/src/projects/infrastructure/persistence/repositories/index.ts @@ -0,0 +1,2 @@ +export { ProjectsRepository } from './projects.repository'; +export { IProjectsRepository } from '../../../domain/repository/projects.repository.interface'; diff --git a/src/modules/projects/repository/projects.repository.ts b/src/projects/infrastructure/persistence/repositories/projects.repository.ts similarity index 89% rename from src/modules/projects/repository/projects.repository.ts rename to src/projects/infrastructure/persistence/repositories/projects.repository.ts index a4f6750..69d8c33 100644 --- a/src/modules/projects/repository/projects.repository.ts +++ b/src/projects/infrastructure/persistence/repositories/projects.repository.ts @@ -1,8 +1,9 @@ import { DATABASE_SERVICE, DatabaseService } from '@libs/database'; import { Injectable, Inject } from '@nestjs/common'; -import * as schema from '../entities'; -import { IProjectsRepository } from './projects.repository.interface'; +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 { @@ -11,7 +12,7 @@ export class ProjectsRepository implements IProjectsRepository { private readonly db: DatabaseService, ) {} - public create = async (data: schema.NewProject) => { + public create = async (data: NewProject) => { const result = await this.db .insert(schema.projects) .values(data) @@ -20,7 +21,7 @@ export class ProjectsRepository implements IProjectsRepository { return { result: result.length > 0, id: result[0].id }; }; - public update = async (id: string, data: Partial) => { + public update = async (id: string, data: Partial) => { const result = await this.db .update(schema.projects) .set({ ...data, updatedAt: new Date() }) @@ -58,7 +59,7 @@ export class ProjectsRepository implements IProjectsRepository { .where(and(eq(schema.projects.teamId, teamId), isNull(schema.projects.deletedAt))); }; - public createShare = async (data: schema.NewProjectShare) => { + public createShare = async (data: NewProjectShare) => { const [result] = await this.db .insert(schema.projectShares) .values(data) diff --git a/src/projects/projects.module.ts b/src/projects/projects.module.ts new file mode 100644 index 0000000..4a74316 --- /dev/null +++ b/src/projects/projects.module.ts @@ -0,0 +1,20 @@ +import { forwardRef, Module } from '@nestjs/common'; +import { ProjectsRepository } from './infrastructure/persistence/repositories'; +import { TeamsModule } from '@core/teams'; +import { ProjectsController } from './application/controller'; +import { FindProjectQuery, ProjectQueries, ProjectUseCases } from './application/use-cases'; +import { POLICIES } from './domain/policy'; +import { ProjectsFacade } from './application/projects.facade'; + +const REPOSITORY = { + provide: 'IProjectsRepository', + useClass: ProjectsRepository, +}; + +@Module({ + imports: [forwardRef(() => TeamsModule)], + controllers: [ProjectsController], + providers: [REPOSITORY, ...POLICIES, ...ProjectUseCases, ...ProjectQueries, ProjectsFacade], + exports: [FindProjectQuery], +}) +export class ProjectsModule {} diff --git a/src/shared/entities/index.ts b/src/shared/entities/index.ts index 119ec4c..b50a6a2 100644 --- a/src/shared/entities/index.ts +++ b/src/shared/entities/index.ts @@ -2,4 +2,4 @@ export { baseSchema } from './schema'; export * from '../../user/infrastructure/persistence/models'; export * from '../../auth/infrastructure/persistence/models'; export * from '../../teams/infrastructure/persistence/models'; -export * from '../../modules/projects/entities'; +export * from '../../projects/infrastructure/persistence/models';