From be256fdc045c6ebec3b90fd962a0887419c17cfc Mon Sep 17 00:00:00 2001 From: Danil <123034340+soorq@users.noreply.github.com> Date: Tue, 16 Jun 2026 21:18:30 +0300 Subject: [PATCH 1/5] chore: update dto, resolve error message at exchange and add condition (#99) --- libs/bootstrap/src/bootstrap.ts | 27 +++- .../controllers/auth/controller.ts | 7 - .../controllers/oauth/controller.ts | 15 +- .../application/controllers/oauth/swagger.ts | 43 ++++-- src/auth/application/dtos/oauth.dto.ts | 18 +-- .../use-cases/auth/sign-up.use-case.ts | 5 +- src/auth/application/use-cases/index.ts | 12 +- .../oauth/authenticate-oauth.use-case.ts | 61 ++------ .../oauth/connect-oauth-provider.use-case.ts | 8 +- .../oauth/connect-provider.use-case.ts | 2 +- .../use-cases/oauth/exchange.use-case.ts | 145 ++++++++++++------ .../oauth/oauth-orchestrator.use-case.ts | 43 ------ .../oauth/process-oauth-login.use-case.ts | 38 ----- .../process-oauth-registration.use-case.ts | 65 -------- .../oauth/process-oauth-sign.use-case.ts | 48 ++++++ src/auth/domain/errors/oauth.error.ts | 12 -- .../strategies/github.strategy.ts | 2 +- 17 files changed, 242 insertions(+), 309 deletions(-) delete mode 100644 src/auth/application/use-cases/oauth/oauth-orchestrator.use-case.ts delete mode 100644 src/auth/application/use-cases/oauth/process-oauth-login.use-case.ts delete mode 100644 src/auth/application/use-cases/oauth/process-oauth-registration.use-case.ts create mode 100644 src/auth/application/use-cases/oauth/process-oauth-sign.use-case.ts diff --git a/libs/bootstrap/src/bootstrap.ts b/libs/bootstrap/src/bootstrap.ts index 0ef9f3c..aae4886 100644 --- a/libs/bootstrap/src/bootstrap.ts +++ b/libs/bootstrap/src/bootstrap.ts @@ -100,6 +100,10 @@ export async function bootstrapApp(options: BootstrapOptions) { }, }); + const isProduction = configService.get('NODE_ENV') === 'production'; + const domain = configService.get('DOMAIN'); + const stage = configService.get('STAGE_DOMAIN'); + if (apiPrefix) { app.setGlobalPrefix(apiPrefix); } @@ -118,9 +122,6 @@ export async function bootstrapApp(options: BootstrapOptions) { if (swaggerOptions) { const { path = 'docs', ...metadata } = swaggerOptions; - const domain = configService.get('DOMAIN'); - const stage = configService.get('STAGE_DOMAIN'); - const fullOptions = { ...metadata, path, @@ -135,14 +136,26 @@ export async function bootstrapApp(options: BootstrapOptions) { } if (useCookieParser) { const secret = configService.getOrThrow('COOKIE_SECRET'); - await app.register(fastifyCookie, { secret }); + await app.register(fastifyCookie, { + secret, + parseOptions: { + httpOnly: true, + sameSite: 'lax', + signed: true, + secure: isProduction, + path: '/', + domain: domain ? `.${domain}` : undefined, + }, + }); await app.register(fastifyCsrf, { cookieOpts: { - signed: true, httpOnly: true, - sameSite: 'strict', - secure: configService.getOrThrow('NODE_ENV') === 'production', + sameSite: 'lax', + secure: isProduction, + path: '/', + domain: domain ? `.${domain}` : undefined, }, + sessionPlugin: '@fastify/cookie', }); } if (setupApp) { diff --git a/src/auth/application/controllers/auth/controller.ts b/src/auth/application/controllers/auth/controller.ts index 25d76a1..849b544 100644 --- a/src/auth/application/controllers/auth/controller.ts +++ b/src/auth/application/controllers/auth/controller.ts @@ -20,14 +20,12 @@ import type { FastifyReply, FastifyRequest } from 'fastify'; @ApiBaseController('auth', 'Auth') export class AuthController { - private readonly isProduction: boolean = false; private readonly domain?: string | null = null; constructor( private readonly facade: AuthFacade, private readonly cfg: ConfigService, ) { - this.isProduction = this.cfg.get('NODE_ENV') === 'production'; this.domain = this.cfg.get('DOMAIN'); } @@ -109,12 +107,7 @@ export class AuthController { private setRefreshCookie(res: FastifyReply, refreshToken: string, expires: Date) { res.setCookie('refresh', refreshToken, { - httpOnly: true, - secure: this.isProduction, - path: '/', expires, - sameSite: 'lax', - domain: this.domain ? `.${this.domain}` : undefined, }); } } diff --git a/src/auth/application/controllers/oauth/controller.ts b/src/auth/application/controllers/oauth/controller.ts index 0870645..0bce2f1 100644 --- a/src/auth/application/controllers/oauth/controller.ts +++ b/src/auth/application/controllers/oauth/controller.ts @@ -33,14 +33,12 @@ import type { FastifyReply, FastifyRequest } from 'fastify'; @ApiBaseController('oauth', 'OAuth') export class OAuthController { - private readonly isProduction: boolean = false; private readonly domain?: string | null = null; constructor( private readonly facade: AuthFacade, private readonly cfg: ConfigService, ) { - this.isProduction = this.cfg.get('NODE_ENV') === 'production'; this.domain = this.cfg.get('DOMAIN'); } @@ -56,7 +54,7 @@ export class OAuthController { @SkipContract() async oauthCallback( @Query() query: { code?: string; state?: string }, - @Param('provider') provider: 'google' | 'yandex' | 'github' | 'vkontakte', + @Param('provider') provider: 'google' | 'yandex' | 'github', @Res({ passthrough: true }) res: FastifyReply, @Req() req: FastifyRequest, ) { @@ -79,10 +77,10 @@ export class OAuthController { const result = await this.facade.authenticateOAuth(dto, meta, state); - if (result.isSign) { - res.redirect(`${baseUrl}/oauth?${result.query.toString()}`, 302); - } else { + if (result.isConnect) { res.redirect(`${baseUrl}/user/profile?${result.query.toString()}`, 302); + } else { + res.redirect(`${baseUrl}/oauth?${result.query.toString()}`, 302); } } catch (err) { let message = 'Произошла ошибка при авторизации'; @@ -154,12 +152,7 @@ export class OAuthController { private setRefreshCookie(res: FastifyReply, refreshToken: string, expires: Date) { res.setCookie('refresh', refreshToken, { - httpOnly: true, - secure: this.isProduction, - path: '/', expires, - sameSite: 'lax', - domain: this.domain ? `.${this.domain}` : undefined, }); } } diff --git a/src/auth/application/controllers/oauth/swagger.ts b/src/auth/application/controllers/oauth/swagger.ts index 5c04d53..a14e345 100644 --- a/src/auth/application/controllers/oauth/swagger.ts +++ b/src/auth/application/controllers/oauth/swagger.ts @@ -19,6 +19,15 @@ import { ProvidersResponse, } from '../../dtos'; +export const ApiProviderParam = () => + ApiParam({ + name: 'provider', + description: 'Название OAuth провайдера', + enum: OAuthProvider, + required: true, + example: OAuthProvider.GOOGLE, + }); + export const OAuthLoginSwagger = () => applyDecorators( ApiOperation({ @@ -26,11 +35,7 @@ export const OAuthLoginSwagger = () => description: 'Перенаправляет пользователя на страницу аутентификации выбранного провайдера (google, github и т.д.).', }), - ApiParam({ - name: 'provider', - description: 'Название OAuth провайдера', - enum: OAuthProvider, - }), + ApiProviderParam(), ApiResponse({ status: 302, description: 'Успешное перенаправление на сторону провайдера.', @@ -42,14 +47,24 @@ export const OAuthCallbackSwagger = () => applyDecorators( ApiOperation({ summary: 'Callback для завершения OAuth авторизации', - description: - 'Обрабатывает ответ от провайдера, аутентифицирует пользователя, устанавливает refresh-токен в httpOnly cookie и возвращает результат.', - }), - ApiParam({ - name: 'provider', - description: 'Название OAuth провайдера', - enum: OAuthProvider, - }), + description: [ + 'Обрабатывает ответ от провайдера. Поддерживает два сценария:', + '', + '**1. Вход/Регистрация **', + '- Проверяет существование пользователя по email', + '- Если существует → выполняет вход', + '- Если не существует → создает нового пользователя', + '- Генерирует **одноразовый exchange-токен** (не access!)', + '- Перенаправляет на фронтенд с exchange-токеном: `?exchange_token=xxx&provider=google`', + '- Фронтенд обменивает exchange-токен на access/refresh через `/oauth/exchange`', + '', + '**2. Привязка провайдера (state содержит `connect_`)**', + '- Привязывает OAuth провайдера к существующему аккаунту', + '- Не генерирует токены', + '- Перенаправляет на фронтенд: `same-url?success=true&provider=google&message=connected`', + ].join('\n'), + }), + ApiProviderParam(), ApiResponse({ status: 302, description: 'Успешный вход. Перенаправление на фронтенд с параметрами авторизации.', @@ -99,7 +114,7 @@ export const ConnectOAuthProviderSwagger = () => ApiResponse({ status: 200, description: 'Провайдер успешно привязан к аккаунту.', - type: [ConnectProviderResponse.Output], + type: ConnectProviderResponse.Output, }), ApiBadRequest( 'Провайдер уже привязан к этому аккаунту или указан неподдерживаемый провайдер', diff --git a/src/auth/application/dtos/oauth.dto.ts b/src/auth/application/dtos/oauth.dto.ts index 2a90476..2f0327c 100644 --- a/src/auth/application/dtos/oauth.dto.ts +++ b/src/auth/application/dtos/oauth.dto.ts @@ -8,8 +8,8 @@ const OAuthResponseSchema = z.object({ last_name: z.string().nullish(), avatar_url: z.string().nullish(), bio: z.string().nullish(), - sex: z.enum(['male', 'female']).or(z.string()), - provider: z.enum(['google', 'yandex', 'github', 'vkontakte']), + sex: z.enum(['male', 'female']), + provider: z.enum(['google', 'yandex', 'github']), }); export class OAuthResponse extends createZodDto(OAuthResponseSchema) {} @@ -64,17 +64,18 @@ export const ExchangeSchema = z.object({ .min(32, 'Token must be at least 32 characters') .max(128, 'Token must not exceed 128 characters') .regex(/^[a-f0-9]+$/, 'Token must be hexadecimal string'), + provider: z + .string() + .describe('Название OAuth-провайдера (например, "google", "github", "facebook")'), }); export class ExchangeDto extends createZodDto(ExchangeSchema) {} -export interface IOAuthExchangeData { - userId: string; +export type IOAuthExchangeData = z.infer & { + ip: string | null; isNewUser: boolean; - email: string; - provider: 'google' | 'yandex' | 'github' | 'vkontakte'; - ip: string; -} + userId: string | null; +}; export const ExchangeResponseSchema = z.object({ success: z.boolean().describe('Успешность операции'), @@ -88,7 +89,6 @@ export const ExchangeResponseSchema = z.object({ .min(10, 'access токен слишком короткий') .max(500, 'access токен слишком длинный') .describe('JWT access токен'), - isNewUser: z.boolean().describe('Новый пользователь?'), provider: z .enum(['google', 'yandex', 'github', 'vkontakte'], { message: 'provider должен быть: google, yandex, github или vkontakte', diff --git a/src/auth/application/use-cases/auth/sign-up.use-case.ts b/src/auth/application/use-cases/auth/sign-up.use-case.ts index 9abd000..1c6c62d 100644 --- a/src/auth/application/use-cases/auth/sign-up.use-case.ts +++ b/src/auth/application/use-cases/auth/sign-up.use-case.ts @@ -40,7 +40,10 @@ export class SignUpUseCase { } try { - await this.findUserQ.execute({ email: dto.email }, { throwIfExists: true }); + await this.findUserQ.execute( + { email: dto.email }, + { throwIfExists: true, throwIfNotFound: false }, + ); const hashPass = await argon.hash(dto.password); diff --git a/src/auth/application/use-cases/index.ts b/src/auth/application/use-cases/index.ts index f1fca2e..92c19e6 100644 --- a/src/auth/application/use-cases/index.ts +++ b/src/auth/application/use-cases/index.ts @@ -11,9 +11,7 @@ import { DisconnectProviderUseCase } from './oauth/disconnect-provider.use-case' import { ExchangeUseCase } from './oauth/exchange.use-case'; import { GetConnectedProvidersQuery } from './oauth/get-connected-providers.query'; import { GetEnabledProvidersQuery } from './oauth/get-enabled-providers.query'; -import { OAuthOrchestratorUseCase } from './oauth/oauth-orchestrator.use-case'; -import { ProcessOAuthLoginUseCase } from './oauth/process-oauth-login.use-case'; -import { ProcessOAuthRegistrationUseCase } from './oauth/process-oauth-registration.use-case'; +import { ProcessOAuthSignUseCase } from './oauth/process-oauth-sign.use-case'; import { ConfirmResetPasswordUseCase } from './password/confirm-reset-password.use-case'; import { ResetPasswordUseCase } from './password/reset-password.use-case'; import { VerifyResetPasswordUseCase } from './password/verify-reset-password.use-case'; @@ -24,9 +22,7 @@ export const AuthUseCases = [ GetConnectedProvidersQuery, DisconnectProviderUseCase, GetEnabledProvidersQuery, - OAuthOrchestratorUseCase, - ProcessOAuthLoginUseCase, - ProcessOAuthRegistrationUseCase, + ProcessOAuthSignUseCase, ConnectOAuthProviderUseCase, AuthenticateOAuthUseCase, ConnectProviderUseCase, @@ -53,9 +49,7 @@ export * from './auth/sign-up-verify.use-case'; export * from './oauth/get-enabled-providers.query'; export * from './oauth/exchange.use-case'; -export * from './oauth/oauth-orchestrator.use-case'; -export * from './oauth/process-oauth-login.use-case'; -export * from './oauth/process-oauth-registration.use-case'; +export * from './oauth/process-oauth-sign.use-case'; export * from './oauth/connect-oauth-provider.use-case'; export * from './auth/sign-in.use-case'; export * from './auth/sign-out.use-case'; diff --git a/src/auth/application/use-cases/oauth/authenticate-oauth.use-case.ts b/src/auth/application/use-cases/oauth/authenticate-oauth.use-case.ts index 1346ff1..aad2e42 100644 --- a/src/auth/application/use-cases/oauth/authenticate-oauth.use-case.ts +++ b/src/auth/application/use-cases/oauth/authenticate-oauth.use-case.ts @@ -1,12 +1,8 @@ -import crypto from 'node:crypto'; +import { Injectable } from '@nestjs/common'; +import { isBaseExceptionWithCode } from '@shared/error'; -import { Inject, Injectable } from '@nestjs/common'; -import { CACHE_SERVICE } from '@shared/adapters/cache/constants'; -import { ICacheService } from '@shared/adapters/cache/ports'; - -import { EXCHANGE_TOKEN_NAME, EXCHANGE_TOKEN_TTL } from '../../../infrastructure/constants'; - -import { OAuthOrchestratorUseCase } from './oauth-orchestrator.use-case'; +import { ConnectOAuthProviderUseCase } from './connect-oauth-provider.use-case'; +import { ProcessOAuthSignUseCase } from './process-oauth-sign.use-case'; import type { OAuthResponse } from '../../dtos'; import type { DeviceMetadata } from '@core/auth/infrastructure/utils'; @@ -14,48 +10,21 @@ import type { DeviceMetadata } from '@core/auth/infrastructure/utils'; @Injectable() export class AuthenticateOAuthUseCase { constructor( - @Inject(CACHE_SERVICE) - private readonly cacheService: ICacheService, - private readonly orchestrator: OAuthOrchestratorUseCase, + private readonly processSign: ProcessOAuthSignUseCase, + private readonly connectProvider: ConnectOAuthProviderUseCase, ) {} async execute(dto: OAuthResponse, meta: DeviceMetadata, state?: string) { - const { user, isNewUser, isConnect } = await this.orchestrator.execute(dto, state); - - if (isConnect) { - const query = new URLSearchParams({ - success: 'true', - message: `Провайдер ${dto.provider} успешно привязан`, - }); - - return { - query, - isSign: false, - refresh: null, - expiresAt: null, - }; + if (state) { + try { + return this.connectProvider.execute(dto, state); + } catch (error) { + if (!isBaseExceptionWithCode(error, 'INVALID_ACTION')) { + throw error; + } + } } - const token = crypto.randomBytes(32).toString('hex'); - - const data = { - userId: user.id, - isNewUser, - email: user.email, - provider: dto.provider, - ip: meta.ip, - }; - - await this.cacheService.setOne( - EXCHANGE_TOKEN_NAME(token), - JSON.stringify(data), - EXCHANGE_TOKEN_TTL, - ); - - const query = new URLSearchParams({ - token, - success: 'true', - }); - return { query, isSign: true }; + return this.processSign.execute(dto, meta); } } diff --git a/src/auth/application/use-cases/oauth/connect-oauth-provider.use-case.ts b/src/auth/application/use-cases/oauth/connect-oauth-provider.use-case.ts index 20c6245..f904f7c 100644 --- a/src/auth/application/use-cases/oauth/connect-oauth-provider.use-case.ts +++ b/src/auth/application/use-cases/oauth/connect-oauth-provider.use-case.ts @@ -30,7 +30,7 @@ export class ConnectOAuthProviderUseCase { await this.identityRepo.create({ userId: user.id, avatarUrl: dto.avatar_url, - provider: dto.provider as any, + provider: dto.provider, providerUserId: dto.id, email: dto.email, }); @@ -40,7 +40,11 @@ export class ConnectOAuthProviderUseCase { `oauth:state:${state}`, ]); - return { user, isConnect: true, isNewUser: false }; + const query = new URLSearchParams({ + success: 'true', + }); + + return { isConnect: true, query }; } private async getStateData(state: string) { diff --git a/src/auth/application/use-cases/oauth/connect-provider.use-case.ts b/src/auth/application/use-cases/oauth/connect-provider.use-case.ts index c60d3b8..324cf9d 100644 --- a/src/auth/application/use-cases/oauth/connect-provider.use-case.ts +++ b/src/auth/application/use-cases/oauth/connect-provider.use-case.ts @@ -23,7 +23,7 @@ export class ConnectProviderUseCase { private readonly STATE_KEY = (state: string) => `oauth:state:${state}`; async execute(provider: string, userId: string) { - await this.findUserQ.execute({ id: userId }); + await this.findUserQ.execute({ id: userId }, { throwIfNotFound: true }); await this.validateProviderNotConnected(userId, provider); await this.validateNoActiveSession(userId, provider); diff --git a/src/auth/application/use-cases/oauth/exchange.use-case.ts b/src/auth/application/use-cases/oauth/exchange.use-case.ts index ab762bd..06f7633 100644 --- a/src/auth/application/use-cases/oauth/exchange.use-case.ts +++ b/src/auth/application/use-cases/oauth/exchange.use-case.ts @@ -1,11 +1,16 @@ +import { FindUserQuery, RegisterUserUseCase } from '@core/user'; +import { InjectQueue } from '@nestjs/bullmq'; import { HttpStatus, Inject, Injectable } from '@nestjs/common'; import { createId } from '@paralleldrive/cuid2'; import { CACHE_SERVICE } from '@shared/adapters/cache/constants'; import { ICacheService } from '@shared/adapters/cache/ports'; import { BaseException } from '@shared/error'; +import { Queue } from 'bullmq'; +import { AuthQueues, AuthUserJobs } from '../../../domain/enums'; import { OAuthErrorCodes, OAuthErrorMessages } from '../../../domain/errors'; -import { ISessionRepository } from '../../../domain/repository'; +import { CreateUserWorkspaceEvent } from '../../../domain/events'; +import { IIdentityRepository, ISessionRepository } from '../../../domain/repository'; import { EXCHANGE_TOKEN_NAME } from '../../../infrastructure/constants'; import { TokenService } from '../../../infrastructure/security'; import { ExchangeDto, type IOAuthExchangeData } from '../../dtos'; @@ -19,10 +24,38 @@ export class ExchangeUseCase { private readonly sessionRepo: ISessionRepository, @Inject(CACHE_SERVICE) private readonly cacheService: ICacheService, + @InjectQueue(AuthQueues.AUTH_USER) + private readonly queue: Queue, + @Inject('IIdentityRepository') + private readonly identityRepo: IIdentityRepository, private readonly tokenService: TokenService, + private readonly registerUserUC: RegisterUserUseCase, + private readonly findUserQ: FindUserQuery, ) {} async execute(dto: ExchangeDto, meta: DeviceMetadata) { + const data = await this.validateAndGetData(dto); + + const { user, isNewUser } = await this.processUser(data); + const tokens = await this.createSession(user.id, user.email, meta); + + if (isNewUser) { + const event = new CreateUserWorkspaceEvent(user.id, user.firstName); + await this.queue.add(AuthUserJobs.CREATE_WORKSPACE, event); + } + + return { + success: true, + message: isNewUser ? 'Регистрация выполнена успешно' : 'Вход выполнен успешно', + access: tokens.access, + refresh: tokens.refresh, + expiresAt: tokens.expiresAt, + isNewUser, + provider: data.provider, + }; + } + + private async validateAndGetData(dto: ExchangeDto) { const key = EXCHANGE_TOKEN_NAME(dto.token); const rawData = await this.cacheService.getOne(key); @@ -36,65 +69,91 @@ export class ExchangeUseCase { ); } - const data = JSON.parse(rawData) as IOAuthExchangeData; + const data: IOAuthExchangeData = JSON.parse(rawData); await this.cacheService.removeOne(key); - if (!data.userId || !data.email) { - await this.cacheService.removeOne(key); + if (!data.email || !data.provider) { throw new BaseException( { - message: 'Неверный формат данных авторизации', - code: 'EXCHANGE_DATA_CORRUPTED', + code: OAuthErrorCodes.DATA_CORRUPTION, + message: OAuthErrorMessages[OAuthErrorCodes.DATA_CORRUPTION], }, HttpStatus.INTERNAL_SERVER_ERROR, ); } - try { - const sessionId = createId(); - const { access, expiresAt, refresh } = await this.tokenService.generateTokens( - { id: data.userId, email: data.email }, - sessionId, + if (data.provider !== dto.provider) { + throw new BaseException( + { + code: OAuthErrorCodes.EXCHANGE_TOKEN_INVALID, + message: OAuthErrorMessages[OAuthErrorCodes.EXCHANGE_TOKEN_INVALID], + }, + HttpStatus.BAD_REQUEST, ); + } + + return data; + } - const result = await this.sessionRepo.create({ - id: sessionId, - ...meta, - expiresAt: expiresAt.toISOString(), - userId: data.userId, - }); - - if (!result?.id) { - throw new BaseException( - { - message: 'Не удалось создать сессию', - code: 'SESSION_CREATION_FAILED', - }, - HttpStatus.INTERNAL_SERVER_ERROR, - ); - } - - return { - success: true, - message: 'Вход выполнен успешно', - access, - isNewUser: data.isNewUser, - provider: data.provider, - refresh, - expiresAt, - }; - } catch (error) { - if (error instanceof BaseException) { - throw error; - } + private async processUser(data: IOAuthExchangeData) { + const identity = await this.identityRepo.findByProvider(data.provider, data.id); + if (identity) { + const entity = await this.findUserQ.execute( + { email: data.email }, + { throwIfNotFound: true }, + ); + + return { user: entity.user, isNewUser: false }; + } + + return this.register(data); + } + + private async register(data: IOAuthExchangeData) { + const user = await this.registerUserUC.execute({ + email: data.email, + firstName: data.first_name || 'User', + lastName: data.last_name ?? '', + password: null, + bio: data.bio, + gender: data.sex || 'none', + avatarUrl: data.avatar_url, + }); + + await this.identityRepo.create({ + userId: user.id, + avatarUrl: data.avatar_url, + provider: data.provider, + providerUserId: data.id, + email: data.email, + }); + + return { user, isNewUser: true }; + } + + private async createSession(userId: string, email: string, meta: DeviceMetadata) { + const sessionId = createId(); + + const tokens = await this.tokenService.generateTokens({ id: userId, email }, sessionId); + + const result = await this.sessionRepo.create({ + id: sessionId, + ...meta, + expiresAt: tokens.expiresAt.toISOString(), + userId, + }); + + if (!result?.id) { throw new BaseException( { - message: 'Внутренняя ошибка сервера при создании сессии', - code: 'SESSION_CREATION_INTERNAL_ERROR', + code: OAuthErrorCodes.SESSION_CREATION_FAILED, + message: OAuthErrorMessages[OAuthErrorCodes.SESSION_CREATION_FAILED], }, HttpStatus.INTERNAL_SERVER_ERROR, ); } + + return tokens; } } diff --git a/src/auth/application/use-cases/oauth/oauth-orchestrator.use-case.ts b/src/auth/application/use-cases/oauth/oauth-orchestrator.use-case.ts deleted file mode 100644 index 9072616..0000000 --- a/src/auth/application/use-cases/oauth/oauth-orchestrator.use-case.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { Injectable } from '@nestjs/common'; -import { isBaseExceptionWithCode } from '@shared/error'; - -import { OAuthResponse } from '../../dtos'; - -import { ConnectOAuthProviderUseCase } from './connect-oauth-provider.use-case'; -import { ProcessOAuthLoginUseCase } from './process-oauth-login.use-case'; -import { ProcessOAuthRegistrationUseCase } from './process-oauth-registration.use-case'; - -@Injectable() -export class OAuthOrchestratorUseCase { - constructor( - private readonly processLogin: ProcessOAuthLoginUseCase, - private readonly connectProvider: ConnectOAuthProviderUseCase, - private readonly processRegistration: ProcessOAuthRegistrationUseCase, - ) {} - - async execute(dto: OAuthResponse, state?: string) { - if (state) { - try { - return this.connectProvider.execute(dto, state); - } catch (error) { - if (!isBaseExceptionWithCode(error, 'INVALID_ACTION')) { - throw error; - } - } - } - - const login = await this.processLogin.execute(dto).catch((err) => { - if (isBaseExceptionWithCode(err, 'OAUTH_LOGIN_NOT_FOUND')) { - return null; - } - - throw err; - }); - - if (login) { - return login; - } - - return this.processRegistration.execute(dto); - } -} diff --git a/src/auth/application/use-cases/oauth/process-oauth-login.use-case.ts b/src/auth/application/use-cases/oauth/process-oauth-login.use-case.ts deleted file mode 100644 index 5bd6a9e..0000000 --- a/src/auth/application/use-cases/oauth/process-oauth-login.use-case.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { IIdentityRepository } from '@core/auth/domain/repository'; -import { FindUserQuery } from '@core/user'; -import { HttpStatus, Inject, Injectable } from '@nestjs/common'; -import { BaseException } from '@shared/error'; - -import { OAuthErrorCodes, OAuthErrorMessages } from '../../../domain/errors'; -import { OAuthResponse } from '../../dtos'; - -@Injectable() -export class ProcessOAuthLoginUseCase { - constructor( - @Inject('IIdentityRepository') - private readonly identityRepo: IIdentityRepository, - private readonly findUserQ: FindUserQuery, - ) {} - - async execute(dto: OAuthResponse) { - const identity = await this.identityRepo.findByProvider(dto.provider as any, dto.id); - - if (!identity) { - throw new BaseException( - { - code: OAuthErrorCodes.OAUTH_LOGIN_NOT_FOUND, - message: OAuthErrorMessages[OAuthErrorCodes.OAUTH_LOGIN_NOT_FOUND], - }, - HttpStatus.NOT_FOUND, - ); - } - - const result = await this.findUserQ.execute({ id: identity.userId }); - - return { - user: result.user, - isNewUser: false, - isConnect: false, - }; - } -} diff --git a/src/auth/application/use-cases/oauth/process-oauth-registration.use-case.ts b/src/auth/application/use-cases/oauth/process-oauth-registration.use-case.ts deleted file mode 100644 index ead5ada..0000000 --- a/src/auth/application/use-cases/oauth/process-oauth-registration.use-case.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { AuthQueues, AuthUserJobs } from '@core/auth/domain/enums'; -import { CreateUserWorkspaceEvent } from '@core/auth/domain/events'; -import { IIdentityRepository } from '@core/auth/domain/repository'; -import { FindUserQuery, RegisterUserUseCase } from '@core/user'; -import { InjectQueue } from '@nestjs/bullmq'; -import { HttpStatus, Inject, Injectable } from '@nestjs/common'; -import { BaseException } from '@shared/error'; -import { Queue } from 'bullmq'; - -import { OAuthErrorCodes, OAuthErrorMessages } from '../../../domain/errors'; -import { OAuthResponse } from '../../dtos'; - -@Injectable() -export class ProcessOAuthRegistrationUseCase { - constructor( - @InjectQueue(AuthQueues.AUTH_USER) - private readonly queue: Queue, - @Inject('IIdentityRepository') - private readonly identityRepo: IIdentityRepository, - private readonly findUserQ: FindUserQuery, - private readonly registerUserUC: RegisterUserUseCase, - ) {} - - async execute(dto: OAuthResponse) { - const existingUser = await this.findUserByEmail(dto.email); - - if (existingUser) { - throw new BaseException( - { - code: OAuthErrorCodes.EMAIL_ALREADY_EXISTS, - message: OAuthErrorMessages[OAuthErrorCodes.EMAIL_ALREADY_EXISTS], - }, - HttpStatus.CONFLICT, - ); - } - - const user = await this.registerUserUC.execute({ - email: dto.email, - firstName: dto.first_name || 'User', - lastName: dto.last_name ?? '', - password: null, - bio: dto.bio, - gender: dto.sex === 'male' ? 'male' : dto.sex === 'female' ? 'female' : 'none', - avatarUrl: dto.avatar_url, - }); - - await this.identityRepo.create({ - userId: user.id, - avatarUrl: dto.avatar_url, - provider: dto.provider as any, - providerUserId: dto.id, - email: dto.email, - }); - - const event = new CreateUserWorkspaceEvent(user.id, user.firstName); - await this.queue.add(AuthUserJobs.CREATE_WORKSPACE, event); - - return { user, isNewUser: true, isConnect: false }; - } - - private async findUserByEmail(email: string) { - const result = await this.findUserQ.execute({ email }); - return result?.user; - } -} diff --git a/src/auth/application/use-cases/oauth/process-oauth-sign.use-case.ts b/src/auth/application/use-cases/oauth/process-oauth-sign.use-case.ts new file mode 100644 index 0000000..eccbd25 --- /dev/null +++ b/src/auth/application/use-cases/oauth/process-oauth-sign.use-case.ts @@ -0,0 +1,48 @@ +import crypto from 'node:crypto'; + +import { Inject, Injectable } from '@nestjs/common'; +import { CACHE_SERVICE } from '@shared/adapters/cache/constants'; +import { ICacheService } from '@shared/adapters/cache/ports'; + +import { IIdentityRepository } from '../../../domain/repository'; +import { EXCHANGE_TOKEN_NAME, EXCHANGE_TOKEN_TTL } from '../../../infrastructure/constants'; +import { IOAuthExchangeData, OAuthResponse } from '../../dtos'; + +import type { DeviceMetadata } from '../../../infrastructure/utils'; + +@Injectable() +export class ProcessOAuthSignUseCase { + constructor( + @Inject(CACHE_SERVICE) + private readonly cacheService: ICacheService, + @Inject('IIdentityRepository') + private readonly identityRepo: IIdentityRepository, + ) {} + + async execute(dto: OAuthResponse, meta: DeviceMetadata) { + const identity = await this.identityRepo.findByProvider(dto.provider, dto.id); + + const token = crypto.randomBytes(32).toString('hex'); + + const data: IOAuthExchangeData = { + isNewUser: identity ? true : false, + userId: identity?.userId ?? null, + ...dto, + ip: meta.ip, + provider: dto.provider, + }; + + await this.cacheService.setOne( + EXCHANGE_TOKEN_NAME(token), + JSON.stringify(data), + EXCHANGE_TOKEN_TTL, + ); + + const query = new URLSearchParams({ + token, + success: 'true', + }); + + return { isConnect: false, query }; + } +} diff --git a/src/auth/domain/errors/oauth.error.ts b/src/auth/domain/errors/oauth.error.ts index ad3c55f..cacdffe 100644 --- a/src/auth/domain/errors/oauth.error.ts +++ b/src/auth/domain/errors/oauth.error.ts @@ -6,17 +6,11 @@ export const OAuthErrorCodes = { INVALID_OR_EXPIRED_STATE: 'OAUTH.INVALID_OR_EXPIRED_STATE', LAST_AUTH_METHOD_CANNOT_BE_REMOVED: 'OAUTH.LAST_AUTH_METHOD_CANNOT_BE_REMOVED', EXCHANGE_TOKEN_INVALID: 'OAUTH.EXCHANGE_TOKEN_INVALID', - EXCHANGE_DATA_CORRUPTED: 'OAUTH.EXCHANGE_DATA_CORRUPTED', PROVIDER_ALREADY_USED: 'OAUTH.PROVIDER_ALREADY_USED', EMAIL_ALREADY_EXISTS: 'OAUTH.EMAIL_ALREADY_EXISTS', - OAUTH_LOGIN_NOT_FOUND: 'OAUTH.LOGIN_NOT_FOUND', ACTIVE_OAUTH_SESSION_EXISTS: 'OAUTH.ACTIVE_SESSION_EXISTS', - UNAUTHORIZED: 'OAUTH.UNAUTHORIZED', DATA_CORRUPTION: 'OAUTH.DATA_CORRUPTION', SESSION_CREATION_FAILED: 'OAUTH.SESSION_CREATION_FAILED', - SESSION_CREATION_INTERNAL_ERROR: 'OAUTH.SESSION_CREATION_INTERNAL_ERROR', - PROVIDER_CONNECT_FAILED: 'OAUTH.PROVIDER_CONNECT_FAILED', - PROVIDER_DISCONNECT_FAILED: 'OAUTH.PROVIDER_DISCONNECT_FAILED', } as const; export type OAuthErrorCode = (typeof OAuthErrorCodes)[keyof typeof OAuthErrorCodes]; @@ -30,16 +24,10 @@ export const OAuthErrorMessages: Record = { [OAuthErrorCodes.LAST_AUTH_METHOD_CANNOT_BE_REMOVED]: 'Нельзя удалить последний способ входа. Установите пароль или добавьте другой провайдер', [OAuthErrorCodes.EXCHANGE_TOKEN_INVALID]: 'Токен обмена недействителен или истёк', - [OAuthErrorCodes.EXCHANGE_DATA_CORRUPTED]: 'Неверный формат данных авторизации', [OAuthErrorCodes.PROVIDER_ALREADY_USED]: 'Провайдер уже привязан к другому пользователю', [OAuthErrorCodes.EMAIL_ALREADY_EXISTS]: 'Пользователь с таким email уже существует. Войдите через пароль', - [OAuthErrorCodes.OAUTH_LOGIN_NOT_FOUND]: 'Пользователь с таким OAuth аккаунтом не найден', [OAuthErrorCodes.ACTIVE_OAUTH_SESSION_EXISTS]: 'Активный процесс авторизации уже существует', - [OAuthErrorCodes.UNAUTHORIZED]: 'Необходима авторизация', [OAuthErrorCodes.DATA_CORRUPTION]: 'Ошибка целостности данных', [OAuthErrorCodes.SESSION_CREATION_FAILED]: 'Не удалось создать сессию', - [OAuthErrorCodes.SESSION_CREATION_INTERNAL_ERROR]: 'Внутренняя ошибка при создании сессии', - [OAuthErrorCodes.PROVIDER_CONNECT_FAILED]: 'Не удалось привязать провайдера', - [OAuthErrorCodes.PROVIDER_DISCONNECT_FAILED]: 'Не удалось отвязать провайдера', }; diff --git a/src/auth/infrastructure/strategies/github.strategy.ts b/src/auth/infrastructure/strategies/github.strategy.ts index f1be86b..6ac949e 100644 --- a/src/auth/infrastructure/strategies/github.strategy.ts +++ b/src/auth/infrastructure/strategies/github.strategy.ts @@ -30,7 +30,7 @@ export class GithubStrategy extends PassportStrategy(Strategy, 'github-oauth') { clientID: cfg.getOrThrow('GITHUB_CLIENT_ID'), clientSecret: cfg.getOrThrow('GITHUB_CLIENT_SECRET'), callbackURL, - scope: ['user:email', 'read:user'], + scope: ['user', 'user:email'], passReqToCallback: true, }); } From 362d8c190ac79800c53e29856101b640d9af3ff8 Mon Sep 17 00:00:00 2001 From: Maxim Date: Thu, 18 Jun 2026 19:35:57 +0300 Subject: [PATCH 2/5] feat(authorization): implement role-based access control system --- package.json | 1 + pnpm-lock.yaml | 38 ++ src/app.module.ts | 2 + src/shared/authorization/ability.factory.ts | 64 +++ .../authorization/authorization.module.ts | 10 + .../authorization/authorization.spec.ts | 525 ++++++++++++++++++ .../permissions/admin.permissions.ts | 50 ++ src/shared/authorization/permissions/index.ts | 5 + .../permissions/member.permissions.ts | 19 + .../permissions/owner.permissions.ts | 35 ++ .../permissions/permissions-map.ts | 14 + .../permissions/viewer.permissions.ts | 29 + src/shared/authorization/types/action.enum.ts | 7 + .../authorization/types/app-ability.type.ts | 5 + .../types/permission-rule.interface.ts | 10 + .../authorization/types/subject.enum.ts | 10 + src/shared/constants/roles.constant.ts | 7 +- .../repository/teams.repository.interface.ts | 5 +- .../persistence/models/enums.ts | 4 +- .../persistence/models/index.ts | 2 +- tsconfig.json | 4 +- 21 files changed, 836 insertions(+), 10 deletions(-) create mode 100644 src/shared/authorization/ability.factory.ts create mode 100644 src/shared/authorization/authorization.module.ts create mode 100644 src/shared/authorization/authorization.spec.ts create mode 100644 src/shared/authorization/permissions/admin.permissions.ts create mode 100644 src/shared/authorization/permissions/index.ts create mode 100644 src/shared/authorization/permissions/member.permissions.ts create mode 100644 src/shared/authorization/permissions/owner.permissions.ts create mode 100644 src/shared/authorization/permissions/permissions-map.ts create mode 100644 src/shared/authorization/permissions/viewer.permissions.ts create mode 100644 src/shared/authorization/types/action.enum.ts create mode 100644 src/shared/authorization/types/app-ability.type.ts create mode 100644 src/shared/authorization/types/permission-rule.interface.ts create mode 100644 src/shared/authorization/types/subject.enum.ts diff --git a/package.json b/package.json index 276012f..6646650 100644 --- a/package.json +++ b/package.json @@ -37,6 +37,7 @@ "dependencies": { "@aws-sdk/client-s3": "^3.1029.0", "@aws-sdk/s3-request-presigner": "^3.1029.0", + "@casl/ability": "^7.0.0", "@fastify/compress": "^8.3.1", "@fastify/cookie": "^11.0.2", "@fastify/cors": "^11.2.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8e10d5f..6d2c316 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -14,6 +14,9 @@ importers: '@aws-sdk/s3-request-presigner': specifier: ^3.1029.0 version: 3.1029.0 + '@casl/ability': + specifier: ^7.0.0 + version: 7.0.0 '@fastify/compress': specifier: ^8.3.1 version: 8.3.1 @@ -481,6 +484,9 @@ packages: '@borewit/text-codec@0.2.2': resolution: {integrity: sha512-DDaRehssg1aNrH4+2hnj1B7vnUGEjU6OIlyRdkMd0aUdIUvKXrJfXsy8LVtXAy7DRvYVluWbMspsRhz2lcW0mQ==} + '@casl/ability@7.0.0': + resolution: {integrity: sha512-QhwRflkTucpdS2uw1XScrzLWbgLYJGvPoq2Xm5OjeRci3dwtPixxnjUKJ04Ss1ivNS9tZQ8y4sjWeelWsrwo4g==} + '@colors/colors@1.5.0': resolution: {integrity: sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ==} engines: {node: '>=0.1.90'} @@ -2220,6 +2226,18 @@ packages: resolution: {integrity: sha512-QVLZu3ZPQEE+HICQyAMZ2yLQhxf0meY/wx6Hx14YcTNj13JB3qHlX3lJ02L3fLGHgERRH71kvYDwiXIguT3AjQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@ucast/core@2.0.0': + resolution: {integrity: sha512-4XVx6LzPXZGvnZO5jp39cm/G4UvuwvEdtmg+9+4+zl6uFkCcB7UJacvtMYeBE56GJVT99Zqy6Pii7dGJq3Kz9Q==} + + '@ucast/js@4.0.1': + resolution: {integrity: sha512-9O5xPBvwEWQk2WvO69Eh2WJB8QljVZ2vRVdFvfnKjlZwWXcYxp1lqLBhwXBU1AtuSgCvKhJPkXdjKJggUmAmQQ==} + + '@ucast/mongo2js@2.0.0': + resolution: {integrity: sha512-vNBZzRnsfLr/TSxEoxz6W6hHQ5tmWsfEeC0nCq5z8RezC1AqIRy3cfHm8AGvlGtcn+cTSFQcZremfqnz6wm+nQ==} + + '@ucast/mongo@3.0.0': + resolution: {integrity: sha512-kwuSH+kdB4GCR0LGhy/PEDm4PCflur89AlK82kNiYD0FvsA8A/p+0sx7m+/R8mMFAlmlkAd3VXp7sM/cLLYWYg==} + '@vitest/coverage-v8@4.1.4': resolution: {integrity: sha512-x7FptB5oDruxNPDNY2+S8tCh0pcq7ymCe1gTHcsp733jYjrJl8V1gMUlVysuCD9Kz46Xz9t1akkv08dPcYDs1w==} peerDependencies: @@ -5281,6 +5299,10 @@ snapshots: '@borewit/text-codec@0.2.2': {} + '@casl/ability@7.0.0': + dependencies: + '@ucast/mongo2js': 2.0.0 + '@colors/colors@1.5.0': optional: true @@ -6952,6 +6974,22 @@ snapshots: '@typescript-eslint/types': 8.61.0 eslint-visitor-keys: 5.0.1 + '@ucast/core@2.0.0': {} + + '@ucast/js@4.0.1': + dependencies: + '@ucast/core': 2.0.0 + + '@ucast/mongo2js@2.0.0': + dependencies: + '@ucast/core': 2.0.0 + '@ucast/js': 4.0.1 + '@ucast/mongo': 3.0.0 + + '@ucast/mongo@3.0.0': + dependencies: + '@ucast/core': 2.0.0 + '@vitest/coverage-v8@4.1.4(vitest@4.1.4)': dependencies: '@bcoe/v8-coverage': 1.0.2 diff --git a/src/app.module.ts b/src/app.module.ts index 3288e74..1733ed4 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -13,6 +13,7 @@ import { CACHE_SERVICE } from '@shared/adapters/cache/constants'; import { CacheModule } from '@shared/adapters/cache/module'; import { ICacheService } from '@shared/adapters/cache/ports'; import { MailModule } from '@shared/adapters/mail'; +import { AuthorizationModule } from '@shared/authorization/authorization.module'; import { GlobalExceptionFilter } from '@shared/error'; import { ZodValidationInterceptor } from '@shared/interceptors'; import { ZodValidationPipe } from 'nestjs-zod'; @@ -56,6 +57,7 @@ import { UserModule } from './user'; ProjectModule, AreaModule, MetricsModule, + AuthorizationModule, HealthModule.registerAsync({ inject: [DatabaseHealthService, S3Service, CACHE_SERVICE], useFactory: (db: DatabaseHealthService, s3: S3Service, cache: ICacheService) => { diff --git a/src/shared/authorization/ability.factory.ts b/src/shared/authorization/ability.factory.ts new file mode 100644 index 0000000..e3aa0da --- /dev/null +++ b/src/shared/authorization/ability.factory.ts @@ -0,0 +1,64 @@ +import { createMongoAbility, RawRuleOf } from '@casl/ability'; +import { RawMemberRow } from '@core/teams/domain/repository'; +import { Injectable } from '@nestjs/common'; +import { Subject } from '@shared/authorization/types/subject.enum'; +import { ROLE_PRIORITY } from '@shared/constants'; + +import { ROLE_PERMISSIONS_MAP } from './permissions'; +import { AppAbility } from './types/app-ability.type'; + +import type { ConditionValue } from './types/permission-rule.interface'; + +@Injectable() +export class AbilityFactory { + constructor() {} + + createForTeamMember(member: RawMemberRow) { + const role = member.role; + const permissions = ROLE_PERMISSIONS_MAP[role]; + const rolePriority = ROLE_PRIORITY[role]; + + const rules: RawRuleOf[] = permissions.map((permission) => { + const conditions = this.resolveConditions(permission.conditions, member.userId); + + if ( + role !== 'owner' && + (permission.subject === Subject.TEAM_MEMBER || permission.subject === Subject.ROLE) + ) { + return { + action: permission.action, + subject: permission.subject, + conditions: { + ...conditions, + priority: { $lt: rolePriority }, + }, + }; + } + + return { + action: permission.action, + subject: permission.subject, + conditions, + }; + }); + + return createMongoAbility(rules); + } + + private resolveConditions( + conditions: Record | undefined, + userId: string, + ) { + if (!conditions) { + return {}; + } + + const result: Record = {}; + + for (const [key, value] of Object.entries(conditions)) { + result[key] = value === '$currentUser' ? userId : value; + } + + return result; + } +} diff --git a/src/shared/authorization/authorization.module.ts b/src/shared/authorization/authorization.module.ts new file mode 100644 index 0000000..5afe655 --- /dev/null +++ b/src/shared/authorization/authorization.module.ts @@ -0,0 +1,10 @@ +import { Global, Module } from '@nestjs/common'; + +import { AbilityFactory } from './ability.factory'; + +@Global() +@Module({ + providers: [AbilityFactory], + exports: [AbilityFactory], +}) +export class AuthorizationModule {} diff --git a/src/shared/authorization/authorization.spec.ts b/src/shared/authorization/authorization.spec.ts new file mode 100644 index 0000000..6e7d8ac --- /dev/null +++ b/src/shared/authorization/authorization.spec.ts @@ -0,0 +1,525 @@ +import { subject } from '@casl/ability'; +import { Action } from '@shared/authorization/types/action.enum'; +import { Subject } from '@shared/authorization/types/subject.enum'; +import { ROLE_PRIORITY } from '@shared/constants'; +import { describe, expect } from 'vitest'; + +import { AbilityFactory } from './ability.factory'; + +import type { RawMemberRow } from '@core/teams/domain/repository'; +import type { TeamRole } from '@core/teams/infrastructure/persistence/models'; + +describe('AuthorizationService - Permissions Matrix', () => { + const findTeamMemberMock = { + execute: vi.fn(), + }; + + const factory = new AbilityFactory(); + + const createAbilityFor = (role: TeamRole) => { + const mockMember: RawMemberRow = { + userId: '1', + role, + status: 'active', + joinedAt: null, + firstName: null, + lastName: null, + middleName: null, + avatarUrl: null, + }; + vi.spyOn(findTeamMemberMock, 'execute').mockResolvedValue(mockMember); + return factory.createForTeamMember(mockMember); + }; + + describe('Role: Owner', () => { + it('should MANAGE TEAM', () => { + const ability = createAbilityFor('owner'); + + expect(ability.can(Action.MANAGE, Subject.TEAM)).toBeTruthy(); + expect(ability.can(Action.CREATE, Subject.TEAM)).toBeTruthy(); + expect(ability.can(Action.READ, Subject.TEAM)).toBeTruthy(); + expect(ability.can(Action.UPDATE, Subject.TEAM)).toBeTruthy(); + expect(ability.can(Action.DELETE, Subject.TEAM)).toBeTruthy(); + }); + + it('should MANAGE TEAM_MEMBER with any priority', () => { + const ability = createAbilityFor('owner'); + + expect(ability.can(Action.MANAGE, Subject.TEAM_MEMBER)).toBeTruthy(); + expect( + ability.can( + Action.MANAGE, + subject(Subject.TEAM_MEMBER, { priority: ROLE_PRIORITY['owner'] }), + ), + ).toBeTruthy(); + expect( + ability.can( + Action.MANAGE, + subject(Subject.TEAM_MEMBER, { priority: ROLE_PRIORITY['admin'] }), + ), + ).toBeTruthy(); + expect( + ability.can( + Action.MANAGE, + + subject(Subject.TEAM_MEMBER, { priority: ROLE_PRIORITY['member'] }), + ), + ).toBeTruthy(); + + expect( + ability.can( + Action.MANAGE, + subject(Subject.TEAM_MEMBER, { priority: ROLE_PRIORITY['viewer'] }), + ), + ).toBeTruthy(); + }); + + it('should MANAGE INVITE', () => { + const ability = createAbilityFor('owner'); + + expect(ability.can(Action.MANAGE, Subject.INVITE)).toBeTruthy(); + expect(ability.can(Action.CREATE, Subject.INVITE)).toBeTruthy(); + expect(ability.can(Action.READ, Subject.INVITE)).toBeTruthy(); + expect(ability.can(Action.UPDATE, Subject.INVITE)).toBeTruthy(); + expect(ability.can(Action.DELETE, Subject.INVITE)).toBeTruthy(); + }); + + it('should MANAGE PROJECT', () => { + const ability = createAbilityFor('owner'); + + expect(ability.can(Action.MANAGE, Subject.PROJECT)).toBeTruthy(); + expect(ability.can(Action.CREATE, Subject.PROJECT)).toBeTruthy(); + expect(ability.can(Action.READ, Subject.PROJECT)).toBeTruthy(); + expect(ability.can(Action.UPDATE, Subject.PROJECT)).toBeTruthy(); + expect(ability.can(Action.DELETE, Subject.PROJECT)).toBeTruthy(); + }); + + it('should MANAGE TASK', () => { + const ability = createAbilityFor('owner'); + + expect(ability.can(Action.MANAGE, Subject.TASK)).toBeTruthy(); + expect(ability.can(Action.CREATE, Subject.TASK)).toBeTruthy(); + expect(ability.can(Action.READ, Subject.TASK)).toBeTruthy(); + expect(ability.can(Action.UPDATE, Subject.TASK)).toBeTruthy(); + expect(ability.can(Action.DELETE, Subject.TASK)).toBeTruthy(); + }); + + it('should MANAGE ROLE', () => { + const ability = createAbilityFor('owner'); + + expect(ability.can(Action.MANAGE, Subject.ROLE)).toBeTruthy(); + expect(ability.can(Action.CREATE, Subject.ROLE)).toBeTruthy(); + expect(ability.can(Action.READ, Subject.ROLE)).toBeTruthy(); + expect(ability.can(Action.UPDATE, Subject.ROLE)).toBeTruthy(); + expect( + ability.can( + Action.UPDATE, + subject(Subject.ROLE, { priority: ROLE_PRIORITY['owner'] }), + ), + ).toBeTruthy(); + expect(ability.can(Action.DELETE, Subject.ROLE)).toBeTruthy(); + }); + + it('should MANAGE BILLING', () => { + const ability = createAbilityFor('owner'); + + expect(ability.can(Action.MANAGE, Subject.BILLING)).toBeTruthy(); + expect(ability.can(Action.CREATE, Subject.BILLING)).toBeTruthy(); + expect(ability.can(Action.READ, Subject.BILLING)).toBeTruthy(); + expect(ability.can(Action.UPDATE, Subject.BILLING)).toBeTruthy(); + expect(ability.can(Action.DELETE, Subject.BILLING)).toBeTruthy(); + }); + }); + + describe('Role: Admin', () => { + it('should READ and UPDATE TEAM, but not DELETE', () => { + const ability = createAbilityFor('admin'); + + expect(ability.can(Action.READ, Subject.TEAM)).toBeTruthy(); + expect(ability.can(Action.UPDATE, Subject.TEAM)).toBeTruthy(); + + expect(ability.can(Action.DELETE, Subject.TEAM)).toBeFalsy(); + expect(ability.can(Action.MANAGE, Subject.TEAM)).toBeFalsy(); + }); + + it('should MANAGE TEAM_MEMBER with lower priority only (not higher or the same role)', () => { + const ability = createAbilityFor('admin'); + + // admin can manage members with lower role priority + expect( + ability.can( + Action.MANAGE, + subject(Subject.TEAM_MEMBER, { priority: ROLE_PRIORITY['member'] }), + ), + ).toBeTruthy(); + expect( + ability.can( + Action.MANAGE, + subject(Subject.TEAM_MEMBER, { priority: ROLE_PRIORITY['viewer'] }), + ), + ).toBeTruthy(); + + // cannot manage members with the same or higher role priority + expect( + ability.can( + Action.MANAGE, + subject(Subject.TEAM_MEMBER, { priority: ROLE_PRIORITY['owner'] }), + ), + ).toBeFalsy(); + expect( + ability.can( + Action.MANAGE, + subject(Subject.TEAM_MEMBER, { priority: ROLE_PRIORITY['admin'] }), + ), + ).toBeFalsy(); + }); + + it('should MANAGE INVITE', () => { + const ability = createAbilityFor('admin'); + + expect(ability.can(Action.MANAGE, Subject.INVITE)).toBeTruthy(); + expect(ability.can(Action.CREATE, Subject.INVITE)).toBeTruthy(); + expect(ability.can(Action.READ, Subject.INVITE)).toBeTruthy(); + expect(ability.can(Action.UPDATE, Subject.INVITE)).toBeTruthy(); + expect(ability.can(Action.DELETE, Subject.INVITE)).toBeTruthy(); + }); + + it('should MANAGE PROJECT', () => { + const ability = createAbilityFor('admin'); + + expect(ability.can(Action.MANAGE, Subject.PROJECT)).toBeTruthy(); + expect(ability.can(Action.CREATE, Subject.PROJECT)).toBeTruthy(); + expect(ability.can(Action.READ, Subject.PROJECT)).toBeTruthy(); + expect(ability.can(Action.UPDATE, Subject.PROJECT)).toBeTruthy(); + expect(ability.can(Action.DELETE, Subject.PROJECT)).toBeTruthy(); + }); + + it('should MANAGE TASK', () => { + const ability = createAbilityFor('admin'); + + expect(ability.can(Action.MANAGE, Subject.TASK)).toBeTruthy(); + expect(ability.can(Action.CREATE, Subject.TASK)).toBeTruthy(); + expect(ability.can(Action.READ, Subject.TASK)).toBeTruthy(); + expect(ability.can(Action.UPDATE, Subject.TASK)).toBeTruthy(); + expect(ability.can(Action.DELETE, Subject.TASK)).toBeTruthy(); + }); + + it('should MANAGE ROLE', () => { + const ability = createAbilityFor('admin'); + + expect(ability.can(Action.MANAGE, Subject.ROLE)).toBeTruthy(); + expect(ability.can(Action.CREATE, Subject.ROLE)).toBeTruthy(); + expect(ability.can(Action.READ, Subject.ROLE)).toBeTruthy(); + expect(ability.can(Action.UPDATE, Subject.ROLE)).toBeTruthy(); + expect(ability.can(Action.DELETE, Subject.ROLE)).toBeTruthy(); + }); + + it('should NOT have access to CREATE/UPDATE-TO ROLE with the same or higher priority', () => { + const ability = createAbilityFor('admin'); + + expect( + ability.can( + Action.CREATE, + subject(Subject.ROLE, { priority: ROLE_PRIORITY['member'] }), + ), + ).toBeTruthy(); + expect( + ability.can( + Action.CREATE, + subject(Subject.ROLE, { priority: ROLE_PRIORITY['admin'] }), + ), + ).toBeFalsy(); + expect( + ability.can( + Action.CREATE, + subject(Subject.ROLE, { priority: ROLE_PRIORITY['owner'] }), + ), + ).toBeFalsy(); + + expect( + ability.can( + Action.UPDATE, + subject(Subject.ROLE, { priority: ROLE_PRIORITY['member'] }), + ), + ).toBeTruthy(); + expect( + ability.can( + Action.CREATE, + subject(Subject.ROLE, { priority: ROLE_PRIORITY['admin'] }), + ), + ).toBeFalsy(); + expect( + ability.can( + Action.CREATE, + subject(Subject.ROLE, { priority: ROLE_PRIORITY['owner'] }), + ), + ).toBeFalsy(); + }); + + it('should have only READ access to BILLING', () => { + const ability = createAbilityFor('admin'); + + expect(ability.can(Action.READ, Subject.BILLING)).toBeTruthy(); + + expect(ability.can(Action.MANAGE, Subject.BILLING)).toBeFalsy(); + expect(ability.can(Action.UPDATE, Subject.BILLING)).toBeFalsy(); + expect(ability.can(Action.DELETE, Subject.BILLING)).toBeFalsy(); + }); + }); + + describe('Role: Member', () => { + it('should MANAGE own TASK only', () => { + const ability = createAbilityFor('member'); + + expect( + ability.can(Action.MANAGE, subject(Subject.TASK, { ownerId: '1' })), + ).toBeTruthy(); + expect(ability.can(Action.MANAGE, subject(Subject.TASK, { ownerId: '2' }))).toBeFalsy(); + }); + + it('should NOT have access to TEAM', () => { + const ability = createAbilityFor('member'); + + expect(ability.can(Action.MANAGE, Subject.TEAM)).toBeFalsy(); + expect(ability.can(Action.READ, Subject.TEAM)).toBeFalsy(); + expect(ability.can(Action.UPDATE, Subject.TEAM)).toBeFalsy(); + expect(ability.can(Action.DELETE, Subject.TEAM)).toBeFalsy(); + }); + + it('should NOT have access to TEAM_MEMBER', () => { + const ability = createAbilityFor('member'); + + expect(ability.can(Action.MANAGE, Subject.TEAM_MEMBER)).toBeFalsy(); + expect( + ability.can(Action.MANAGE, subject(Subject.TEAM_MEMBER, { priority: 0 })), + ).toBeFalsy(); + }); + + it('should NOT have access to INVITE', () => { + const ability = createAbilityFor('member'); + + expect(ability.can(Action.MANAGE, Subject.INVITE)).toBeFalsy(); + expect(ability.can(Action.CREATE, Subject.INVITE)).toBeFalsy(); + expect(ability.can(Action.READ, Subject.INVITE)).toBeFalsy(); + expect(ability.can(Action.UPDATE, Subject.INVITE)).toBeFalsy(); + expect(ability.can(Action.DELETE, Subject.INVITE)).toBeFalsy(); + }); + + it('should NOT have access to ROLE', () => { + const ability = createAbilityFor('member'); + + expect(ability.can(Action.MANAGE, Subject.ROLE)).toBeFalsy(); + expect(ability.can(Action.CREATE, Subject.ROLE)).toBeFalsy(); + expect(ability.can(Action.READ, Subject.ROLE)).toBeFalsy(); + expect(ability.can(Action.UPDATE, Subject.ROLE)).toBeFalsy(); + expect(ability.can(Action.DELETE, Subject.ROLE)).toBeFalsy(); + }); + + it('should have only READ access to PROJECT', () => { + const ability = createAbilityFor('member'); + + expect(ability.can(Action.READ, Subject.PROJECT)).toBeTruthy(); + + expect(ability.can(Action.MANAGE, Subject.PROJECT)).toBeFalsy(); + expect(ability.can(Action.CREATE, Subject.PROJECT)).toBeFalsy(); + expect(ability.can(Action.UPDATE, Subject.PROJECT)).toBeFalsy(); + expect(ability.can(Action.DELETE, Subject.PROJECT)).toBeFalsy(); + }); + + it('should NOT have access to BILLING', () => { + const ability = createAbilityFor('member'); + + expect(ability.can(Action.MANAGE, Subject.BILLING)).toBeFalsy(); + expect(ability.can(Action.CREATE, Subject.BILLING)).toBeFalsy(); + expect(ability.can(Action.READ, Subject.BILLING)).toBeFalsy(); + expect(ability.can(Action.UPDATE, Subject.BILLING)).toBeFalsy(); + expect(ability.can(Action.DELETE, Subject.BILLING)).toBeFalsy(); + }); + }); + + describe('Role: Viewer', () => { + it('should have only READ access to TEAM', () => { + const ability = createAbilityFor('viewer'); + + expect(ability.can(Action.READ, Subject.TEAM)).toBeTruthy(); + + expect(ability.can(Action.CREATE, Subject.TEAM)).toBeFalsy(); + expect(ability.can(Action.MANAGE, Subject.TEAM)).toBeFalsy(); + expect(ability.can(Action.UPDATE, Subject.TEAM)).toBeFalsy(); + expect(ability.can(Action.DELETE, Subject.TEAM)).toBeFalsy(); + }); + + it('should have only READ access to PROJECT', () => { + const ability = createAbilityFor('viewer'); + + expect(ability.can(Action.READ, Subject.PROJECT)).toBeTruthy(); + + expect(ability.can(Action.MANAGE, Subject.PROJECT)).toBeFalsy(); + expect(ability.can(Action.CREATE, Subject.PROJECT)).toBeFalsy(); + expect(ability.can(Action.UPDATE, Subject.PROJECT)).toBeFalsy(); + expect(ability.can(Action.DELETE, Subject.PROJECT)).toBeFalsy(); + }); + + it('should have NOT access to ROLE', () => { + const ability = createAbilityFor('viewer'); + + expect(ability.can(Action.READ, Subject.ROLE)).toBeFalsy(); + expect(ability.can(Action.MANAGE, Subject.ROLE)).toBeFalsy(); + expect(ability.can(Action.CREATE, Subject.ROLE)).toBeFalsy(); + expect(ability.can(Action.UPDATE, Subject.ROLE)).toBeFalsy(); + expect(ability.can(Action.DELETE, Subject.ROLE)).toBeFalsy(); + }); + + it('should have NOT access to INVITE', () => { + const ability = createAbilityFor('viewer'); + + expect(ability.can(Action.READ, Subject.INVITE)).toBeFalsy(); + expect(ability.can(Action.MANAGE, Subject.INVITE)).toBeFalsy(); + expect(ability.can(Action.CREATE, Subject.INVITE)).toBeFalsy(); + expect(ability.can(Action.UPDATE, Subject.INVITE)).toBeFalsy(); + expect(ability.can(Action.DELETE, Subject.INVITE)).toBeFalsy(); + }); + + it('should have NOT access to BILLING', () => { + const ability = createAbilityFor('viewer'); + + expect(ability.can(Action.READ, Subject.BILLING)).toBeFalsy(); + expect(ability.can(Action.MANAGE, Subject.BILLING)).toBeFalsy(); + expect(ability.can(Action.CREATE, Subject.BILLING)).toBeFalsy(); + expect(ability.can(Action.UPDATE, Subject.BILLING)).toBeFalsy(); + expect(ability.can(Action.DELETE, Subject.BILLING)).toBeFalsy(); + }); + + it('should have only READ access to TEAM_MEMBER', () => { + const ability = createAbilityFor('viewer'); + + expect(ability.can(Action.READ, Subject.TEAM_MEMBER)).toBeTruthy(); + + expect(ability.can(Action.MANAGE, Subject.TEAM_MEMBER)).toBeFalsy(); + expect(ability.can(Action.CREATE, Subject.TEAM_MEMBER)).toBeFalsy(); + expect(ability.can(Action.UPDATE, Subject.TEAM_MEMBER)).toBeFalsy(); + expect(ability.can(Action.DELETE, Subject.TEAM_MEMBER)).toBeFalsy(); + }); + + it('should have only READ access to TASK (even own)', () => { + const ability = createAbilityFor('viewer'); + + expect(ability.can(Action.READ, Subject.TASK)).toBeTruthy(); + + expect(ability.can(Action.MANAGE, Subject.TASK)).toBeFalsy(); + expect(ability.can(Action.CREATE, Subject.TASK)).toBeFalsy(); + expect(ability.can(Action.UPDATE, Subject.TASK)).toBeFalsy(); + expect(ability.can(Action.DELETE, Subject.TASK)).toBeFalsy(); + + expect(ability.can(Action.MANAGE, subject(Subject.TASK, { ownerId: '1' }))).toBeFalsy(); + expect(ability.can(Action.UPDATE, subject(Subject.TASK, { ownerId: '1' }))).toBeFalsy(); + expect(ability.can(Action.DELETE, subject(Subject.TASK, { ownerId: '1' }))).toBeFalsy(); + expect(ability.can(Action.CREATE, subject(Subject.TASK, { ownerId: '1' }))).toBeFalsy(); + }); + }); + + describe('Role Priority - Admin cannot manage higher or equal priority members', () => { + it('should NOT allow admin to manage owner (priority 3 > 2)', () => { + const ability = createAbilityFor('admin'); + + expect( + ability.can( + Action.MANAGE, + subject(Subject.TEAM_MEMBER, { priority: ROLE_PRIORITY['owner'] }), + ), + ).toBeFalsy(); + }); + + it('should NOT allow admin to manage another admin (priority 2 === 2)', () => { + const ability = createAbilityFor('admin'); + + expect( + ability.can( + Action.MANAGE, + subject(Subject.TEAM_MEMBER, { priority: ROLE_PRIORITY['admin'] }), + ), + ).toBeFalsy(); + }); + + it('should allow admin to manage member (priority 1 < 2)', () => { + const ability = createAbilityFor('admin'); + + expect( + ability.can( + Action.MANAGE, + subject(Subject.TEAM_MEMBER, { priority: ROLE_PRIORITY['member'] }), + ), + ).toBeTruthy(); + }); + + it('should allow admin to manage viewer (priority 0 < 2)', () => { + const ability = createAbilityFor('admin'); + + expect( + ability.can( + Action.MANAGE, + subject(Subject.TEAM_MEMBER, { priority: ROLE_PRIORITY['viewer'] }), + ), + ).toBeTruthy(); + }); + }); + + describe('Role Priority - Owner can manage all members', () => { + it('should allow owner to manage owner (priority 3 = 3)', () => { + const ability = createAbilityFor('owner'); + + expect( + ability.can( + Action.MANAGE, + subject(Subject.TEAM_MEMBER, { priority: ROLE_PRIORITY['owner'] }), + ), + ).toBeTruthy(); + }); + + it('should allow owner to manage admin (priority 2 < 3)', () => { + const ability = createAbilityFor('owner'); + + expect( + ability.can( + Action.MANAGE, + subject(Subject.TEAM_MEMBER, { priority: ROLE_PRIORITY['admin'] }), + ), + ).toBeTruthy(); + }); + + it('should allow owner to manage member (priority 1 < 3)', () => { + const ability = createAbilityFor('owner'); + + expect( + ability.can( + Action.MANAGE, + subject(Subject.TEAM_MEMBER, { priority: ROLE_PRIORITY['member'] }), + ), + ).toBeTruthy(); + }); + + it('should allow owner to manage viewer (priority 0 < 3)', () => { + const ability = createAbilityFor('owner'); + + expect( + ability.can( + Action.MANAGE, + subject(Subject.TEAM_MEMBER, { priority: ROLE_PRIORITY['viewer'] }), + ), + ).toBeTruthy(); + }); + }); + + describe('Fallback to viewer role', () => { + it('should grant no permissions when member is not found (defaults to viewer)', () => { + vi.spyOn(findTeamMemberMock, 'execute').mockResolvedValue(null); + const ability = createAbilityFor('viewer'); + + expect(ability.can(Action.MANAGE, Subject.TEAM)).toBeFalsy(); + expect(ability.can(Action.MANAGE, Subject.TEAM_MEMBER)).toBeFalsy(); + expect(ability.can(Action.MANAGE, Subject.INVITE)).toBeFalsy(); + expect(ability.can(Action.MANAGE, Subject.ROLE)).toBeFalsy(); + expect(ability.can(Action.MANAGE, subject(Subject.TASK, { ownerId: '1' }))).toBeFalsy(); + }); + }); +}); diff --git a/src/shared/authorization/permissions/admin.permissions.ts b/src/shared/authorization/permissions/admin.permissions.ts new file mode 100644 index 0000000..737c2d7 --- /dev/null +++ b/src/shared/authorization/permissions/admin.permissions.ts @@ -0,0 +1,50 @@ +import { Action } from '@shared/authorization/types/action.enum'; +import { type PermissionRule } from '@shared/authorization/types/permission-rule.interface'; +import { Subject } from '@shared/authorization/types/subject.enum'; + +export const ADMIN_PERMISSIONS: PermissionRule[] = [ + { + action: Action.READ, + subject: Subject.TEAM, + }, + { + action: Action.UPDATE, + subject: Subject.TEAM, + }, + + //team members + { + action: Action.MANAGE, + subject: Subject.TEAM_MEMBER, + }, + + //project + { + action: Action.MANAGE, + subject: Subject.PROJECT, + }, + + //task + { + action: Action.MANAGE, + subject: Subject.TASK, + }, + + //invites + { + action: Action.MANAGE, + subject: Subject.INVITE, + }, + + //roles + { + action: Action.MANAGE, + subject: Subject.ROLE, + }, + + //billing + { + action: Action.READ, + subject: Subject.BILLING, + }, +]; diff --git a/src/shared/authorization/permissions/index.ts b/src/shared/authorization/permissions/index.ts new file mode 100644 index 0000000..24feaa3 --- /dev/null +++ b/src/shared/authorization/permissions/index.ts @@ -0,0 +1,5 @@ +export { OWNER_PERMISSIONS } from './owner.permissions'; +export { VIEWER_PERMISSIONS } from './viewer.permissions'; +export { ADMIN_PERMISSIONS } from './admin.permissions'; +export { MEMBER_PERMISSIONS } from './member.permissions'; +export { ROLE_PERMISSIONS_MAP } from './permissions-map'; diff --git a/src/shared/authorization/permissions/member.permissions.ts b/src/shared/authorization/permissions/member.permissions.ts new file mode 100644 index 0000000..d793b92 --- /dev/null +++ b/src/shared/authorization/permissions/member.permissions.ts @@ -0,0 +1,19 @@ +import { Action } from '@shared/authorization/types/action.enum'; +import { type PermissionRule } from '@shared/authorization/types/permission-rule.interface'; +import { Subject } from '@shared/authorization/types/subject.enum'; + +export const MEMBER_PERMISSIONS: PermissionRule[] = [ + { + action: Action.MANAGE, + subject: Subject.TASK, + conditions: { + ownerId: '$currentUser', + }, + }, + + //project + { + action: Action.READ, + subject: Subject.PROJECT, + }, +]; diff --git a/src/shared/authorization/permissions/owner.permissions.ts b/src/shared/authorization/permissions/owner.permissions.ts new file mode 100644 index 0000000..8039d9e --- /dev/null +++ b/src/shared/authorization/permissions/owner.permissions.ts @@ -0,0 +1,35 @@ +import { Action } from '@shared/authorization/types/action.enum'; +import { Subject } from '@shared/authorization/types/subject.enum'; + +import type { PermissionRule } from '@shared/authorization/types/permission-rule.interface'; + +export const OWNER_PERMISSIONS: PermissionRule[] = [ + { + action: Action.MANAGE, + subject: Subject.TASK, + }, + { + action: Action.MANAGE, + subject: Subject.PROJECT, + }, + { + action: Action.MANAGE, + subject: Subject.TEAM, + }, + { + action: Action.MANAGE, + subject: Subject.INVITE, + }, + { + action: Action.MANAGE, + subject: Subject.TEAM_MEMBER, + }, + { + action: Action.MANAGE, + subject: Subject.ROLE, + }, + { + action: Action.MANAGE, + subject: Subject.BILLING, + }, +]; diff --git a/src/shared/authorization/permissions/permissions-map.ts b/src/shared/authorization/permissions/permissions-map.ts new file mode 100644 index 0000000..5aa335e --- /dev/null +++ b/src/shared/authorization/permissions/permissions-map.ts @@ -0,0 +1,14 @@ +import { ADMIN_PERMISSIONS } from '@shared/authorization/permissions/admin.permissions'; +import { MEMBER_PERMISSIONS } from '@shared/authorization/permissions/member.permissions'; +import { OWNER_PERMISSIONS } from '@shared/authorization/permissions/owner.permissions'; +import { VIEWER_PERMISSIONS } from '@shared/authorization/permissions/viewer.permissions'; + +import type { TeamRole } from '@core/teams/infrastructure/persistence/models'; +import type { PermissionRule } from '@shared/authorization/types/permission-rule.interface'; + +export const ROLE_PERMISSIONS_MAP: Record = { + owner: OWNER_PERMISSIONS, + admin: ADMIN_PERMISSIONS, + member: MEMBER_PERMISSIONS, + viewer: VIEWER_PERMISSIONS, +}; diff --git a/src/shared/authorization/permissions/viewer.permissions.ts b/src/shared/authorization/permissions/viewer.permissions.ts new file mode 100644 index 0000000..53d9f9f --- /dev/null +++ b/src/shared/authorization/permissions/viewer.permissions.ts @@ -0,0 +1,29 @@ +import { Action } from '@shared/authorization/types/action.enum'; +import { type PermissionRule } from '@shared/authorization/types/permission-rule.interface'; +import { Subject } from '@shared/authorization/types/subject.enum'; + +export const VIEWER_PERMISSIONS: PermissionRule[] = [ + // team + { + action: Action.READ, + subject: Subject.TEAM, + }, + + //projects + { + action: Action.READ, + subject: Subject.PROJECT, + }, + + //tasks + { + action: Action.READ, + subject: Subject.TASK, + }, + + //team members + { + action: Action.READ, + subject: Subject.TEAM_MEMBER, + }, +]; diff --git a/src/shared/authorization/types/action.enum.ts b/src/shared/authorization/types/action.enum.ts new file mode 100644 index 0000000..a1be279 --- /dev/null +++ b/src/shared/authorization/types/action.enum.ts @@ -0,0 +1,7 @@ +export enum Action { + MANAGE = 'manage', + CREATE = 'create', + READ = 'read', + UPDATE = 'update', + DELETE = 'delete', +} diff --git a/src/shared/authorization/types/app-ability.type.ts b/src/shared/authorization/types/app-ability.type.ts new file mode 100644 index 0000000..877c234 --- /dev/null +++ b/src/shared/authorization/types/app-ability.type.ts @@ -0,0 +1,5 @@ +import type { ForcedSubject, MongoAbility } from '@casl/ability'; +import type { Action } from '@shared/authorization/types/action.enum'; +import type { Subject } from '@shared/authorization/types/subject.enum'; + +export type AppAbility = MongoAbility<[Action, Subject | ForcedSubject]>; diff --git a/src/shared/authorization/types/permission-rule.interface.ts b/src/shared/authorization/types/permission-rule.interface.ts new file mode 100644 index 0000000..c91d9c4 --- /dev/null +++ b/src/shared/authorization/types/permission-rule.interface.ts @@ -0,0 +1,10 @@ +import { type Action } from './action.enum'; +import { type Subject } from './subject.enum'; + +export interface PermissionRule { + subject: Subject; + action: Action; + conditions?: Record; +} + +export type ConditionValue = '$currentUser'; diff --git a/src/shared/authorization/types/subject.enum.ts b/src/shared/authorization/types/subject.enum.ts new file mode 100644 index 0000000..6db3eb1 --- /dev/null +++ b/src/shared/authorization/types/subject.enum.ts @@ -0,0 +1,10 @@ +export enum Subject { + TEAM = 'Team', + TEAM_MEMBER = 'TeamMember', + ROLE = 'Role', + PROJECT = 'Project', + TASK = 'Task', + BILLING = 'Billing', + INVITE = 'Invite', + STATUS = 'Status', +} diff --git a/src/shared/constants/roles.constant.ts b/src/shared/constants/roles.constant.ts index b50e0b4..8763713 100644 --- a/src/shared/constants/roles.constant.ts +++ b/src/shared/constants/roles.constant.ts @@ -1,11 +1,10 @@ -export const TEAM_ROLES = ['owner', 'admin', 'moderator', 'lead', 'member', 'viewer'] as const; -export type TeamRole = (typeof TEAM_ROLES)[number]; +import type { TeamRole } from '@core/teams/infrastructure/persistence/models'; + +export const TEAM_ROLES = ['owner', 'admin', 'member', 'viewer'] as const; export const ROLE_PRIORITY: Record = { owner: 4, admin: 3, - lead: 2, - moderator: 2, member: 1, viewer: 0, }; diff --git a/src/teams/domain/repository/teams.repository.interface.ts b/src/teams/domain/repository/teams.repository.interface.ts index aa958cf..9b0d5ea 100644 --- a/src/teams/domain/repository/teams.repository.interface.ts +++ b/src/teams/domain/repository/teams.repository.interface.ts @@ -1,11 +1,12 @@ import type { Team, NewTeam, NewTeamMember } from '../entities'; +import type { TeamRole, TeamMemberStatus } from '@core/teams/infrastructure/persistence/models'; type TResponse = { readonly success: boolean; readonly teamId: string }; export type RawMemberRow = { readonly userId: string; - readonly role: string; - readonly status: string; + readonly role: TeamRole; + readonly status: TeamMemberStatus; readonly joinedAt: string | null; readonly firstName: string | null; readonly lastName: string | null; diff --git a/src/teams/infrastructure/persistence/models/enums.ts b/src/teams/infrastructure/persistence/models/enums.ts index 2dba2b2..c006798 100644 --- a/src/teams/infrastructure/persistence/models/enums.ts +++ b/src/teams/infrastructure/persistence/models/enums.ts @@ -3,8 +3,6 @@ import { baseSchema } from '@shared/entities'; export const roleEnum = baseSchema.enum('team_role', [ 'owner', 'admin', // управление юзерами, настройками - 'lead', // управление проектами - 'moderator', // чистка контента/сообщений 'member', // обычный работяга 'viewer', // просто смотрит ]); @@ -15,3 +13,5 @@ export const statusEnum = baseSchema.enum('member_status', [ 'banned', // Заблокирован не может вернуться по инвайту 'inactive', // Доступ закрыт, но запись сохранена ]); + +export type TeamMemberStatus = (typeof statusEnum.enumValues)[number]; diff --git a/src/teams/infrastructure/persistence/models/index.ts b/src/teams/infrastructure/persistence/models/index.ts index 2a40eb0..0054591 100644 --- a/src/teams/infrastructure/persistence/models/index.ts +++ b/src/teams/infrastructure/persistence/models/index.ts @@ -1,2 +1,2 @@ export { teamMembers, teams } from './teams.model'; -export { type TeamRole, roleEnum, statusEnum } from './enums'; +export { type TeamRole, type TeamMemberStatus, roleEnum, statusEnum } from './enums'; diff --git a/tsconfig.json b/tsconfig.json index d175afd..627f45b 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -48,7 +48,9 @@ "@libs/s3": ["./libs/s3/src"], "@libs/s3/*": ["./libs/s3/src/*"], "@shared/*": ["./src/shared/*"], - "@core/*": ["./src/*"] + "@core/*": ["./src/*"], + "@casl/ability": ["./node_modules/@casl/ability/dist/types"], + "@casl/ability/extra": ["./node_modules/@casl/extra/dist/types/extra"] } }, "include": [ From e82401f655c7a72ea6e939132399b8a32b6670b4 Mon Sep 17 00:00:00 2001 From: Maxim Date: Thu, 18 Jun 2026 19:58:38 +0300 Subject: [PATCH 3/5] feat(teams): add ability-based access control for invitations, roles, and team management --- .../controller/invitations/controller.ts | 2 +- src/teams/application/team.facade.ts | 6 +- .../use-cases/base/delete-team.use-case.ts | 45 +++--- .../use-cases/base/update-team.use-case.ts | 47 ++++--- src/teams/application/use-cases/index.ts | 6 +- ...-case.ts => delete-invitation.use-case.ts} | 65 ++++----- .../invitions/get-invitations.query.ts | 44 +++--- .../invitions/send-invitation.use-case.ts | 45 ++++-- .../invitions/update-invitation.use-case.ts | 80 ++++++----- .../members/remove-team-member.use-case.ts | 103 ++++++++------ .../members/update-team-member.use-case.ts | 131 +++++++++++------- src/teams/domain/policy/team-member.policy.ts | 2 +- 12 files changed, 334 insertions(+), 242 deletions(-) rename src/teams/application/use-cases/invitions/{decline-invitation.use-case.ts => delete-invitation.use-case.ts} (61%) diff --git a/src/teams/application/controller/invitations/controller.ts b/src/teams/application/controller/invitations/controller.ts index 5cedc7e..9e062e9 100644 --- a/src/teams/application/controller/invitations/controller.ts +++ b/src/teams/application/controller/invitations/controller.ts @@ -69,6 +69,6 @@ export class TeamsInvitationsController { @Param('code') code: string, @GetUser() user: JwtPayload, ) { - return this.facade.declineInvitation(teamId, code, user.sub, user.email); + return this.facade.declineInvitation(teamId, code, user.sub); } } diff --git a/src/teams/application/team.facade.ts b/src/teams/application/team.facade.ts index c979f27..f0e8c3b 100644 --- a/src/teams/application/team.facade.ts +++ b/src/teams/application/team.facade.ts @@ -26,7 +26,7 @@ export class TeamsFacade { private readonly sendInviteUc: UC.SendInvitationUseCase, private readonly acceptInviteUc: UC.AcceptInvitationUseCase, private readonly updateInvitationUc: UC.UpdateInvitationUseCase, - private readonly declineInvitationUc: UC.DeclineInvitationUseCase, + private readonly deleteInvitationUc: UC.DeleteInvitationUseCase, private readonly getMyTeamsUc: UC.GetMyTeamsUseCase, private readonly getMyInvitesUc: UC.GetMyInvitesUseCase, @@ -63,8 +63,8 @@ export class TeamsFacade { public acceptInvite = (code: string, userId: string, email: string) => this.acceptInviteUc.execute(code, userId, email); - public declineInvitation = (teamId: string, code: string, userId: string, userEmail: string) => - this.declineInvitationUc.execute(teamId, code, userId, userEmail); + public declineInvitation = (teamId: string, code: string, userId: string) => + this.deleteInvitationUc.execute(teamId, code, userId); public updateInvitation = ( teamId: string, diff --git a/src/teams/application/use-cases/base/delete-team.use-case.ts b/src/teams/application/use-cases/base/delete-team.use-case.ts index c56459b..56e49bb 100644 --- a/src/teams/application/use-cases/base/delete-team.use-case.ts +++ b/src/teams/application/use-cases/base/delete-team.use-case.ts @@ -1,5 +1,8 @@ -import { ITeamsRepository } from '@core/teams/domain/repository'; +import { ITeamsRepository, RawMemberRow } from '@core/teams/domain/repository'; import { Inject, Injectable, HttpStatus } from '@nestjs/common'; +import { AbilityFactory } from '@shared/authorization/ability.factory'; +import { Action } from '@shared/authorization/types/action.enum'; +import { Subject } from '@shared/authorization/types/subject.enum'; import { BaseException } from '@shared/error'; @Injectable() @@ -7,36 +10,25 @@ export class DeleteTeamUseCase { constructor( @Inject('ITeamsRepository') private readonly teamsRepo: ITeamsRepository, + private readonly abilityFactory: AbilityFactory, ) {} async execute(teamId: string, userId: string) { - const team = await this.teamsRepo.findById(teamId); + const member = await this.teamsRepo.findMember(teamId, userId); - if (!team) { + if (!member) { throw new BaseException( { - code: 'TEAM_NOT_FOUND', - message: `Команда ${teamId} не найдена`, - }, - HttpStatus.NOT_FOUND, - ); - } - - const member = await this.teamsRepo.findMember(team.id, userId); - const isOwner = team.ownerId === userId || member?.role === 'owner'; - - if (!isOwner) { - throw new BaseException( - { - code: 'ONLY_OWNER_CAN_DELETE', - message: 'Только владелец может удалить команду', + code: 'TEAM_NOT_FOUND_OR_FORBIDDEN', + message: 'Команда не найдена или у вас нет к ней доступа', }, HttpStatus.FORBIDDEN, ); } + this.validateAccess(member); try { - const result = await this.teamsRepo.remove(team.id, userId); + const result = await this.teamsRepo.remove(teamId, userId); return { success: result, @@ -56,4 +48,19 @@ export class DeleteTeamUseCase { ); } } + + private validateAccess(member: RawMemberRow) { + const ability = this.abilityFactory.createForTeamMember(member); + const isAllow = ability.can(Action.DELETE, Subject.TEAM); + + if (!isAllow) { + throw new BaseException( + { + code: 'INSUFFICIENT_PERMISSIONS', + message: 'У вас недостаточно прав', + }, + HttpStatus.FORBIDDEN, + ); + } + } } diff --git a/src/teams/application/use-cases/base/update-team.use-case.ts b/src/teams/application/use-cases/base/update-team.use-case.ts index 53c78e0..068e436 100644 --- a/src/teams/application/use-cases/base/update-team.use-case.ts +++ b/src/teams/application/use-cases/base/update-team.use-case.ts @@ -1,7 +1,10 @@ import { Inject, Injectable, HttpStatus } from '@nestjs/common'; +import { AbilityFactory } from '@shared/authorization/ability.factory'; +import { Action } from '@shared/authorization/types/action.enum'; +import { Subject } from '@shared/authorization/types/subject.enum'; import { BaseException } from '@shared/error'; -import { ITeamsRepository } from '../../../domain/repository'; +import { ITeamsRepository, RawMemberRow } from '../../../domain/repository'; import { UpdateTeamDto } from '../../dtos'; @Injectable() @@ -9,37 +12,26 @@ export class UpdateTeamUseCase { constructor( @Inject('ITeamsRepository') private readonly teamsRepo: ITeamsRepository, + private readonly abilityFactory: AbilityFactory, ) {} async execute(teamId: string, userId: string, dto: UpdateTeamDto) { - const team = await this.teamsRepo.findById(teamId); + const member = await this.teamsRepo.findMember(teamId, userId); - if (!team?.id) { + if (!member) { throw new BaseException( { - code: 'TEAM_NOT_FOUND', - message: `Команда ${teamId} не найдена`, - }, - HttpStatus.NOT_FOUND, - ); - } - - const member = await this.teamsRepo.findMember(team.id, userId); - const canEdit = member?.role === 'admin' || member?.role === 'owner'; - - if (!canEdit) { - throw new BaseException( - { - code: 'INSUFFICIENT_PERMISSIONS', - message: 'У вас нет прав для редактирования этой команды', - details: [{ target: 'role', value: member?.role }], + code: 'TEAM_NOT_FOUND_OR_FORBIDDEN', + message: `У вас нет прав или команда не найдена`, }, HttpStatus.FORBIDDEN, ); } + this.validateAccess(member); + try { - const result = await this.teamsRepo.update(team.id, dto); + const result = await this.teamsRepo.update(teamId, dto); return { ...result, @@ -59,4 +51,19 @@ export class UpdateTeamUseCase { ); } } + + private validateAccess(member: RawMemberRow) { + const ability = this.abilityFactory.createForTeamMember(member); + const isAllow = ability.can(Action.UPDATE, Subject.TEAM); + + if (!isAllow) { + throw new BaseException( + { + code: 'INSUFFICIENT_PERMISSIONS', + message: 'У вас нет прав для редактирования этой команды', + }, + HttpStatus.FORBIDDEN, + ); + } + } } diff --git a/src/teams/application/use-cases/index.ts b/src/teams/application/use-cases/index.ts index ac91205..0cf70ae 100644 --- a/src/teams/application/use-cases/index.ts +++ b/src/teams/application/use-cases/index.ts @@ -4,7 +4,7 @@ import { FindTeamQuery } from './base/find-team.query'; import { GetMyTeamsUseCase } from './base/get-my-teams.use-case'; import { UpdateTeamUseCase } from './base/update-team.use-case'; import { AcceptInvitationUseCase } from './invitions/accept-invitation.use-case'; -import { DeclineInvitationUseCase } from './invitions/decline-invitation.use-case'; +import { DeleteInvitationUseCase } from './invitions/delete-invitation.use-case'; import { GetInvitationQuery } from './invitions/get-invitation.query'; import { GetInvitationsQuery } from './invitions/get-invitations.query'; import { GetMyInvitesUseCase } from './invitions/get-my-invites.use-case'; @@ -34,7 +34,7 @@ export const TeamUseCases = [ UpdateTeamUseCase, UpdateTeamMemberUseCase, UpdateInvitationUseCase, - DeclineInvitationUseCase, + DeleteInvitationUseCase, ]; export const TEAM_EXTERNAL_QUERIES = [FindTeamQuery, FindTeamMemberQuery]; @@ -56,4 +56,4 @@ export { SendInvitationUseCase } from './invitions/send-invitation.use-case'; export { UpdateTeamUseCase } from './base/update-team.use-case'; export { UpdateTeamMemberUseCase } from './members/update-team-member.use-case'; export { UpdateInvitationUseCase } from './invitions/update-invitation.use-case'; -export { DeclineInvitationUseCase } from './invitions/decline-invitation.use-case'; +export { DeleteInvitationUseCase } from './invitions/delete-invitation.use-case'; diff --git a/src/teams/application/use-cases/invitions/decline-invitation.use-case.ts b/src/teams/application/use-cases/invitions/delete-invitation.use-case.ts similarity index 61% rename from src/teams/application/use-cases/invitions/decline-invitation.use-case.ts rename to src/teams/application/use-cases/invitions/delete-invitation.use-case.ts index 4ded710..29c648c 100644 --- a/src/teams/application/use-cases/invitions/decline-invitation.use-case.ts +++ b/src/teams/application/use-cases/invitions/delete-invitation.use-case.ts @@ -1,13 +1,16 @@ -import { ITeamsRepository } from '@core/teams/domain/repository'; +import { ITeamsRepository, RawMemberRow } from '@core/teams/domain/repository'; import { HttpStatus, Inject, Injectable } from '@nestjs/common'; import { CACHE_SERVICE } from '@shared/adapters/cache/constants'; import { ICacheService } from '@shared/adapters/cache/ports'; +import { AbilityFactory } from '@shared/authorization/ability.factory'; +import { Action } from '@shared/authorization/types/action.enum'; +import { Subject } from '@shared/authorization/types/subject.enum'; import { BaseException } from '@shared/error'; import type { TeamInvite } from '../../dtos/invitation.dto'; @Injectable() -export class DeclineInvitationUseCase { +export class DeleteInvitationUseCase { private readonly INVITES_KEY = (code: string) => `inv:code:${code}`; private readonly TEAM_INVITES_KEY = (teamId: string) => `team:invites:${teamId}`; private readonly USER_INVITES_KEY = (email: string) => `user:invites:${email.toLowerCase()}`; @@ -15,17 +18,27 @@ export class DeclineInvitationUseCase { constructor( @Inject('ITeamsRepository') private readonly teamsRepo: ITeamsRepository, @Inject(CACHE_SERVICE) private readonly cacheService: ICacheService, + private readonly abilityFactory: AbilityFactory, ) {} - async execute(teamId: string, code: string, userId: string, userEmail: string) { - const team = await this.getTeamOrThrow(teamId); + async execute(teamId: string, code: string, userId: string) { const invite = await this.getInviteOrThrow(code); + this.validateInviteOwnership(invite, teamId); - this.validateInviteOwnership(invite, team.id); + const member = await this.teamsRepo.findMember(teamId, userId); + if (!member) { + throw new BaseException( + { + code: 'TEAM_NOT_FOUND_OR_FORBIDDEN', + message: `У вас нет прав или команда не найдена`, + }, + HttpStatus.FORBIDDEN, + ); + } - await this.validateAccess(team.id, userId, userEmail, invite.email); + this.validateAccess(member); - await this.cleanupInvite(code, team.id, invite.email); + await this.cleanupInvite(code, teamId, invite.email); return { success: true, @@ -33,39 +46,19 @@ export class DeclineInvitationUseCase { }; } - private async validateAccess( - teamId: string, - userId: string, - currentUserEmail: string, - inviteEmail: string, - ) { - if (currentUserEmail.toLowerCase() === inviteEmail.toLowerCase()) { - return; - } + private validateAccess(member: RawMemberRow) { + const ability = this.abilityFactory.createForTeamMember(member); + const isAllow = ability.can(Action.DELETE, Subject.INVITE); - const member = await this.teamsRepo.findMember(teamId, userId); - if (member && (member.role === 'owner' || member.role === 'admin')) { - return; - } - - throw new BaseException( - { - code: 'INSUFFICIENT_PERMISSIONS', - message: 'У вас нет прав для отмены этого приглашения', - }, - HttpStatus.FORBIDDEN, - ); - } - - private async getTeamOrThrow(teamId: string) { - const team = await this.teamsRepo.findById(teamId); - if (!team) { + if (!isAllow) { throw new BaseException( - { code: 'TEAM_NOT_FOUND', message: 'Команда не найдена' }, - HttpStatus.NOT_FOUND, + { + code: 'INSUFFICIENT_PERMISSIONS', + message: 'У вас нет прав для удаления приглашений', + }, + HttpStatus.FORBIDDEN, ); } - return team; } private async getInviteOrThrow(code: string) { diff --git a/src/teams/application/use-cases/invitions/get-invitations.query.ts b/src/teams/application/use-cases/invitions/get-invitations.query.ts index 66b9b7e..e3971cf 100644 --- a/src/teams/application/use-cases/invitions/get-invitations.query.ts +++ b/src/teams/application/use-cases/invitions/get-invitations.query.ts @@ -1,7 +1,10 @@ -import { ITeamsRepository } from '@core/teams/domain/repository'; +import { ITeamsRepository, RawMemberRow } from '@core/teams/domain/repository'; import { HttpStatus, Inject, Injectable } from '@nestjs/common'; import { CACHE_SERVICE } from '@shared/adapters/cache/constants'; import { ICacheService } from '@shared/adapters/cache/ports'; +import { AbilityFactory } from '@shared/authorization/ability.factory'; +import { Action } from '@shared/authorization/types/action.enum'; +import { Subject } from '@shared/authorization/types/subject.enum'; import { BaseException } from '@shared/error'; @Injectable() @@ -12,14 +15,26 @@ export class GetInvitationsQuery { constructor( @Inject('ITeamsRepository') private readonly teamsRepo: ITeamsRepository, @Inject(CACHE_SERVICE) private readonly cacheService: ICacheService, + private readonly abilityFactory: AbilityFactory, ) {} async execute(teamId: string, userId: string) { - const team = await this.getTeamOrThrow(teamId); - await this.ensureAdminPermissions(team.id, userId); + const member = await this.teamsRepo.findMember(teamId, userId); + if (!member) { + throw new BaseException( + { + code: 'TEAM_NOT_FOUND_OR_FORBIDDEN', + message: `У вас нет прав или команда не найдена`, + }, + HttpStatus.FORBIDDEN, + ); + } + + this.validateAccess(member); - const teamKey = this.TEAM_INVITES_KEY(team.id); + const teamKey = this.TEAM_INVITES_KEY(teamId); const codes = await this.cacheService.getCollection(teamKey); + if (!codes.length) { return { // TODO: реализовать полноценную пагинацию для инвайтов команды. @@ -28,7 +43,7 @@ export class GetInvitationsQuery { total: 0, totalPages: 0, page: 1, - limit: 0, + limit: 10, hasPrevPage: false, hasNextPage: false, }, @@ -67,27 +82,18 @@ export class GetInvitationsQuery { total: active.length, totalPages: active.length ? 1 : 0, page: 1, - limit: active.length, + limit: 10, hasPrevPage: false, hasNextPage: false, }, }; } - private async getTeamOrThrow(teamId: string) { - const team = await this.teamsRepo.findById(teamId); - if (!team) { - throw new BaseException( - { code: 'TEAM_NOT_FOUND', message: 'Команда не найдена' }, - HttpStatus.NOT_FOUND, - ); - } - return team; - } + private validateAccess(member: RawMemberRow) { + const ability = this.abilityFactory.createForTeamMember(member); + const isAllow = ability.can(Action.READ, Subject.INVITE); - private async ensureAdminPermissions(teamId: string, userId: string) { - const member = await this.teamsRepo.findMember(teamId, userId); - if (!member || (member.role !== 'owner' && member.role !== 'admin')) { + if (!isAllow) { throw new BaseException( { code: 'INSUFFICIENT_PERMISSIONS', message: 'У вас нет прав' }, HttpStatus.FORBIDDEN, diff --git a/src/teams/application/use-cases/invitions/send-invitation.use-case.ts b/src/teams/application/use-cases/invitions/send-invitation.use-case.ts index 875f319..aa2e7a1 100644 --- a/src/teams/application/use-cases/invitions/send-invitation.use-case.ts +++ b/src/teams/application/use-cases/invitions/send-invitation.use-case.ts @@ -1,12 +1,16 @@ +import { subject } from '@casl/ability'; import { TeamMailJobs, TeamQueues } from '@core/teams/domain/enums'; import { TeamInvitationEvent } from '@core/teams/domain/events'; -import { TeamMemberPolicy } from '@core/teams/domain/policy'; -import { ITeamsRepository } from '@core/teams/domain/repository'; +import { ITeamsRepository, RawMemberRow } from '@core/teams/domain/repository'; import { InjectQueue } from '@nestjs/bullmq'; import { HttpStatus, Inject, Injectable } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { CACHE_SERVICE } from '@shared/adapters/cache/constants'; import { ICacheService } from '@shared/adapters/cache/ports'; +import { AbilityFactory } from '@shared/authorization/ability.factory'; +import { Action } from '@shared/authorization/types/action.enum'; +import { Subject } from '@shared/authorization/types/subject.enum'; +import { ROLE_PRIORITY } from '@shared/constants'; import { BaseException } from '@shared/error'; import { ImageHelper } from '@shared/utils'; import { Queue } from 'bullmq'; @@ -28,16 +32,17 @@ export class SendInvitationUseCase { @Inject(CACHE_SERVICE) private readonly cacheService: ICacheService, @InjectQueue(TeamQueues.TEAM_MAIL) private readonly mailQueue: Queue, private readonly cfg: ConfigService, - private readonly policy: TeamMemberPolicy, + private readonly abilityFactory: AbilityFactory, ) {} async execute(teamId: string, inviterId: string, dto: InviteMemberDto) { const team = await this.getTeamOrThrow(teamId); - const inviter = await this.getInviterOrThrow(team.id, inviterId); + const inviter = await this.getInviterOrThrow(teamId, inviterId); - this.validatePermissions(inviter.role as TeamRole, dto.role as TeamRole); - await this.ensureNotAlreadyMember(team.id, dto.email); - await this.ensureNoPendingInvite(team.id, dto.email); + this.validateAccess(inviter, dto.role); + + await this.ensureNotAlreadyMember(teamId, dto.email); + await this.ensureNoPendingInvite(teamId, dto.email); const code = generateSecret({ length: 8 }); const inviteData = this.buildInviteData(team, inviter, dto); @@ -71,10 +76,30 @@ export class SendInvitationUseCase { return inviter; } - private validatePermissions(inviterRole: TeamRole, targetRole: TeamRole) { - if (!this.policy.canInvite(inviterRole, targetRole || 'member')) { + private validateAccess(member: RawMemberRow, targetRole: TeamRole) { + const ability = this.abilityFactory.createForTeamMember(member); + const canInvite = ability.can(Action.CREATE, Subject.INVITE); + const canAssignRole = ability.can( + Action.CREATE, + subject(Subject.ROLE, { priority: ROLE_PRIORITY[targetRole] }), + ); + + if (!canInvite) { + throw new BaseException( + { + code: 'INSUFFICIENT_PERMISSIONS', + message: 'Недостаточно прав чтобы приглашать пользователей в команду', + }, + HttpStatus.FORBIDDEN, + ); + } + + if (!canAssignRole) { throw new BaseException( - { code: 'INSUFFICIENT_PERMISSIONS', message: 'Недостаточно прав' }, + { + code: 'INSUFFICIENT_PERMISSIONS', + message: `Недостаточно прав чтобы пригласить пользователя в команду с ролью ${targetRole}`, + }, HttpStatus.FORBIDDEN, ); } diff --git a/src/teams/application/use-cases/invitions/update-invitation.use-case.ts b/src/teams/application/use-cases/invitions/update-invitation.use-case.ts index aeaebdd..3187d93 100644 --- a/src/teams/application/use-cases/invitions/update-invitation.use-case.ts +++ b/src/teams/application/use-cases/invitions/update-invitation.use-case.ts @@ -1,15 +1,18 @@ -import { TeamMemberPolicy } from '@core/teams/domain/policy'; -import { ITeamsRepository } from '@core/teams/domain/repository'; +import { subject } from '@casl/ability'; +import { ITeamsRepository, RawMemberRow } from '@core/teams/domain/repository'; import { HttpStatus, Inject, Injectable } from '@nestjs/common'; import { CACHE_SERVICE } from '@shared/adapters/cache/constants'; import { ICacheService } from '@shared/adapters/cache/ports'; +import { AbilityFactory } from '@shared/authorization/ability.factory'; +import { Action } from '@shared/authorization/types/action.enum'; +import { Subject } from '@shared/authorization/types/subject.enum'; +import { ROLE_PRIORITY } from '@shared/constants'; +import { TeamRole } from '@shared/entities'; import { BaseException } from '@shared/error'; import { UpdateInvitationDto } from '../../dtos'; import { TeamInvite } from '../../dtos/invitation.dto'; -import type { TeamRole } from '../../../infrastructure/persistence/models'; - @Injectable() export class UpdateInvitationUseCase { private readonly INVITES_KEY = (code: string) => `inv:code:${code}`; @@ -17,22 +20,31 @@ export class UpdateInvitationUseCase { constructor( @Inject('ITeamsRepository') private readonly teamsRepo: ITeamsRepository, @Inject(CACHE_SERVICE) private readonly cacheService: ICacheService, - private readonly policy: TeamMemberPolicy, + private readonly abilityFactory: AbilityFactory, ) {} async execute(teamId: string, code: string, userId: string, dto: UpdateInvitationDto) { - const team = await this.getTeamOrThrow(teamId); - const member = await this.getMemberOrThrow(team.id, userId); - const key = this.INVITES_KEY(code); + const { invite, ttlSeconds } = await this.getInviteContextOrThrow(key); + this.validateInviteOwnership(invite, teamId); + + const member = await this.teamsRepo.findMember(teamId, userId); + if (!member) { + throw new BaseException( + { + code: 'TEAM_NOT_FOUND_OR_FORBIDDEN', + message: `У вас нет прав или команда не найдена`, + }, + HttpStatus.FORBIDDEN, + ); + } - this.validateInviteOwnership(invite, team.id); - this.validatePolicy(member.role as TeamRole, invite.role as TeamRole, dto.role as TeamRole); + this.validateAccess(member, dto.role); const updatedInvite = { ...invite, - role: dto.role as TeamRole, + role: dto.role, }; await this.cacheService.setOne(key, JSON.stringify(updatedInvite), ttlSeconds); @@ -43,26 +55,34 @@ export class UpdateInvitationUseCase { }; } - private async getTeamOrThrow(teamId: string) { - const team = await this.teamsRepo.findById(teamId); - if (!team) { + private validateAccess(member: RawMemberRow, targetRole: TeamRole) { + const ability = this.abilityFactory.createForTeamMember(member); + const canUpdateInvites = ability.can(Action.UPDATE, Subject.INVITE); + + if (!canUpdateInvites) { throw new BaseException( - { code: 'TEAM_NOT_FOUND', message: 'Команда не найдена' }, - HttpStatus.NOT_FOUND, + { + code: 'INSUFFICIENT_PERMISSIONS', + message: 'У вас недостаточно прав для обновления приглашений', + }, + HttpStatus.FORBIDDEN, ); } - return team; - } - private async getMemberOrThrow(teamId: string, userId: string) { - const member = await this.teamsRepo.findMember(teamId, userId); - if (!member) { + const canAssignCurrentRole = ability.can( + Action.UPDATE, + subject(Subject.ROLE, { priority: ROLE_PRIORITY[targetRole] }), + ); + + if (!canAssignCurrentRole) { throw new BaseException( - { code: 'NOT_A_MEMBER', message: 'Вы не член команды' }, + { + code: 'INSUFFICIENT_PERMISSIONS', + message: `У вас недостаточно прав чтобы назначить роль ${targetRole}`, + }, HttpStatus.FORBIDDEN, ); } - return member; } private async getInviteContextOrThrow(key: string) { @@ -89,18 +109,4 @@ export class UpdateInvitationUseCase { ); } } - - private validatePolicy(issuerRole: TeamRole, currentTargetRole: TeamRole, newRole: TeamRole) { - const canUpdate = this.policy.canAssignRole(issuerRole, currentTargetRole, newRole); - - if (!canUpdate) { - throw new BaseException( - { - code: 'INSUFFICIENT_PERMISSIONS', - message: 'У вас недостаточно прав для назначения этой роли', - }, - HttpStatus.FORBIDDEN, - ); - } - } } diff --git a/src/teams/application/use-cases/members/remove-team-member.use-case.ts b/src/teams/application/use-cases/members/remove-team-member.use-case.ts index d5a875a..cc4a4be 100644 --- a/src/teams/application/use-cases/members/remove-team-member.use-case.ts +++ b/src/teams/application/use-cases/members/remove-team-member.use-case.ts @@ -1,6 +1,10 @@ -import { TeamMemberPolicy } from '@core/teams/domain/policy'; -import { ITeamsRepository } from '@core/teams/domain/repository'; +import { subject } from '@casl/ability'; +import { ITeamsRepository, RawMemberRow } from '@core/teams/domain/repository'; import { HttpStatus, Inject, Injectable } from '@nestjs/common'; +import { AbilityFactory } from '@shared/authorization/ability.factory'; +import { Action } from '@shared/authorization/types/action.enum'; +import { Subject } from '@shared/authorization/types/subject.enum'; +import { ROLE_PRIORITY } from '@shared/constants'; import { BaseException } from '@shared/error'; import type { TeamRole } from '@shared/entities'; @@ -10,22 +14,25 @@ export class RemoveTeamMemberUseCase { constructor( @Inject('ITeamsRepository') private readonly teamsRepo: ITeamsRepository, - private readonly policy: TeamMemberPolicy, + private readonly abilityFactory: AbilityFactory, ) {} async execute(teamId: string, currentUserId: string, targetUserId: string) { - const team = await this.teamsRepo.findById(teamId); - if (!team) { + //TODO: move to policy + this.isSelfRemoval(currentUserId, targetUserId); + + const member = await this.teamsRepo.findMember(teamId, currentUserId); + if (!member) { throw new BaseException( - { code: 'TEAM_NOT_FOUND', message: `Команда ${teamId} не найдена` }, - HttpStatus.NOT_FOUND, + { + code: 'TEAM_NOT_FOUND_OR_FORBIDDEN', + message: `У вас нет прав или команда не найдена`, + }, + HttpStatus.FORBIDDEN, ); } - const [currentUser, targetUser] = await Promise.all([ - this.teamsRepo.findMember(team.id, currentUserId), - this.teamsRepo.findMember(team.id, targetUserId), - ]); + const targetUser = await this.teamsRepo.findMember(teamId, targetUserId); if (!targetUser) { throw new BaseException( @@ -34,49 +41,59 @@ export class RemoveTeamMemberUseCase { ); } - if (!currentUser) { - throw new BaseException( - { code: 'NOT_A_TEAM_MEMBER', message: 'Вы не состоите в этой команде' }, - HttpStatus.FORBIDDEN, - ); - } + this.validateAccess(member, targetUser.role); + + try { + const success = await this.teamsRepo.removeMember(teamId, targetUserId); + if (!success) { + this.errorDuringRemoving(); + } - const isSelfRemoval = currentUserId === targetUserId; + return { + success: true, + message: `Участник успешно исключен из команды`, + }; + } catch (error) { + if (error instanceof BaseException) { + throw error; + } - const canRemove = this.policy.canRemove( - currentUser.role as TeamRole, - targetUser.role as TeamRole, - isSelfRemoval, + return this.errorDuringRemoving(); + } + } + + private errorDuringRemoving() { + throw new BaseException( + { code: 'MEMBER_REMOVAL_FAILED', message: 'Ошибка при удалении участника' }, + HttpStatus.INTERNAL_SERVER_ERROR, ); + } - if (!canRemove) { - const errorCode = isSelfRemoval ? 'OWNER_CANNOT_LEAVE' : 'KICK_FORBIDDEN'; - const errorMessage = isSelfRemoval - ? 'Владелец не может покинуть команду без передачи прав' - : 'У вас недостаточно прав, чтобы исключить этого участника'; + private isSelfRemoval(currentUserId: string, targetUserId: string) { + const isSelf = currentUserId === targetUserId; + if (isSelf) { throw new BaseException( - { code: errorCode, message: errorMessage }, - HttpStatus.FORBIDDEN, + { code: 'INSUFFICIENT_PERMISSIONS', message: 'Вы не можете удалить самого себя' }, + HttpStatus.BAD_REQUEST, ); } + } - try { - const result = await this.teamsRepo.removeMember(team.id, targetUserId); - return { - success: result, - message: isSelfRemoval - ? `Вы успешно покинули команду ${team.name}` - : `Участник успешно исключен из команды ${team.name}`, - }; - } catch (error) { - if (error instanceof BaseException) { - throw error; - } + private validateAccess(member: RawMemberRow, targetUserRole: TeamRole) { + const ability = this.abilityFactory.createForTeamMember(member); + const isAllow = ability.can( + Action.DELETE, + subject(Subject.TEAM_MEMBER, { priority: ROLE_PRIORITY[targetUserRole] }), + ); + if (!isAllow) { throw new BaseException( - { code: 'MEMBER_REMOVAL_FAILED', message: 'Ошибка при удалении участника' }, - HttpStatus.INTERNAL_SERVER_ERROR, + { + code: 'INSUFFICIENT_PERMISSIONS', + message: 'У вас нет прав для удаления этого участника команды', + }, + HttpStatus.FORBIDDEN, ); } } diff --git a/src/teams/application/use-cases/members/update-team-member.use-case.ts b/src/teams/application/use-cases/members/update-team-member.use-case.ts index 9b8eb5c..be27422 100644 --- a/src/teams/application/use-cases/members/update-team-member.use-case.ts +++ b/src/teams/application/use-cases/members/update-team-member.use-case.ts @@ -1,6 +1,10 @@ -import { TeamMemberPolicy } from '@core/teams/domain/policy'; -import { ITeamsRepository } from '@core/teams/domain/repository'; +import { subject } from '@casl/ability'; +import { ITeamsRepository, RawMemberRow } from '@core/teams/domain/repository'; import { HttpStatus, Inject, Injectable } from '@nestjs/common'; +import { AbilityFactory } from '@shared/authorization/ability.factory'; +import { Action } from '@shared/authorization/types/action.enum'; +import { Subject } from '@shared/authorization/types/subject.enum'; +import { ROLE_PRIORITY } from '@shared/constants'; import { TeamRole } from '@shared/entities'; import { BaseException } from '@shared/error'; @@ -11,7 +15,7 @@ export class UpdateTeamMemberUseCase { constructor( @Inject('ITeamsRepository') private readonly teamsRepo: ITeamsRepository, - private readonly teamMemberPolicy: TeamMemberPolicy, + private readonly abilityFactory: AbilityFactory, ) {} async execute( @@ -20,25 +24,21 @@ export class UpdateTeamMemberUseCase { targetUserId: string, dto: UpdateMemberDto, ) { - if (currentUserId === targetUserId) { - throw new BaseException( - { code: 'SELF_EDIT_RESTRICTED', message: 'Вы не можете редактировать свои данные' }, - HttpStatus.BAD_REQUEST, - ); - } + //TODO: move to policy + this.isSelfRemoval(currentUserId, targetUserId); - const team = await this.teamsRepo.findById(teamId); - if (!team) { + const member = await this.teamsRepo.findMember(teamId, currentUserId); + if (!member) { throw new BaseException( - { code: 'TEAM_NOT_FOUND', message: `Команда ${teamId} не найдена` }, - HttpStatus.NOT_FOUND, + { + code: 'TEAM_NOT_FOUND_OR_FORBIDDEN', + message: `У вас нет прав или команда не найдена`, + }, + HttpStatus.FORBIDDEN, ); } - const [currentUser, targetUser] = await Promise.all([ - this.teamsRepo.findMember(team.id, currentUserId), - this.teamsRepo.findMember(team.id, targetUserId), - ]); + const targetUser = await this.teamsRepo.findMember(teamId, targetUserId); if (!targetUser) { throw new BaseException( @@ -47,61 +47,92 @@ export class UpdateTeamMemberUseCase { ); } - if (!currentUser) { - throw new BaseException( - { code: 'NOT_A_TEAM_MEMBER', message: 'Вы не состоите в этой команде' }, - HttpStatus.FORBIDDEN, - ); - } + this.validateAccess(member, targetUser.role, dto); - const issuerRole = currentUser.role as TeamRole; - const targetRole = targetUser.role as TeamRole; + try { + const result = await this.teamsRepo.updateMember(teamId, targetUserId, dto); + return { + success: result, + message: `Данные участника команды успешно обновлены`, + }; + } catch (error) { + if (error instanceof BaseException) { + throw error; + } - if (!this.teamMemberPolicy.canManage(issuerRole, targetRole)) { throw new BaseException( - { code: 'INSUFFICIENT_RANK', message: 'Ваш ранг должен быть выше ранга цели' }, - HttpStatus.FORBIDDEN, + { + code: 'MEMBER_UPDATE_FAILED', + message: 'Ошибка при обновлении данных участника', + }, + HttpStatus.INTERNAL_SERVER_ERROR, ); } + } - if (dto.role && !this.teamMemberPolicy.canAssignRole(issuerRole, targetRole, dto.role)) { + private isSelfRemoval(currentUserId: string, targetUserId: string) { + const isSelf = currentUserId === targetUserId; + + if (isSelf) { throw new BaseException( { - code: 'INVALID_ROLE_ASSIGNMENT', - message: 'У вас нет прав назначить выбранную роль', + code: 'SELF_EDIT_RESTRICTED', + message: 'Вы не можете редактировать свои данные', }, - HttpStatus.FORBIDDEN, + HttpStatus.BAD_REQUEST, ); } + } + + private validateAccess(member: RawMemberRow, targetUserRole: TeamRole, dto: UpdateMemberDto) { + const ability = this.abilityFactory.createForTeamMember(member); + const canUpdate = ability.can( + Action.UPDATE, + subject(Subject.TEAM_MEMBER, { priority: ROLE_PRIORITY[targetUserRole] }), + ); - if (dto.status && !this.teamMemberPolicy.canChangeStatus(issuerRole, targetRole)) { + if (!canUpdate) { throw new BaseException( { - code: 'INVALID_STATUS_CHANGE', - message: 'Вы не можете менять статус этого участника', + code: 'INSUFFICIENT_PERMISSIONS', + message: 'У вас нет прав на управление этим участником', }, HttpStatus.FORBIDDEN, ); } - try { - const result = await this.teamsRepo.updateMember(team.id, targetUserId, dto); - return { - success: result, - message: `Данные участника команды ${team.name} успешно обновлены`, - }; - } catch (error) { - if (error instanceof BaseException) { - throw error; + if (dto.role) { + const canAssignCurrentRole = ability.can( + Action.UPDATE, + subject(Subject.ROLE, { priority: ROLE_PRIORITY[dto.role] }), + ); + + if (!canAssignCurrentRole) { + throw new BaseException( + { + code: 'INSUFFICIENT_PERMISSIONS', + message: `У вас нет прав назначить роль ${dto.role}`, + }, + HttpStatus.FORBIDDEN, + ); } + } - throw new BaseException( - { - code: 'MEMBER_UPDATE_FAILED', - message: 'Ошибка при обновлении данных участника', - }, - HttpStatus.INTERNAL_SERVER_ERROR, + if (dto.status) { + const canAssignStatus = ability.can( + Action.UPDATE, + subject(Subject.STATUS, { priority: ROLE_PRIORITY[targetUserRole] }), ); + + if (!canAssignStatus) { + throw new BaseException( + { + code: 'INSUFFICIENT_PERMISSIONS', + message: `У вас нет прав назначить статус этому участнику`, + }, + HttpStatus.FORBIDDEN, + ); + } } } } diff --git a/src/teams/domain/policy/team-member.policy.ts b/src/teams/domain/policy/team-member.policy.ts index 2a726e2..655800a 100644 --- a/src/teams/domain/policy/team-member.policy.ts +++ b/src/teams/domain/policy/team-member.policy.ts @@ -115,6 +115,6 @@ export class TeamMemberPolicy { * const canUpdate = policy.canUpdateMedia('admin'); // true */ public canUpdateMedia(issuerRole: TeamRole): boolean { - return this.getPriority(issuerRole) >= (ROLE_PRIORITY['moderator'] ?? 2); + return this.getPriority(issuerRole) >= (ROLE_PRIORITY['admin'] ?? 2); } } From 6eadde797b2fa197793bc8ec18f0c21df466d9be Mon Sep 17 00:00:00 2001 From: Maxim Date: Thu, 18 Jun 2026 20:37:31 +0300 Subject: [PATCH 4/5] refactor(teams): rename TEAMS to TEAM --- infra/k6/package.json | 8 +- ...ams-invitations.js => team-invitations.js} | 0 .../k6/scenarios/{teams-me.js => team-me.js} | 0 .../{teams-members.js => team-members.js} | 0 infra/k6/scenarios/{teams.js => team.js} | 0 migrations/0006_opposite_pet_avengers.sql | 6 + migrations/meta/0006_snapshot.json | 2066 +++++++++++++++++ migrations/meta/_journal.json | 7 + package.json | 8 +- src/app.module.ts | 4 +- src/auth/auth.module.ts | 5 +- .../infrastructure/workers/user.processor.ts | 3 +- .../application/mappers/project.mapper.ts | 2 +- .../use-cases/member/add.use-case.ts | 2 +- .../use-cases/project/find-one.query.ts | 3 +- .../domain/policy/project-access.policy.ts | 2 +- .../persistence/models/project.model.ts | 4 +- src/project/project.module.ts | 5 +- src/shared/authorization/ability.factory.ts | 3 +- .../authorization/authorization.spec.ts | 4 +- .../permissions/permissions-map.ts | 2 +- src/shared/constants/roles.constant.ts | 2 +- src/shared/entities/index.ts | 2 +- src/team/application/controllers/index.ts | 4 + .../controllers}/invitations/controller.ts | 6 +- .../controllers}/invitations/swagger.ts | 0 .../application/controllers}/me/controller.ts | 4 +- .../application/controllers}/me/swagger.ts | 0 .../controllers}/members/controller.ts | 6 +- .../controllers}/members/swagger.ts | 0 .../controllers/team}/controller.ts | 6 +- .../application/controllers/team}/swagger.ts | 2 +- src/{teams => team}/application/dtos/index.ts | 0 .../application/dtos/invitation.dto.ts | 0 .../application/dtos/member.dto.ts | 3 +- .../application/dtos/team.dto.ts | 0 .../application/mappers/index.ts | 0 .../application/mappers/member.mapper.ts | 0 .../application/team.facade.ts | 2 +- .../use-cases/base/create-team.use-case.ts | 8 +- .../use-cases/base/delete-team.use-case.ts | 11 +- .../use-cases/base/find-team.query.ts | 6 +- .../use-cases/base/get-my-teams.use-case.ts | 11 +- .../use-cases/base/update-team.use-case.ts | 10 +- .../application/use-cases/index.ts | 0 .../invitions/accept-invitation.use-case.ts | 9 +- .../invitions/delete-invitation.use-case.ts | 7 +- .../invitions/get-invitation.query.ts | 9 +- .../invitions/get-invitations.query.ts | 7 +- .../invitions/get-my-invites.use-case.ts | 3 +- .../invitions/send-invitation.use-case.ts | 14 +- .../invitions/update-invitation.use-case.ts | 6 +- .../members/find-team-member.query.ts | 6 +- .../members/get-team-members.query.ts | 13 +- .../members/remove-team-member.use-case.ts | 13 +- .../members/update-team-member.use-case.ts | 12 +- src/team/domain/entities/index.ts | 1 + .../domain/entities/team.domain.ts} | 6 +- src/{teams => team}/domain/enums/index.ts | 0 .../domain/enums/mail-jobs.enum.ts | 0 src/{teams => team}/domain/events/index.ts | 0 .../domain/events/team-invitation.event.ts | 0 src/{teams => team}/domain/policy/index.ts | 0 .../domain/policy/team-member.policy.ts | 0 src/team/domain/repository/index.ts | 5 + .../repository/team.repository.interface.ts} | 4 +- src/{teams => team}/index.ts | 2 +- .../infrastructure/listeners/index.ts | 0 .../listeners/update-media.listener.ts | 9 +- .../persistence/models/enums.ts | 0 .../persistence/models/index.ts | 2 +- .../persistence/models/team.model.ts} | 4 +- .../persistence/repositories/index.ts | 1 + .../repositories/team.repository.ts} | 42 +- .../infrastructure/workers/index.ts | 0 .../infrastructure/workers/mail.processor.ts | 5 +- .../teams.module.ts => team/team.module.ts} | 27 +- src/teams/application/controller/index.ts | 4 - src/teams/domain/entities/index.ts | 1 - src/teams/domain/repository/index.ts | 5 - .../persistence/repositories/index.ts | 1 - 81 files changed, 2263 insertions(+), 172 deletions(-) rename infra/k6/scenarios/{teams-invitations.js => team-invitations.js} (100%) rename infra/k6/scenarios/{teams-me.js => team-me.js} (100%) rename infra/k6/scenarios/{teams-members.js => team-members.js} (100%) rename infra/k6/scenarios/{teams.js => team.js} (100%) create mode 100644 migrations/0006_opposite_pet_avengers.sql create mode 100644 migrations/meta/0006_snapshot.json create mode 100644 src/team/application/controllers/index.ts rename src/{teams/application/controller => team/application/controllers}/invitations/controller.ts (93%) rename src/{teams/application/controller => team/application/controllers}/invitations/swagger.ts (100%) rename src/{teams/application/controller => team/application/controllers}/me/controller.ts (85%) rename src/{teams/application/controller => team/application/controllers}/me/swagger.ts (100%) rename src/{teams/application/controller => team/application/controllers}/members/controller.ts (88%) rename src/{teams/application/controller => team/application/controllers}/members/swagger.ts (100%) rename src/{teams/application/controller/teams => team/application/controllers/team}/controller.ts (89%) rename src/{teams/application/controller/teams => team/application/controllers/team}/swagger.ts (97%) rename src/{teams => team}/application/dtos/index.ts (100%) rename src/{teams => team}/application/dtos/invitation.dto.ts (100%) rename src/{teams => team}/application/dtos/member.dto.ts (97%) rename src/{teams => team}/application/dtos/team.dto.ts (100%) rename src/{teams => team}/application/mappers/index.ts (100%) rename src/{teams => team}/application/mappers/member.mapper.ts (100%) rename src/{teams => team}/application/team.facade.ts (99%) rename src/{teams => team}/application/use-cases/base/create-team.use-case.ts (78%) rename src/{teams => team}/application/use-cases/base/delete-team.use-case.ts (85%) rename src/{teams => team}/application/use-cases/base/find-team.query.ts (62%) rename src/{teams => team}/application/use-cases/base/get-my-teams.use-case.ts (70%) rename src/{teams => team}/application/use-cases/base/update-team.use-case.ts (86%) rename src/{teams => team}/application/use-cases/index.ts (100%) rename src/{teams => team}/application/use-cases/invitions/accept-invitation.use-case.ts (92%) rename src/{teams => team}/application/use-cases/invitions/delete-invitation.use-case.ts (93%) rename src/{teams => team}/application/use-cases/invitions/get-invitation.query.ts (89%) rename src/{teams => team}/application/use-cases/invitions/get-invitations.query.ts (93%) rename src/{teams => team}/application/use-cases/invitions/get-my-invites.use-case.ts (97%) rename src/{teams => team}/application/use-cases/invitions/send-invitation.use-case.ts (92%) rename src/{teams => team}/application/use-cases/invitions/update-invitation.use-case.ts (94%) rename src/{teams => team}/application/use-cases/members/find-team-member.query.ts (61%) rename src/{teams => team}/application/use-cases/members/get-team-members.query.ts (80%) rename src/{teams => team}/application/use-cases/members/remove-team-member.use-case.ts (88%) rename src/{teams => team}/application/use-cases/members/update-team-member.use-case.ts (91%) create mode 100644 src/team/domain/entities/index.ts rename src/{teams/domain/entities/teams.domain.ts => team/domain/entities/team.domain.ts} (60%) rename src/{teams => team}/domain/enums/index.ts (100%) rename src/{teams => team}/domain/enums/mail-jobs.enum.ts (100%) rename src/{teams => team}/domain/events/index.ts (100%) rename src/{teams => team}/domain/events/team-invitation.event.ts (100%) rename src/{teams => team}/domain/policy/index.ts (100%) rename src/{teams => team}/domain/policy/team-member.policy.ts (100%) create mode 100644 src/team/domain/repository/index.ts rename src/{teams/domain/repository/teams.repository.interface.ts => team/domain/repository/team.repository.interface.ts} (92%) rename src/{teams => team}/index.ts (62%) rename src/{teams => team}/infrastructure/listeners/index.ts (100%) rename src/{teams => team}/infrastructure/listeners/update-media.listener.ts (92%) rename src/{teams => team}/infrastructure/persistence/models/enums.ts (100%) rename src/{teams => team}/infrastructure/persistence/models/index.ts (62%) rename src/{teams/infrastructure/persistence/models/teams.model.ts => team/infrastructure/persistence/models/team.model.ts} (94%) create mode 100644 src/team/infrastructure/persistence/repositories/index.ts rename src/{teams/infrastructure/persistence/repositories/teams.repository.ts => team/infrastructure/persistence/repositories/team.repository.ts} (82%) rename src/{teams => team}/infrastructure/workers/index.ts (100%) rename src/{teams => team}/infrastructure/workers/mail.processor.ts (92%) rename src/{teams/teams.module.ts => team/team.module.ts} (56%) delete mode 100644 src/teams/application/controller/index.ts delete mode 100644 src/teams/domain/entities/index.ts delete mode 100644 src/teams/domain/repository/index.ts delete mode 100644 src/teams/infrastructure/persistence/repositories/index.ts diff --git a/infra/k6/package.json b/infra/k6/package.json index cd1384a..9f4ae9f 100644 --- a/infra/k6/package.json +++ b/infra/k6/package.json @@ -5,10 +5,10 @@ "scripts": { "test:all": "k6 run scenarios/stress-full.js", "test:auth": "k6 run scenarios/auth.js", - "test:teams": "k6 run scenarios/teams.js", - "test:teams-members": "k6 run scenarios/teams-members.js", - "test:teams-invitations": "k6 run scenarios/teams-invitations.js", - "test:teams-me": "k6 run scenarios/teams-me.js", + "test:team": "k6 run scenarios/team.js", + "test:team-members": "k6 run scenarios/team-members.js", + "test:team-invitations": "k6 run scenarios/team-invitations.js", + "test:team-me": "k6 run scenarios/team-me.js", "test:projects": "k6 run scenarios/projects.js", "test:users": "k6 run scenarios/users.js", "test:boards:all": "k6 run scenarios/boards.js && k6 run scenarios/boards-columns.js && k6 run scenarios/boards-views.js", diff --git a/infra/k6/scenarios/teams-invitations.js b/infra/k6/scenarios/team-invitations.js similarity index 100% rename from infra/k6/scenarios/teams-invitations.js rename to infra/k6/scenarios/team-invitations.js diff --git a/infra/k6/scenarios/teams-me.js b/infra/k6/scenarios/team-me.js similarity index 100% rename from infra/k6/scenarios/teams-me.js rename to infra/k6/scenarios/team-me.js diff --git a/infra/k6/scenarios/teams-members.js b/infra/k6/scenarios/team-members.js similarity index 100% rename from infra/k6/scenarios/teams-members.js rename to infra/k6/scenarios/team-members.js diff --git a/infra/k6/scenarios/teams.js b/infra/k6/scenarios/team.js similarity index 100% rename from infra/k6/scenarios/teams.js rename to infra/k6/scenarios/team.js diff --git a/migrations/0006_opposite_pet_avengers.sql b/migrations/0006_opposite_pet_avengers.sql new file mode 100644 index 0000000..96620d9 --- /dev/null +++ b/migrations/0006_opposite_pet_avengers.sql @@ -0,0 +1,6 @@ +ALTER TABLE "base"."team_members" ALTER COLUMN "role" SET DATA TYPE text; +ALTER TABLE "base"."team_members" ALTER COLUMN "role" SET DEFAULT 'member'::text; +DROP TYPE "base"."team_role"; +CREATE TYPE "base"."team_role" AS ENUM('owner', 'admin', 'member', 'viewer'); +ALTER TABLE "base"."team_members" ALTER COLUMN "role" SET DEFAULT 'member'::"base"."team_role"; +ALTER TABLE "base"."team_members" ALTER COLUMN "role" SET DATA TYPE "base"."team_role" USING "role"::"base"."team_role"; \ No newline at end of file diff --git a/migrations/meta/0006_snapshot.json b/migrations/meta/0006_snapshot.json new file mode 100644 index 0000000..9b955f4 --- /dev/null +++ b/migrations/meta/0006_snapshot.json @@ -0,0 +1,2066 @@ +{ + "id": "7c0713c0-ce86-43d0-8fa2-6af013b763aa", + "prevId": "69248628-6c77-4c0b-bc3f-913a61c68731", + "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.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.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.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", + "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": { + "base.team_role": { + "name": "team_role", + "schema": "base", + "values": [ + "owner", + "admin", + "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 1fc04a1..46eb837 100644 --- a/migrations/meta/_journal.json +++ b/migrations/meta/_journal.json @@ -43,6 +43,13 @@ "when": 1781463108615, "tag": "0005_add_area", "breakpoints": false + }, + { + "idx": 6, + "version": "7", + "when": 1781803875274, + "tag": "0006_opposite_pet_avengers", + "breakpoints": false } ] } \ No newline at end of file diff --git a/package.json b/package.json index 6646650..39e1896 100644 --- a/package.json +++ b/package.json @@ -22,11 +22,11 @@ "prepare": "husky", "k6:all": "pnpm --filter @project/performance-tests test:all", "k6:auth": "pnpm --filter @project/performance-tests test:auth", - "k6:teams": "pnpm --filter @project/performance-tests test:teams", + "k6:team": "pnpm --filter @project/performance-tests test:team", "k6:projects": "pnpm --filter @project/performance-tests test:projects", - "k6:teams-members": "pnpm --filter @project/performance-tests test:teams-members", - "k6:teams-invitations": "pnpm --filter @project/performance-tests test:teams-invitations", - "k6:teams-me": "pnpm --filter @project/performance-tests test:teams-me", + "k6:team-members": "pnpm --filter @project/performance-tests test:team-members", + "k6:team-invitations": "pnpm --filter @project/performance-tests test:team-invitations", + "k6:team-me": "pnpm --filter @project/performance-tests test:team-me", "k6:users": "pnpm --filter @project/performance-tests test:users", "k6:boards": "pnpm --filter @project/performance-tests test:boards:all", "k6:tasks": "pnpm --filter @project/performance-tests test:tasks", diff --git a/src/app.module.ts b/src/app.module.ts index 1733ed4..707abaf 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -22,7 +22,7 @@ import { AreaModule } from './area'; import { AuthModule } from './auth/auth.module'; import { ProjectModule } from './project'; import * as schema from './shared/entities'; -import { TeamsModule } from './teams'; +import { TeamModule } from './team'; import { UserModule } from './user'; @Module({ @@ -53,7 +53,7 @@ import { UserModule } from './user'; MailModule, AuthModule, UserModule, - TeamsModule, + TeamModule, ProjectModule, AreaModule, MetricsModule, diff --git a/src/auth/auth.module.ts b/src/auth/auth.module.ts index eb31892..04fa746 100644 --- a/src/auth/auth.module.ts +++ b/src/auth/auth.module.ts @@ -1,5 +1,4 @@ import { ProjectModule } from '@core/project'; -import { TeamsModule } from '@core/teams'; import { UserModule } from '@core/user'; import { BullModule } from '@nestjs/bullmq'; import { forwardRef, Module } from '@nestjs/common'; @@ -7,6 +6,8 @@ import { ConfigService } from '@nestjs/config'; import { JwtModule } from '@nestjs/jwt'; import { MailAdapter } from '@shared/adapters/mail'; +import { TeamModule } from '../team'; + import { AuthFacade } from './application/auth.facade'; import { CONTROLLERS } from './application/controllers'; import { AuthUseCases } from './application/use-cases'; @@ -43,7 +44,7 @@ const WORKERS = [MailProcessor, UserProcessor]; }), BullModule.registerQueue({ name: AuthQueues.AUTH_MAIL }, { name: AuthQueues.AUTH_USER }), forwardRef(() => UserModule), - TeamsModule, + TeamModule, ProjectModule, ], controllers: CONTROLLERS, diff --git a/src/auth/infrastructure/workers/user.processor.ts b/src/auth/infrastructure/workers/user.processor.ts index fb61c30..eb57eec 100644 --- a/src/auth/infrastructure/workers/user.processor.ts +++ b/src/auth/infrastructure/workers/user.processor.ts @@ -2,11 +2,12 @@ import { AuthQueues } from '@core/auth/domain/enums'; import { AuthUserJobs } from '@core/auth/domain/enums/auth-jobs.enum'; import { CreateUserWorkspaceEvent } from '@core/auth/domain/events'; import { CreateProjectUseCase } from '@core/project/application/use-cases'; -import { CreateTeamUseCase } from '@core/teams/application/use-cases'; import { Processor, WorkerHost } from '@nestjs/bullmq'; import { Job } from 'bullmq'; import slugify from 'slugify'; +import { CreateTeamUseCase } from '../../../team/application/use-cases'; + @Processor(AuthQueues.AUTH_USER) export class UserProcessor extends WorkerHost { constructor( diff --git a/src/project/application/mappers/project.mapper.ts b/src/project/application/mappers/project.mapper.ts index df3466f..e90343f 100644 --- a/src/project/application/mappers/project.mapper.ts +++ b/src/project/application/mappers/project.mapper.ts @@ -1,5 +1,5 @@ +import type { RawMemberRow } from '../../../team/domain/repository'; import type { Project } from '@core/project/domain/entities'; -import type { RawMemberRow } from '@core/teams/domain/repository'; export class ProjectMapper { public static toDetailResponse(project: Project, member?: RawMemberRow | null, token?: string) { diff --git a/src/project/application/use-cases/member/add.use-case.ts b/src/project/application/use-cases/member/add.use-case.ts index f528e86..6f253b8 100644 --- a/src/project/application/use-cases/member/add.use-case.ts +++ b/src/project/application/use-cases/member/add.use-case.ts @@ -2,10 +2,10 @@ import { MemberErrorCodes, MemberErrorMessages } from '@core/project/domain/erro import { ProjectAccessPolicy } from '@core/project/domain/policy'; import { IMemberRepository } from '@core/project/domain/repository'; import { MAX_MEMBERS_PER_PROJECT } from '@core/project/infrastructure/constants'; -import { FindTeamMemberQuery } from '@core/teams'; import { HttpStatus, Inject, Injectable } from '@nestjs/common'; import { BaseException } from '@shared/error'; +import { FindTeamMemberQuery } from '../../../../team'; import { AddProjectMemberDto } from '../../dtos'; @Injectable() diff --git a/src/project/application/use-cases/project/find-one.query.ts b/src/project/application/use-cases/project/find-one.query.ts index 3a4d67b..1aef4d9 100644 --- a/src/project/application/use-cases/project/find-one.query.ts +++ b/src/project/application/use-cases/project/find-one.query.ts @@ -2,11 +2,12 @@ import { createHash } from 'node:crypto'; import { ProjectErrorCodes, ProjectErrorMessages } from '@core/project/domain/errors'; import { IProjectRepository } from '@core/project/domain/repository'; -import { FindTeamMemberQuery, FindTeamQuery } from '@core/teams'; import { HttpStatus, Inject, Injectable } from '@nestjs/common'; import { isTeamRole, ROLE_PRIORITY } from '@shared/constants'; import { BaseException } from '@shared/error'; +import { FindTeamMemberQuery, FindTeamQuery } from '../../../../team'; + import type { Project } from '@core/project/domain/entities'; @Injectable() diff --git a/src/project/domain/policy/project-access.policy.ts b/src/project/domain/policy/project-access.policy.ts index d11a423..078a16d 100644 --- a/src/project/domain/policy/project-access.policy.ts +++ b/src/project/domain/policy/project-access.policy.ts @@ -1,9 +1,9 @@ -import { FindTeamMemberQuery, FindTeamQuery } from '@core/teams'; import { HttpStatus, Inject, Injectable } from '@nestjs/common'; import { ROLE_PRIORITY, PROJECT_ROLE_PRIORITY } from '@shared/constants'; import { BaseException } from '@shared/error'; import { isTeamRole } from '../../../shared/constants/roles.constant'; +import { FindTeamMemberQuery, FindTeamQuery } from '../../../team'; import { ProjectErrorCodes, ProjectErrorMessages } from '../errors'; import { MemberErrorCodes, MemberErrorMessages } from '../errors/member.errors'; import { IMemberRepository, IProjectRepository } from '../repository'; diff --git a/src/project/infrastructure/persistence/models/project.model.ts b/src/project/infrastructure/persistence/models/project.model.ts index bd5c9ad..0cbc606 100644 --- a/src/project/infrastructure/persistence/models/project.model.ts +++ b/src/project/infrastructure/persistence/models/project.model.ts @@ -1,5 +1,5 @@ import { createId } from '@paralleldrive/cuid2'; -import { baseSchema, teams, users } from '@shared/entities'; +import { baseSchema, team, users } from '@shared/entities'; import { isNull } from 'drizzle-orm'; import { text, @@ -20,7 +20,7 @@ export const projects = baseSchema.table( .primaryKey() .$defaultFn(() => createId()), teamId: text('team_id') - .references(() => teams.id, { onDelete: 'cascade' }) + .references(() => team.id, { onDelete: 'cascade' }) .notNull(), slug: varchar('slug', { length: 100 }).notNull().unique(), name: varchar('name', { length: 100 }).notNull(), diff --git a/src/project/project.module.ts b/src/project/project.module.ts index 6fbd286..6a894ce 100644 --- a/src/project/project.module.ts +++ b/src/project/project.module.ts @@ -1,7 +1,8 @@ -import { TeamsModule } from '@core/teams'; import { UserModule } from '@core/user'; import { forwardRef, Module } from '@nestjs/common'; +import { TeamModule } from '../team'; + import { CONTROLLERS } from './application/controllers'; import { ProjectFacade } from './application/project.facade'; import { CreateProjectUseCase, FindProjectQuery, USE_CASES } from './application/use-cases'; @@ -9,7 +10,7 @@ import { POLICIES, ProjectAccessPolicy } from './domain/policy'; import { REPOSITORIES } from './infrastructure/persistence/repositories'; @Module({ - imports: [UserModule, forwardRef(() => TeamsModule)], + imports: [UserModule, forwardRef(() => TeamModule)], controllers: CONTROLLERS, providers: [...REPOSITORIES, ...POLICIES, ...USE_CASES, ProjectFacade], exports: [FindProjectQuery, ProjectAccessPolicy, CreateProjectUseCase], diff --git a/src/shared/authorization/ability.factory.ts b/src/shared/authorization/ability.factory.ts index e3aa0da..8a498e6 100644 --- a/src/shared/authorization/ability.factory.ts +++ b/src/shared/authorization/ability.factory.ts @@ -1,9 +1,10 @@ import { createMongoAbility, RawRuleOf } from '@casl/ability'; -import { RawMemberRow } from '@core/teams/domain/repository'; import { Injectable } from '@nestjs/common'; import { Subject } from '@shared/authorization/types/subject.enum'; import { ROLE_PRIORITY } from '@shared/constants'; +import { RawMemberRow } from '../../team/domain/repository'; + import { ROLE_PERMISSIONS_MAP } from './permissions'; import { AppAbility } from './types/app-ability.type'; diff --git a/src/shared/authorization/authorization.spec.ts b/src/shared/authorization/authorization.spec.ts index 6e7d8ac..8b0cb8e 100644 --- a/src/shared/authorization/authorization.spec.ts +++ b/src/shared/authorization/authorization.spec.ts @@ -6,8 +6,8 @@ import { describe, expect } from 'vitest'; import { AbilityFactory } from './ability.factory'; -import type { RawMemberRow } from '@core/teams/domain/repository'; -import type { TeamRole } from '@core/teams/infrastructure/persistence/models'; +import type { RawMemberRow } from '../../team/domain/repository'; +import type { TeamRole } from '../../team/infrastructure/persistence/models'; describe('AuthorizationService - Permissions Matrix', () => { const findTeamMemberMock = { diff --git a/src/shared/authorization/permissions/permissions-map.ts b/src/shared/authorization/permissions/permissions-map.ts index 5aa335e..c02e2f2 100644 --- a/src/shared/authorization/permissions/permissions-map.ts +++ b/src/shared/authorization/permissions/permissions-map.ts @@ -3,7 +3,7 @@ import { MEMBER_PERMISSIONS } from '@shared/authorization/permissions/member.per import { OWNER_PERMISSIONS } from '@shared/authorization/permissions/owner.permissions'; import { VIEWER_PERMISSIONS } from '@shared/authorization/permissions/viewer.permissions'; -import type { TeamRole } from '@core/teams/infrastructure/persistence/models'; +import type { TeamRole } from '../../../team/infrastructure/persistence/models'; import type { PermissionRule } from '@shared/authorization/types/permission-rule.interface'; export const ROLE_PERMISSIONS_MAP: Record = { diff --git a/src/shared/constants/roles.constant.ts b/src/shared/constants/roles.constant.ts index 8763713..3fbe63e 100644 --- a/src/shared/constants/roles.constant.ts +++ b/src/shared/constants/roles.constant.ts @@ -1,4 +1,4 @@ -import type { TeamRole } from '@core/teams/infrastructure/persistence/models'; +import type { TeamRole } from '../../team/infrastructure/persistence/models'; export const TEAM_ROLES = ['owner', 'admin', 'member', 'viewer'] as const; diff --git a/src/shared/entities/index.ts b/src/shared/entities/index.ts index b357898..661d666 100644 --- a/src/shared/entities/index.ts +++ b/src/shared/entities/index.ts @@ -1,6 +1,6 @@ export { baseSchema } from './schema'; export * from '../../user/infrastructure/persistence/models'; export * from '../../auth/infrastructure/persistence/models'; -export * from '../../teams/infrastructure/persistence/models'; +export * from '../../team/infrastructure/persistence/models'; export * from '../../project/infrastructure/persistence/models'; export * from '../../area/infrastructure/persistence/models'; diff --git a/src/team/application/controllers/index.ts b/src/team/application/controllers/index.ts new file mode 100644 index 0000000..ca79160 --- /dev/null +++ b/src/team/application/controllers/index.ts @@ -0,0 +1,4 @@ +export { MeController } from './me/controller'; +export { TeamController } from './team/controller'; +export { TeamMembersController } from './members/controller'; +export { TeamInvitationsController } from './invitations/controller'; diff --git a/src/teams/application/controller/invitations/controller.ts b/src/team/application/controllers/invitations/controller.ts similarity index 93% rename from src/teams/application/controller/invitations/controller.ts rename to src/team/application/controllers/invitations/controller.ts index 9e062e9..0494d0b 100644 --- a/src/teams/application/controller/invitations/controller.ts +++ b/src/team/application/controllers/invitations/controller.ts @@ -2,7 +2,7 @@ import { Body, Get, Param, Delete, Patch, Post } from '@nestjs/common'; import { ApiBaseController, GetUser, GetUserId } from '@shared/decorators'; import { InviteMemberDto, UpdateInvitationDto } from '../../dtos'; -import { TeamsFacade } from '../../team.facade'; +import { TeamFacade } from '../../team.facade'; import { AcceptInviteSwagger, @@ -16,8 +16,8 @@ import { import type { JwtPayload } from '@shared/types'; @ApiBaseController('teams/:teamId/invitations', 'Teams Invitations', true) -export class TeamsInvitationsController { - constructor(private readonly facade: TeamsFacade) {} +export class TeamInvitationsController { + constructor(private readonly facade: TeamFacade) {} @Get() @GetTeamInvitationsSwagger() diff --git a/src/teams/application/controller/invitations/swagger.ts b/src/team/application/controllers/invitations/swagger.ts similarity index 100% rename from src/teams/application/controller/invitations/swagger.ts rename to src/team/application/controllers/invitations/swagger.ts diff --git a/src/teams/application/controller/me/controller.ts b/src/team/application/controllers/me/controller.ts similarity index 85% rename from src/teams/application/controller/me/controller.ts rename to src/team/application/controllers/me/controller.ts index 877808f..05fbb51 100644 --- a/src/teams/application/controller/me/controller.ts +++ b/src/team/application/controllers/me/controller.ts @@ -1,7 +1,7 @@ import { Get } from '@nestjs/common'; import { ApiBaseController, GetUser, GetUserId } from '@shared/decorators'; -import { TeamsFacade } from '../../team.facade'; +import { TeamFacade } from '../../team.facade'; import { FindInvitesSwagger, FindTeamsSwagger } from './swagger'; @@ -9,7 +9,7 @@ import type { JwtPayload } from '@shared/types'; @ApiBaseController('users/me', 'Account Teams', true) export class MeController { - constructor(private readonly facade: TeamsFacade) {} + constructor(private readonly facade: TeamFacade) {} @Get('teams') @FindTeamsSwagger() diff --git a/src/teams/application/controller/me/swagger.ts b/src/team/application/controllers/me/swagger.ts similarity index 100% rename from src/teams/application/controller/me/swagger.ts rename to src/team/application/controllers/me/swagger.ts diff --git a/src/teams/application/controller/members/controller.ts b/src/team/application/controllers/members/controller.ts similarity index 88% rename from src/teams/application/controller/members/controller.ts rename to src/team/application/controllers/members/controller.ts index a558bd8..8408acf 100644 --- a/src/teams/application/controller/members/controller.ts +++ b/src/team/application/controllers/members/controller.ts @@ -2,13 +2,13 @@ import { Body, Delete, Get, Param, Patch } from '@nestjs/common'; import { ApiBaseController, GetUserId } from '@shared/decorators'; import { UpdateMemberDto } from '../../dtos'; -import { TeamsFacade } from '../../team.facade'; +import { TeamFacade } from '../../team.facade'; import { GetMembersSwagger, RemoveMemberSwagger, UpdateMemberSwagger } from './swagger'; @ApiBaseController('teams/:teamId', 'Teams Members', true) -export class TeamsMembersController { - constructor(private readonly facade: TeamsFacade) {} +export class TeamMembersController { + constructor(private readonly facade: TeamFacade) {} @Get('members') @GetMembersSwagger() diff --git a/src/teams/application/controller/members/swagger.ts b/src/team/application/controllers/members/swagger.ts similarity index 100% rename from src/teams/application/controller/members/swagger.ts rename to src/team/application/controllers/members/swagger.ts diff --git a/src/teams/application/controller/teams/controller.ts b/src/team/application/controllers/team/controller.ts similarity index 89% rename from src/teams/application/controller/teams/controller.ts rename to src/team/application/controllers/team/controller.ts index b3e872d..e297c08 100644 --- a/src/teams/application/controller/teams/controller.ts +++ b/src/team/application/controllers/team/controller.ts @@ -2,7 +2,7 @@ import { Body, Delete, Get, HttpCode, HttpStatus, Param, Patch, Post } from '@ne import { ApiBaseController, GetUserId } from '@shared/decorators'; import { CreateTeamDto, UpdateTeamDto } from '../../dtos'; -import { TeamsFacade } from '../../team.facade'; +import { TeamFacade } from '../../team.facade'; import { CreateTeamSwagger, @@ -12,8 +12,8 @@ import { } from './swagger'; @ApiBaseController('teams', 'Teams', true) -export class TeamsController { - constructor(private readonly facade: TeamsFacade) {} +export class TeamController { + constructor(private readonly facade: TeamFacade) {} @Post() @CreateTeamSwagger() diff --git a/src/teams/application/controller/teams/swagger.ts b/src/team/application/controllers/team/swagger.ts similarity index 97% rename from src/teams/application/controller/teams/swagger.ts rename to src/team/application/controllers/team/swagger.ts index ea1dec4..318bade 100644 --- a/src/teams/application/controller/teams/swagger.ts +++ b/src/team/application/controllers/team/swagger.ts @@ -1,10 +1,10 @@ -import { CreateTeamResponse } from '@core/teams/application/dtos/team.dto'; import { applyDecorators, SetMetadata } from '@nestjs/common'; import { ApiBody, ApiOperation, ApiParam, ApiResponse } from '@nestjs/swagger'; import { ApiForbidden, ApiNotFound, ApiUnauthorized, ApiValidationError } from '@shared/error'; import { ZOD_RESPONSE_TOKEN } from '@shared/interceptors'; import { ActionResponse } from '@shared/schemas'; +import { CreateTeamResponse } from '../../../application/dtos/team.dto'; import { CreateTeamDto, UpdateTeamDto, TeamResponse } from '../../dtos'; export const CreateTeamSwagger = () => diff --git a/src/teams/application/dtos/index.ts b/src/team/application/dtos/index.ts similarity index 100% rename from src/teams/application/dtos/index.ts rename to src/team/application/dtos/index.ts diff --git a/src/teams/application/dtos/invitation.dto.ts b/src/team/application/dtos/invitation.dto.ts similarity index 100% rename from src/teams/application/dtos/invitation.dto.ts rename to src/team/application/dtos/invitation.dto.ts diff --git a/src/teams/application/dtos/member.dto.ts b/src/team/application/dtos/member.dto.ts similarity index 97% rename from src/teams/application/dtos/member.dto.ts rename to src/team/application/dtos/member.dto.ts index 9c2e6a0..8b324f0 100644 --- a/src/teams/application/dtos/member.dto.ts +++ b/src/team/application/dtos/member.dto.ts @@ -1,8 +1,9 @@ -import { roleEnum } from '@core/teams/infrastructure/persistence/models'; import { AvatarResponseSchema, createPaginationSchema } from '@shared/schemas'; import { createZodDto } from 'nestjs-zod'; import { z } from 'zod/v4'; +import { roleEnum } from '../../infrastructure/persistence/models'; + export const InviteMemberSchema = z.object({ email: z.string().email().describe('Email пользователя, которого нужно пригласить'), role: z diff --git a/src/teams/application/dtos/team.dto.ts b/src/team/application/dtos/team.dto.ts similarity index 100% rename from src/teams/application/dtos/team.dto.ts rename to src/team/application/dtos/team.dto.ts diff --git a/src/teams/application/mappers/index.ts b/src/team/application/mappers/index.ts similarity index 100% rename from src/teams/application/mappers/index.ts rename to src/team/application/mappers/index.ts diff --git a/src/teams/application/mappers/member.mapper.ts b/src/team/application/mappers/member.mapper.ts similarity index 100% rename from src/teams/application/mappers/member.mapper.ts rename to src/team/application/mappers/member.mapper.ts diff --git a/src/teams/application/team.facade.ts b/src/team/application/team.facade.ts similarity index 99% rename from src/teams/application/team.facade.ts rename to src/team/application/team.facade.ts index f0e8c3b..c86cb18 100644 --- a/src/teams/application/team.facade.ts +++ b/src/team/application/team.facade.ts @@ -10,7 +10,7 @@ import { import * as UC from './use-cases'; @Injectable() -export class TeamsFacade { +export class TeamFacade { constructor( private readonly findTeamQ: UC.FindTeamQuery, private readonly getInvitationQ: UC.GetInvitationQuery, diff --git a/src/teams/application/use-cases/base/create-team.use-case.ts b/src/team/application/use-cases/base/create-team.use-case.ts similarity index 78% rename from src/teams/application/use-cases/base/create-team.use-case.ts rename to src/team/application/use-cases/base/create-team.use-case.ts index dec55f5..48bf2e8 100644 --- a/src/teams/application/use-cases/base/create-team.use-case.ts +++ b/src/team/application/use-cases/base/create-team.use-case.ts @@ -1,19 +1,19 @@ -import { ITeamsRepository } from '@core/teams/domain/repository'; import { HttpStatus, Inject, Injectable } from '@nestjs/common'; import { BaseException } from '@shared/error'; +import { ITeamRepository } from '../../../domain/repository'; import { CreateTeamDto } from '../../dtos'; @Injectable() export class CreateTeamUseCase { constructor( - @Inject('ITeamsRepository') - private readonly teamsRepo: ITeamsRepository, + @Inject('ITeamRepository') + private readonly teamRepo: ITeamRepository, ) {} async execute(userId: string, dto: CreateTeamDto) { try { - const result = await this.teamsRepo.create(userId, dto); + const result = await this.teamRepo.create(userId, dto); return { ...result, diff --git a/src/teams/application/use-cases/base/delete-team.use-case.ts b/src/team/application/use-cases/base/delete-team.use-case.ts similarity index 85% rename from src/teams/application/use-cases/base/delete-team.use-case.ts rename to src/team/application/use-cases/base/delete-team.use-case.ts index 56e49bb..738f586 100644 --- a/src/teams/application/use-cases/base/delete-team.use-case.ts +++ b/src/team/application/use-cases/base/delete-team.use-case.ts @@ -1,20 +1,21 @@ -import { ITeamsRepository, RawMemberRow } from '@core/teams/domain/repository'; import { Inject, Injectable, HttpStatus } from '@nestjs/common'; import { AbilityFactory } from '@shared/authorization/ability.factory'; import { Action } from '@shared/authorization/types/action.enum'; import { Subject } from '@shared/authorization/types/subject.enum'; import { BaseException } from '@shared/error'; +import { ITeamRepository, RawMemberRow } from '../../../domain/repository'; + @Injectable() export class DeleteTeamUseCase { constructor( - @Inject('ITeamsRepository') - private readonly teamsRepo: ITeamsRepository, + @Inject('ITeamRepository') + private readonly teamRepo: ITeamRepository, private readonly abilityFactory: AbilityFactory, ) {} async execute(teamId: string, userId: string) { - const member = await this.teamsRepo.findMember(teamId, userId); + const member = await this.teamRepo.findMember(teamId, userId); if (!member) { throw new BaseException( @@ -28,7 +29,7 @@ export class DeleteTeamUseCase { this.validateAccess(member); try { - const result = await this.teamsRepo.remove(teamId, userId); + const result = await this.teamRepo.remove(teamId, userId); return { success: result, diff --git a/src/teams/application/use-cases/base/find-team.query.ts b/src/team/application/use-cases/base/find-team.query.ts similarity index 62% rename from src/teams/application/use-cases/base/find-team.query.ts rename to src/team/application/use-cases/base/find-team.query.ts index 8b025d7..7de46a2 100644 --- a/src/teams/application/use-cases/base/find-team.query.ts +++ b/src/team/application/use-cases/base/find-team.query.ts @@ -1,12 +1,12 @@ import { Inject, Injectable } from '@nestjs/common'; -import { ITeamsRepository } from '../../../domain/repository'; +import { ITeamRepository } from '../../../domain/repository'; @Injectable() export class FindTeamQuery { constructor( - @Inject('ITeamsRepository') - private readonly repository: ITeamsRepository, + @Inject('ITeamRepository') + private readonly repository: ITeamRepository, ) {} async execute(teamId: string) { diff --git a/src/teams/application/use-cases/base/get-my-teams.use-case.ts b/src/team/application/use-cases/base/get-my-teams.use-case.ts similarity index 70% rename from src/teams/application/use-cases/base/get-my-teams.use-case.ts rename to src/team/application/use-cases/base/get-my-teams.use-case.ts index 96d1b49..6c88f1d 100644 --- a/src/teams/application/use-cases/base/get-my-teams.use-case.ts +++ b/src/team/application/use-cases/base/get-my-teams.use-case.ts @@ -1,18 +1,19 @@ -import { TeamMemberMapper } from '@core/teams/application/mappers'; -import { ITeamsRepository } from '@core/teams/domain/repository'; import { Inject, Injectable } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; +import { TeamMemberMapper } from '../../../application/mappers'; +import { ITeamRepository } from '../../../domain/repository'; + @Injectable() export class GetMyTeamsUseCase { constructor( - @Inject('ITeamsRepository') - private readonly teamsRepo: ITeamsRepository, + @Inject('ITeamRepository') + private readonly teamRepo: ITeamRepository, private readonly cfg: ConfigService, ) {} async execute(userId: string) { - const teams = await this.teamsRepo.findByUser(userId); + const teams = await this.teamRepo.findByUser(userId); const cdn = this.getCdnBaseUrl(); return teams.map((t) => TeamMemberMapper.toUserTeam(t, cdn)); diff --git a/src/teams/application/use-cases/base/update-team.use-case.ts b/src/team/application/use-cases/base/update-team.use-case.ts similarity index 86% rename from src/teams/application/use-cases/base/update-team.use-case.ts rename to src/team/application/use-cases/base/update-team.use-case.ts index 068e436..ce3c7d1 100644 --- a/src/teams/application/use-cases/base/update-team.use-case.ts +++ b/src/team/application/use-cases/base/update-team.use-case.ts @@ -4,19 +4,19 @@ import { Action } from '@shared/authorization/types/action.enum'; import { Subject } from '@shared/authorization/types/subject.enum'; import { BaseException } from '@shared/error'; -import { ITeamsRepository, RawMemberRow } from '../../../domain/repository'; +import { ITeamRepository, RawMemberRow } from '../../../domain/repository'; import { UpdateTeamDto } from '../../dtos'; @Injectable() export class UpdateTeamUseCase { constructor( - @Inject('ITeamsRepository') - private readonly teamsRepo: ITeamsRepository, + @Inject('ITeamRepository') + private readonly teamRepo: ITeamRepository, private readonly abilityFactory: AbilityFactory, ) {} async execute(teamId: string, userId: string, dto: UpdateTeamDto) { - const member = await this.teamsRepo.findMember(teamId, userId); + const member = await this.teamRepo.findMember(teamId, userId); if (!member) { throw new BaseException( @@ -31,7 +31,7 @@ export class UpdateTeamUseCase { this.validateAccess(member); try { - const result = await this.teamsRepo.update(teamId, dto); + const result = await this.teamRepo.update(teamId, dto); return { ...result, diff --git a/src/teams/application/use-cases/index.ts b/src/team/application/use-cases/index.ts similarity index 100% rename from src/teams/application/use-cases/index.ts rename to src/team/application/use-cases/index.ts diff --git a/src/teams/application/use-cases/invitions/accept-invitation.use-case.ts b/src/team/application/use-cases/invitions/accept-invitation.use-case.ts similarity index 92% rename from src/teams/application/use-cases/invitions/accept-invitation.use-case.ts rename to src/team/application/use-cases/invitions/accept-invitation.use-case.ts index 94e9a30..eff6ac0 100644 --- a/src/teams/application/use-cases/invitions/accept-invitation.use-case.ts +++ b/src/team/application/use-cases/invitions/accept-invitation.use-case.ts @@ -1,9 +1,10 @@ -import { ITeamsRepository } from '@core/teams/domain/repository'; import { HttpStatus, Inject, Injectable } from '@nestjs/common'; import { CACHE_SERVICE } from '@shared/adapters/cache/constants'; import { ICacheService } from '@shared/adapters/cache/ports'; import { BaseException } from '@shared/error'; +import { ITeamRepository } from '../../../domain/repository'; + import type { TeamInvite } from '../../dtos/invitation.dto'; @Injectable() @@ -13,7 +14,7 @@ export class AcceptInvitationUseCase { private readonly USER_INVITES_KEY = (email: string) => `user:invites:${email.toLowerCase()}`; constructor( - @Inject('ITeamsRepository') private readonly teamsRepo: ITeamsRepository, + @Inject('ITeamRepository') private readonly teamRepo: ITeamRepository, @Inject(CACHE_SERVICE) private readonly cacheService: ICacheService, ) {} @@ -40,7 +41,7 @@ export class AcceptInvitationUseCase { ); } - const member = await this.teamsRepo.findMember(invite.teamId, userId); + const member = await this.teamRepo.findMember(invite.teamId, userId); if (member) { if (member.status === 'banned') { throw new BaseException( @@ -57,7 +58,7 @@ export class AcceptInvitationUseCase { } } - await this.teamsRepo.addMember({ + await this.teamRepo.addMember({ teamId: invite.teamId, userId, role: invite.role, diff --git a/src/teams/application/use-cases/invitions/delete-invitation.use-case.ts b/src/team/application/use-cases/invitions/delete-invitation.use-case.ts similarity index 93% rename from src/teams/application/use-cases/invitions/delete-invitation.use-case.ts rename to src/team/application/use-cases/invitions/delete-invitation.use-case.ts index 29c648c..36aec93 100644 --- a/src/teams/application/use-cases/invitions/delete-invitation.use-case.ts +++ b/src/team/application/use-cases/invitions/delete-invitation.use-case.ts @@ -1,4 +1,3 @@ -import { ITeamsRepository, RawMemberRow } from '@core/teams/domain/repository'; import { HttpStatus, Inject, Injectable } from '@nestjs/common'; import { CACHE_SERVICE } from '@shared/adapters/cache/constants'; import { ICacheService } from '@shared/adapters/cache/ports'; @@ -7,6 +6,8 @@ import { Action } from '@shared/authorization/types/action.enum'; import { Subject } from '@shared/authorization/types/subject.enum'; import { BaseException } from '@shared/error'; +import { ITeamRepository, RawMemberRow } from '../../../domain/repository'; + import type { TeamInvite } from '../../dtos/invitation.dto'; @Injectable() @@ -16,7 +17,7 @@ export class DeleteInvitationUseCase { private readonly USER_INVITES_KEY = (email: string) => `user:invites:${email.toLowerCase()}`; constructor( - @Inject('ITeamsRepository') private readonly teamsRepo: ITeamsRepository, + @Inject('ITeamRepository') private readonly teamRepo: ITeamRepository, @Inject(CACHE_SERVICE) private readonly cacheService: ICacheService, private readonly abilityFactory: AbilityFactory, ) {} @@ -25,7 +26,7 @@ export class DeleteInvitationUseCase { const invite = await this.getInviteOrThrow(code); this.validateInviteOwnership(invite, teamId); - const member = await this.teamsRepo.findMember(teamId, userId); + const member = await this.teamRepo.findMember(teamId, userId); if (!member) { throw new BaseException( { diff --git a/src/teams/application/use-cases/invitions/get-invitation.query.ts b/src/team/application/use-cases/invitions/get-invitation.query.ts similarity index 89% rename from src/teams/application/use-cases/invitions/get-invitation.query.ts rename to src/team/application/use-cases/invitions/get-invitation.query.ts index 43a8c52..ec61caf 100644 --- a/src/teams/application/use-cases/invitions/get-invitation.query.ts +++ b/src/team/application/use-cases/invitions/get-invitation.query.ts @@ -1,9 +1,10 @@ -import { ITeamsRepository } from '@core/teams/domain/repository'; import { HttpStatus, Inject, Injectable } from '@nestjs/common'; import { CACHE_SERVICE } from '@shared/adapters/cache/constants'; import { ICacheService } from '@shared/adapters/cache/ports'; import { BaseException } from '@shared/error'; +import { ITeamRepository } from '../../../domain/repository'; + import type { TeamInvite } from '../../dtos/invitation.dto'; @Injectable() @@ -11,7 +12,7 @@ export class GetInvitationQuery { private readonly INVITES_KEY = (code: string) => `inv:code:${code}`; constructor( - @Inject('ITeamsRepository') private readonly teamsRepo: ITeamsRepository, + @Inject('ITeamRepository') private readonly teamRepo: ITeamRepository, @Inject(CACHE_SERVICE) private readonly cacheService: ICacheService, ) {} @@ -26,7 +27,7 @@ export class GetInvitationQuery { } private async getTeamOrThrow(teamId: string) { - const team = await this.teamsRepo.findById(teamId); + const team = await this.teamRepo.findById(teamId); if (!team) { throw new BaseException( { code: 'TEAM_NOT_FOUND', message: 'Команда не найдена' }, @@ -66,7 +67,7 @@ export class GetInvitationQuery { return; } - const member = await this.teamsRepo.findMember(teamId, userId); + const member = await this.teamRepo.findMember(teamId, userId); if (member && (member.role === 'owner' || member.role === 'admin')) { return; } diff --git a/src/teams/application/use-cases/invitions/get-invitations.query.ts b/src/team/application/use-cases/invitions/get-invitations.query.ts similarity index 93% rename from src/teams/application/use-cases/invitions/get-invitations.query.ts rename to src/team/application/use-cases/invitions/get-invitations.query.ts index e3971cf..f226041 100644 --- a/src/teams/application/use-cases/invitions/get-invitations.query.ts +++ b/src/team/application/use-cases/invitions/get-invitations.query.ts @@ -1,4 +1,3 @@ -import { ITeamsRepository, RawMemberRow } from '@core/teams/domain/repository'; import { HttpStatus, Inject, Injectable } from '@nestjs/common'; import { CACHE_SERVICE } from '@shared/adapters/cache/constants'; import { ICacheService } from '@shared/adapters/cache/ports'; @@ -7,19 +6,21 @@ import { Action } from '@shared/authorization/types/action.enum'; import { Subject } from '@shared/authorization/types/subject.enum'; import { BaseException } from '@shared/error'; +import { ITeamRepository, RawMemberRow } from '../../../domain/repository'; + @Injectable() export class GetInvitationsQuery { private readonly TEAM_INVITES_KEY = (teamId: string) => `team:invites:${teamId}`; private readonly INVITES_KEY = (code: string) => `inv:code:${code}`; constructor( - @Inject('ITeamsRepository') private readonly teamsRepo: ITeamsRepository, + @Inject('ITeamRepository') private readonly teamRepo: ITeamRepository, @Inject(CACHE_SERVICE) private readonly cacheService: ICacheService, private readonly abilityFactory: AbilityFactory, ) {} async execute(teamId: string, userId: string) { - const member = await this.teamsRepo.findMember(teamId, userId); + const member = await this.teamRepo.findMember(teamId, userId); if (!member) { throw new BaseException( { diff --git a/src/teams/application/use-cases/invitions/get-my-invites.use-case.ts b/src/team/application/use-cases/invitions/get-my-invites.use-case.ts similarity index 97% rename from src/teams/application/use-cases/invitions/get-my-invites.use-case.ts rename to src/team/application/use-cases/invitions/get-my-invites.use-case.ts index 6c17669..92e431c 100644 --- a/src/teams/application/use-cases/invitions/get-my-invites.use-case.ts +++ b/src/team/application/use-cases/invitions/get-my-invites.use-case.ts @@ -1,8 +1,9 @@ -import { TeamMemberMapper } from '@core/teams/application/mappers'; import { Inject, Injectable } from '@nestjs/common'; import { CACHE_SERVICE } from '@shared/adapters/cache/constants'; import { ICacheService } from '@shared/adapters/cache/ports'; +import { TeamMemberMapper } from '../../../application/mappers'; + @Injectable() export class GetMyInvitesUseCase { constructor( diff --git a/src/teams/application/use-cases/invitions/send-invitation.use-case.ts b/src/team/application/use-cases/invitions/send-invitation.use-case.ts similarity index 92% rename from src/teams/application/use-cases/invitions/send-invitation.use-case.ts rename to src/team/application/use-cases/invitions/send-invitation.use-case.ts index aa2e7a1..5800646 100644 --- a/src/teams/application/use-cases/invitions/send-invitation.use-case.ts +++ b/src/team/application/use-cases/invitions/send-invitation.use-case.ts @@ -1,7 +1,4 @@ import { subject } from '@casl/ability'; -import { TeamMailJobs, TeamQueues } from '@core/teams/domain/enums'; -import { TeamInvitationEvent } from '@core/teams/domain/events'; -import { ITeamsRepository, RawMemberRow } from '@core/teams/domain/repository'; import { InjectQueue } from '@nestjs/bullmq'; import { HttpStatus, Inject, Injectable } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; @@ -16,6 +13,9 @@ import { ImageHelper } from '@shared/utils'; import { Queue } from 'bullmq'; import { generateSecret } from 'otplib'; +import { TeamMailJobs, TeamQueues } from '../../../domain/enums'; +import { TeamInvitationEvent } from '../../../domain/events'; +import { ITeamRepository, RawMemberRow } from '../../../domain/repository'; import { InviteMemberDto, type TeamInvite } from '../../dtos'; import type { TeamRole } from '@shared/entities'; @@ -28,7 +28,7 @@ export class SendInvitationUseCase { private readonly USER_INVITES_KEY = (email: string) => `user:invites:${email.toLowerCase()}`; constructor( - @Inject('ITeamsRepository') private readonly teamsRepo: ITeamsRepository, + @Inject('ITeamRepository') private readonly teamRepo: ITeamRepository, @Inject(CACHE_SERVICE) private readonly cacheService: ICacheService, @InjectQueue(TeamQueues.TEAM_MAIL) private readonly mailQueue: Queue, private readonly cfg: ConfigService, @@ -55,7 +55,7 @@ export class SendInvitationUseCase { } private async getTeamOrThrow(teamId: string) { - const team = await this.teamsRepo.findById(teamId); + const team = await this.teamRepo.findById(teamId); if (!team) { throw new BaseException( { code: 'TEAM_NOT_FOUND', message: 'Команда не найдена' }, @@ -66,7 +66,7 @@ export class SendInvitationUseCase { } private async getInviterOrThrow(teamId: string, userId: string) { - const inviter = await this.teamsRepo.findMember(teamId, userId); + const inviter = await this.teamRepo.findMember(teamId, userId); if (!inviter) { throw new BaseException( { code: 'NOT_A_MEMBER', message: 'Вы не член команды' }, @@ -106,7 +106,7 @@ export class SendInvitationUseCase { } private async ensureNotAlreadyMember(teamId: string, email: string) { - const member = await this.teamsRepo.findMember(teamId, email); // Тут лучше искать по email в репо + const member = await this.teamRepo.findMember(teamId, email); // Тут лучше искать по email в репо if (member) { throw new BaseException( { code: 'ALREADY_MEMBER', message: 'Уже в команде' }, diff --git a/src/teams/application/use-cases/invitions/update-invitation.use-case.ts b/src/team/application/use-cases/invitions/update-invitation.use-case.ts similarity index 94% rename from src/teams/application/use-cases/invitions/update-invitation.use-case.ts rename to src/team/application/use-cases/invitions/update-invitation.use-case.ts index 3187d93..511238f 100644 --- a/src/teams/application/use-cases/invitions/update-invitation.use-case.ts +++ b/src/team/application/use-cases/invitions/update-invitation.use-case.ts @@ -1,5 +1,4 @@ import { subject } from '@casl/ability'; -import { ITeamsRepository, RawMemberRow } from '@core/teams/domain/repository'; import { HttpStatus, Inject, Injectable } from '@nestjs/common'; import { CACHE_SERVICE } from '@shared/adapters/cache/constants'; import { ICacheService } from '@shared/adapters/cache/ports'; @@ -10,6 +9,7 @@ import { ROLE_PRIORITY } from '@shared/constants'; import { TeamRole } from '@shared/entities'; import { BaseException } from '@shared/error'; +import { ITeamRepository, RawMemberRow } from '../../../domain/repository'; import { UpdateInvitationDto } from '../../dtos'; import { TeamInvite } from '../../dtos/invitation.dto'; @@ -18,7 +18,7 @@ export class UpdateInvitationUseCase { private readonly INVITES_KEY = (code: string) => `inv:code:${code}`; constructor( - @Inject('ITeamsRepository') private readonly teamsRepo: ITeamsRepository, + @Inject('ITeamRepository') private readonly teamRepo: ITeamRepository, @Inject(CACHE_SERVICE) private readonly cacheService: ICacheService, private readonly abilityFactory: AbilityFactory, ) {} @@ -29,7 +29,7 @@ export class UpdateInvitationUseCase { const { invite, ttlSeconds } = await this.getInviteContextOrThrow(key); this.validateInviteOwnership(invite, teamId); - const member = await this.teamsRepo.findMember(teamId, userId); + const member = await this.teamRepo.findMember(teamId, userId); if (!member) { throw new BaseException( { diff --git a/src/teams/application/use-cases/members/find-team-member.query.ts b/src/team/application/use-cases/members/find-team-member.query.ts similarity index 61% rename from src/teams/application/use-cases/members/find-team-member.query.ts rename to src/team/application/use-cases/members/find-team-member.query.ts index 5f82597..6e66aee 100644 --- a/src/teams/application/use-cases/members/find-team-member.query.ts +++ b/src/team/application/use-cases/members/find-team-member.query.ts @@ -1,12 +1,12 @@ import { Inject, Injectable } from '@nestjs/common'; -import { ITeamsRepository } from '../../../domain/repository'; +import { ITeamRepository } from '../../../domain/repository'; @Injectable() export class FindTeamMemberQuery { constructor( - @Inject('ITeamsRepository') - private readonly repository: ITeamsRepository, + @Inject('ITeamRepository') + private readonly repository: ITeamRepository, ) {} async execute(teamId: string, userId: string) { diff --git a/src/teams/application/use-cases/members/get-team-members.query.ts b/src/team/application/use-cases/members/get-team-members.query.ts similarity index 80% rename from src/teams/application/use-cases/members/get-team-members.query.ts rename to src/team/application/use-cases/members/get-team-members.query.ts index ccf02a8..f0a33ea 100644 --- a/src/teams/application/use-cases/members/get-team-members.query.ts +++ b/src/team/application/use-cases/members/get-team-members.query.ts @@ -1,19 +1,20 @@ -import { TeamMemberMapper } from '@core/teams/application/mappers'; -import { ITeamsRepository } from '@core/teams/domain/repository'; import { HttpStatus, Inject, Injectable } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { BaseException } from '@shared/error'; +import { TeamMemberMapper } from '../../../application/mappers'; +import { ITeamRepository } from '../../../domain/repository'; + @Injectable() export class GetTeamMembersQuery { constructor( - @Inject('ITeamsRepository') - private readonly teamsRepo: ITeamsRepository, + @Inject('ITeamRepository') + private readonly teamRepo: ITeamRepository, private readonly cfg: ConfigService, ) {} async execute(teamId: string) { - const team = await this.teamsRepo.findById(teamId); + const team = await this.teamRepo.findById(teamId); if (!team) { throw new BaseException( @@ -22,7 +23,7 @@ export class GetTeamMembersQuery { ); } const cdn = this.getCdnBaseUrl(); - const members = await this.teamsRepo.findMembers(team.id); + const members = await this.teamRepo.findMembers(team.id); const data = TeamMemberMapper.toList(members, cdn); return { diff --git a/src/teams/application/use-cases/members/remove-team-member.use-case.ts b/src/team/application/use-cases/members/remove-team-member.use-case.ts similarity index 88% rename from src/teams/application/use-cases/members/remove-team-member.use-case.ts rename to src/team/application/use-cases/members/remove-team-member.use-case.ts index cc4a4be..c670287 100644 --- a/src/teams/application/use-cases/members/remove-team-member.use-case.ts +++ b/src/team/application/use-cases/members/remove-team-member.use-case.ts @@ -1,5 +1,4 @@ import { subject } from '@casl/ability'; -import { ITeamsRepository, RawMemberRow } from '@core/teams/domain/repository'; import { HttpStatus, Inject, Injectable } from '@nestjs/common'; import { AbilityFactory } from '@shared/authorization/ability.factory'; import { Action } from '@shared/authorization/types/action.enum'; @@ -7,13 +6,15 @@ import { Subject } from '@shared/authorization/types/subject.enum'; import { ROLE_PRIORITY } from '@shared/constants'; import { BaseException } from '@shared/error'; +import { ITeamRepository, RawMemberRow } from '../../../domain/repository'; + import type { TeamRole } from '@shared/entities'; @Injectable() export class RemoveTeamMemberUseCase { constructor( - @Inject('ITeamsRepository') - private readonly teamsRepo: ITeamsRepository, + @Inject('ITeamRepository') + private readonly teamRepo: ITeamRepository, private readonly abilityFactory: AbilityFactory, ) {} @@ -21,7 +22,7 @@ export class RemoveTeamMemberUseCase { //TODO: move to policy this.isSelfRemoval(currentUserId, targetUserId); - const member = await this.teamsRepo.findMember(teamId, currentUserId); + const member = await this.teamRepo.findMember(teamId, currentUserId); if (!member) { throw new BaseException( { @@ -32,7 +33,7 @@ export class RemoveTeamMemberUseCase { ); } - const targetUser = await this.teamsRepo.findMember(teamId, targetUserId); + const targetUser = await this.teamRepo.findMember(teamId, targetUserId); if (!targetUser) { throw new BaseException( @@ -44,7 +45,7 @@ export class RemoveTeamMemberUseCase { this.validateAccess(member, targetUser.role); try { - const success = await this.teamsRepo.removeMember(teamId, targetUserId); + const success = await this.teamRepo.removeMember(teamId, targetUserId); if (!success) { this.errorDuringRemoving(); } diff --git a/src/teams/application/use-cases/members/update-team-member.use-case.ts b/src/team/application/use-cases/members/update-team-member.use-case.ts similarity index 91% rename from src/teams/application/use-cases/members/update-team-member.use-case.ts rename to src/team/application/use-cases/members/update-team-member.use-case.ts index be27422..0850d86 100644 --- a/src/teams/application/use-cases/members/update-team-member.use-case.ts +++ b/src/team/application/use-cases/members/update-team-member.use-case.ts @@ -1,5 +1,4 @@ import { subject } from '@casl/ability'; -import { ITeamsRepository, RawMemberRow } from '@core/teams/domain/repository'; import { HttpStatus, Inject, Injectable } from '@nestjs/common'; import { AbilityFactory } from '@shared/authorization/ability.factory'; import { Action } from '@shared/authorization/types/action.enum'; @@ -8,13 +7,14 @@ import { ROLE_PRIORITY } from '@shared/constants'; import { TeamRole } from '@shared/entities'; import { BaseException } from '@shared/error'; +import { ITeamRepository, RawMemberRow } from '../../../domain/repository'; import { UpdateMemberDto } from '../../dtos'; @Injectable() export class UpdateTeamMemberUseCase { constructor( - @Inject('ITeamsRepository') - private readonly teamsRepo: ITeamsRepository, + @Inject('ITeamRepository') + private readonly teamRepo: ITeamRepository, private readonly abilityFactory: AbilityFactory, ) {} @@ -27,7 +27,7 @@ export class UpdateTeamMemberUseCase { //TODO: move to policy this.isSelfRemoval(currentUserId, targetUserId); - const member = await this.teamsRepo.findMember(teamId, currentUserId); + const member = await this.teamRepo.findMember(teamId, currentUserId); if (!member) { throw new BaseException( { @@ -38,7 +38,7 @@ export class UpdateTeamMemberUseCase { ); } - const targetUser = await this.teamsRepo.findMember(teamId, targetUserId); + const targetUser = await this.teamRepo.findMember(teamId, targetUserId); if (!targetUser) { throw new BaseException( @@ -50,7 +50,7 @@ export class UpdateTeamMemberUseCase { this.validateAccess(member, targetUser.role, dto); try { - const result = await this.teamsRepo.updateMember(teamId, targetUserId, dto); + const result = await this.teamRepo.updateMember(teamId, targetUserId, dto); return { success: result, message: `Данные участника команды успешно обновлены`, diff --git a/src/team/domain/entities/index.ts b/src/team/domain/entities/index.ts new file mode 100644 index 0000000..0b6f253 --- /dev/null +++ b/src/team/domain/entities/index.ts @@ -0,0 +1 @@ +export * from './team.domain'; diff --git a/src/teams/domain/entities/teams.domain.ts b/src/team/domain/entities/team.domain.ts similarity index 60% rename from src/teams/domain/entities/teams.domain.ts rename to src/team/domain/entities/team.domain.ts index 4ee5e26..3ba1c3f 100644 --- a/src/teams/domain/entities/teams.domain.ts +++ b/src/team/domain/entities/team.domain.ts @@ -1,8 +1,8 @@ -import type { teams, teamMembers } from '../../infrastructure/persistence/models'; +import type { team, teamMembers } from '../../infrastructure/persistence/models'; import type { InferSelectModel, InferInsertModel } from 'drizzle-orm'; -export type Team = InferSelectModel; -export type NewTeam = InferInsertModel; +export type Team = InferSelectModel; +export type NewTeam = InferInsertModel; export type TeamMember = InferSelectModel; export type NewTeamMember = InferInsertModel; diff --git a/src/teams/domain/enums/index.ts b/src/team/domain/enums/index.ts similarity index 100% rename from src/teams/domain/enums/index.ts rename to src/team/domain/enums/index.ts diff --git a/src/teams/domain/enums/mail-jobs.enum.ts b/src/team/domain/enums/mail-jobs.enum.ts similarity index 100% rename from src/teams/domain/enums/mail-jobs.enum.ts rename to src/team/domain/enums/mail-jobs.enum.ts diff --git a/src/teams/domain/events/index.ts b/src/team/domain/events/index.ts similarity index 100% rename from src/teams/domain/events/index.ts rename to src/team/domain/events/index.ts diff --git a/src/teams/domain/events/team-invitation.event.ts b/src/team/domain/events/team-invitation.event.ts similarity index 100% rename from src/teams/domain/events/team-invitation.event.ts rename to src/team/domain/events/team-invitation.event.ts diff --git a/src/teams/domain/policy/index.ts b/src/team/domain/policy/index.ts similarity index 100% rename from src/teams/domain/policy/index.ts rename to src/team/domain/policy/index.ts diff --git a/src/teams/domain/policy/team-member.policy.ts b/src/team/domain/policy/team-member.policy.ts similarity index 100% rename from src/teams/domain/policy/team-member.policy.ts rename to src/team/domain/policy/team-member.policy.ts diff --git a/src/team/domain/repository/index.ts b/src/team/domain/repository/index.ts new file mode 100644 index 0000000..9473cfe --- /dev/null +++ b/src/team/domain/repository/index.ts @@ -0,0 +1,5 @@ +export { + ITeamRepository, + type RawMemberRow, + type RawMemberTeams, +} from './team.repository.interface'; diff --git a/src/teams/domain/repository/teams.repository.interface.ts b/src/team/domain/repository/team.repository.interface.ts similarity index 92% rename from src/teams/domain/repository/teams.repository.interface.ts rename to src/team/domain/repository/team.repository.interface.ts index 9b0d5ea..e6bafba 100644 --- a/src/teams/domain/repository/teams.repository.interface.ts +++ b/src/team/domain/repository/team.repository.interface.ts @@ -1,5 +1,5 @@ +import type { TeamRole, TeamMemberStatus } from '../../infrastructure/persistence/models'; import type { Team, NewTeam, NewTeamMember } from '../entities'; -import type { TeamRole, TeamMemberStatus } from '@core/teams/infrastructure/persistence/models'; type TResponse = { readonly success: boolean; readonly teamId: string }; @@ -24,7 +24,7 @@ export type RawMemberTeams = { readonly joinedAt: string | null; }; -export interface ITeamsRepository { +export interface ITeamRepository { create(ownerId: string, dto: NewTeam): Promise; update(id: string, dto: Partial): Promise; remove(id: string, userId: string): Promise; diff --git a/src/teams/index.ts b/src/team/index.ts similarity index 62% rename from src/teams/index.ts rename to src/team/index.ts index f4d6e9c..aa23e70 100644 --- a/src/teams/index.ts +++ b/src/team/index.ts @@ -1,2 +1,2 @@ -export { TeamsModule } from './teams.module'; +export { TeamModule } from './team.module'; export { FindTeamQuery, FindTeamMemberQuery } from './application/use-cases'; diff --git a/src/teams/infrastructure/listeners/index.ts b/src/team/infrastructure/listeners/index.ts similarity index 100% rename from src/teams/infrastructure/listeners/index.ts rename to src/team/infrastructure/listeners/index.ts diff --git a/src/teams/infrastructure/listeners/update-media.listener.ts b/src/team/infrastructure/listeners/update-media.listener.ts similarity index 92% rename from src/teams/infrastructure/listeners/update-media.listener.ts rename to src/team/infrastructure/listeners/update-media.listener.ts index b3d9247..c2bfdcc 100644 --- a/src/teams/infrastructure/listeners/update-media.listener.ts +++ b/src/team/infrastructure/listeners/update-media.listener.ts @@ -1,17 +1,18 @@ import { MEDIA_JOBS, MEDIA_QUEUES, UpdateMediaTeam } from '@core/media'; -import { TeamMemberPolicy } from '@core/teams/domain/policy'; -import { ITeamsRepository } from '@core/teams/domain/repository'; import { Processor, WorkerHost } from '@nestjs/bullmq'; import { Inject } from '@nestjs/common'; import { type Job, UnrecoverableError } from 'bullmq'; +import { TeamMemberPolicy } from '../../domain/policy'; +import { ITeamRepository } from '../../domain/repository'; + import type { TeamRole } from '@shared/entities'; @Processor(MEDIA_QUEUES.SAVE_ENTITY) export class UpdateTeamMediaListener extends WorkerHost { constructor( - @Inject('ITeamsRepository') - private readonly repository: ITeamsRepository, + @Inject('ITeamRepository') + private readonly repository: ITeamRepository, private readonly policy: TeamMemberPolicy, ) { super(); diff --git a/src/teams/infrastructure/persistence/models/enums.ts b/src/team/infrastructure/persistence/models/enums.ts similarity index 100% rename from src/teams/infrastructure/persistence/models/enums.ts rename to src/team/infrastructure/persistence/models/enums.ts diff --git a/src/teams/infrastructure/persistence/models/index.ts b/src/team/infrastructure/persistence/models/index.ts similarity index 62% rename from src/teams/infrastructure/persistence/models/index.ts rename to src/team/infrastructure/persistence/models/index.ts index 0054591..7d344a5 100644 --- a/src/teams/infrastructure/persistence/models/index.ts +++ b/src/team/infrastructure/persistence/models/index.ts @@ -1,2 +1,2 @@ -export { teamMembers, teams } from './teams.model'; +export { teamMembers, team } from './team.model'; export { type TeamRole, type TeamMemberStatus, roleEnum, statusEnum } from './enums'; diff --git a/src/teams/infrastructure/persistence/models/teams.model.ts b/src/team/infrastructure/persistence/models/team.model.ts similarity index 94% rename from src/teams/infrastructure/persistence/models/teams.model.ts rename to src/team/infrastructure/persistence/models/team.model.ts index 9a78f6f..1d4ffd8 100644 --- a/src/teams/infrastructure/persistence/models/teams.model.ts +++ b/src/team/infrastructure/persistence/models/team.model.ts @@ -4,7 +4,7 @@ import { primaryKey, timestamp, text, varchar, index } from 'drizzle-orm/pg-core import { roleEnum, statusEnum } from './enums'; -export const teams = baseSchema.table( +export const team = baseSchema.table( 'teams', { id: text('id') @@ -33,7 +33,7 @@ export const teamMembers = baseSchema.table( 'team_members', { teamId: text('team_id') - .references(() => teams.id, { onDelete: 'cascade' }) + .references(() => team.id, { onDelete: 'cascade' }) .notNull(), userId: text('user_id') .references(() => users.id, { onDelete: 'cascade' }) diff --git a/src/team/infrastructure/persistence/repositories/index.ts b/src/team/infrastructure/persistence/repositories/index.ts new file mode 100644 index 0000000..b87bc70 --- /dev/null +++ b/src/team/infrastructure/persistence/repositories/index.ts @@ -0,0 +1 @@ +export { TeamRepository } from './team.repository'; diff --git a/src/teams/infrastructure/persistence/repositories/teams.repository.ts b/src/team/infrastructure/persistence/repositories/team.repository.ts similarity index 82% rename from src/teams/infrastructure/persistence/repositories/teams.repository.ts rename to src/team/infrastructure/persistence/repositories/team.repository.ts index cc11935..75a9d9f 100644 --- a/src/teams/infrastructure/persistence/repositories/teams.repository.ts +++ b/src/team/infrastructure/persistence/repositories/team.repository.ts @@ -1,14 +1,14 @@ -import { ITeamsRepository } from '@core/teams/domain/repository'; import * as scUsers from '@core/user/infrastructure/persistence/models'; import { DATABASE_SERVICE, DatabaseService } from '@libs/database'; import { Inject } from '@nestjs/common'; import { and, desc, eq, isNull } from 'drizzle-orm'; +import { ITeamRepository } from '../../../domain/repository'; import * as schema from '../models'; -import type { NewTeam, NewTeamMember, Team, TeamMember } from '@core/teams/domain/entities'; +import type { NewTeam, NewTeamMember, Team, TeamMember } from '../../../domain/entities'; -export class TeamsRepository implements ITeamsRepository { +export class TeamRepository implements ITeamRepository { constructor( @Inject(DATABASE_SERVICE) private readonly db: DatabaseService, @@ -28,9 +28,9 @@ export class TeamsRepository implements ITeamsRepository { public create = async (ownerId: string, dto: NewTeam) => this.db.transaction(async (tx) => { const [team] = await tx - .insert(schema.teams) + .insert(schema.team) .values({ ...dto, ownerId }) - .returning({ teamId: schema.teams.id }); + .returning({ teamId: schema.team.id }); if (!team?.teamId) { throw new Error('Failed to create team: no team returned'); @@ -53,10 +53,10 @@ export class TeamsRepository implements ITeamsRepository { public update = async (id: string, dto: Partial) => this.db.transaction(async (tx) => { const [team] = await tx - .update(schema.teams) + .update(schema.team) .set(dto) - .where(eq(schema.teams.id, id)) - .returning({ teamId: schema.teams.id }); + .where(eq(schema.team.id, id)) + .returning({ teamId: schema.team.id }); if (!team?.teamId) { throw new Error('Failed to create team: no team returned'); @@ -70,11 +70,11 @@ export class TeamsRepository implements ITeamsRepository { public remove = async (teamId: string, userId: string) => { const result = await this.db - .update(schema.teams) + .update(schema.team) .set({ deletedAt: new Date().toISOString(), }) - .where(and(eq(schema.teams.id, teamId), eq(schema.teams.ownerId, userId))); + .where(and(eq(schema.team.id, teamId), eq(schema.team.ownerId, userId))); return (result?.count ?? 0) > 0; }; @@ -96,20 +96,20 @@ export class TeamsRepository implements ITeamsRepository { const filters = [ eq(schema.teamMembers.userId, userId), eq(schema.teamMembers.status, 'active'), - isNull(schema.teams.deletedAt), + isNull(schema.team.deletedAt), ]; const query = this.db .select({ - id: schema.teams.id, - name: schema.teams.name, - description: schema.teams.description, - avatarUrl: schema.teams.avatarUrl, + id: schema.team.id, + name: schema.team.name, + description: schema.team.description, + avatarUrl: schema.team.avatarUrl, role: schema.teamMembers.role, joinedAt: schema.teamMembers.joinedAt, }) .from(schema.teamMembers) - .innerJoin(schema.teams, eq(schema.teams.id, schema.teamMembers.teamId)) + .innerJoin(schema.team, eq(schema.team.id, schema.teamMembers.teamId)) .where(and(...filters)) .orderBy(desc(schema.teamMembers.joinedAt)); @@ -117,7 +117,7 @@ export class TeamsRepository implements ITeamsRepository { }; public findById = async (teamId: string) => { - const [team] = await this.db.select().from(schema.teams).where(eq(schema.teams.id, teamId)); + const [team] = await this.db.select().from(schema.team).where(eq(schema.team.id, teamId)); if (!team) { return null; } @@ -154,17 +154,17 @@ export class TeamsRepository implements ITeamsRepository { public async updateTeamAvatar(teamId: string, url: string): Promise { const result = await this.db - .update(schema.teams) + .update(schema.team) .set({ avatarUrl: url, updatedAt: new Date().toISOString() }) - .where(eq(schema.teams.id, teamId)); + .where(eq(schema.team.id, teamId)); return (result?.count ?? 0) > 0; } public async updateTeamBanner(teamId: string, url: string): Promise { const result = await this.db - .update(schema.teams) + .update(schema.team) .set({ coverUrl: url, updatedAt: new Date().toISOString() }) - .where(eq(schema.teams.id, teamId)); + .where(eq(schema.team.id, teamId)); return (result?.count ?? 0) > 0; } diff --git a/src/teams/infrastructure/workers/index.ts b/src/team/infrastructure/workers/index.ts similarity index 100% rename from src/teams/infrastructure/workers/index.ts rename to src/team/infrastructure/workers/index.ts diff --git a/src/teams/infrastructure/workers/mail.processor.ts b/src/team/infrastructure/workers/mail.processor.ts similarity index 92% rename from src/teams/infrastructure/workers/mail.processor.ts rename to src/team/infrastructure/workers/mail.processor.ts index ec6c065..07d29c3 100644 --- a/src/teams/infrastructure/workers/mail.processor.ts +++ b/src/team/infrastructure/workers/mail.processor.ts @@ -1,9 +1,10 @@ -import { TeamQueues } from '@core/teams/domain/enums'; -import { TeamInvitationEvent } from '@core/teams/domain/events'; import { Processor, WorkerHost } from '@nestjs/bullmq'; import { Inject } from '@nestjs/common'; import { IMailPort } from '@shared/adapters/mail'; +import { TeamQueues } from '../../domain/enums'; +import { TeamInvitationEvent } from '../../domain/events'; + import type { Job } from 'bullmq'; @Processor(TeamQueues.TEAM_MAIL) diff --git a/src/teams/teams.module.ts b/src/team/team.module.ts similarity index 56% rename from src/teams/teams.module.ts rename to src/team/team.module.ts index 92b3957..24a18c5 100644 --- a/src/teams/teams.module.ts +++ b/src/team/team.module.ts @@ -1,14 +1,13 @@ -import { MailProcessor } from '@core/teams/infrastructure/workers'; import { BullModule } from '@nestjs/bullmq'; import { Module } from '@nestjs/common'; import { - TeamsInvitationsController, - TeamsMembersController, - TeamsController, + TeamInvitationsController, + TeamMembersController, + TeamController, MeController, -} from './application/controller'; -import { TeamsFacade } from './application/team.facade'; +} from './application/controllers'; +import { TeamFacade } from './application/team.facade'; import { TeamQueries, TeamUseCases, @@ -18,9 +17,10 @@ import { import { TeamQueues } from './domain/enums'; import { TeamMemberPolicy } from './domain/policy'; import { LISTENERS } from './infrastructure/listeners'; -import { TeamsRepository } from './infrastructure/persistence/repositories'; +import { TeamRepository } from './infrastructure/persistence/repositories'; +import { MailProcessor } from './infrastructure/workers'; -const REPOSITORY = { provide: 'ITeamsRepository', useClass: TeamsRepository }; +const REPOSITORY = { provide: 'ITeamRepository', useClass: TeamRepository }; @Module({ imports: [ @@ -28,21 +28,16 @@ const REPOSITORY = { provide: 'ITeamsRepository', useClass: TeamsRepository }; name: TeamQueues.TEAM_MAIL, }), ], - controllers: [ - TeamsInvitationsController, - TeamsMembersController, - TeamsController, - MeController, - ], + controllers: [TeamInvitationsController, TeamMembersController, TeamController, MeController], providers: [ TeamMemberPolicy, REPOSITORY, ...LISTENERS, ...TeamUseCases, ...TeamQueries, - TeamsFacade, + TeamFacade, MailProcessor, ], exports: [...TEAM_EXTERNAL_QUERIES, ...TEAM_EXTERNAL_COMMANDS], }) -export class TeamsModule {} +export class TeamModule {} diff --git a/src/teams/application/controller/index.ts b/src/teams/application/controller/index.ts deleted file mode 100644 index cb32def..0000000 --- a/src/teams/application/controller/index.ts +++ /dev/null @@ -1,4 +0,0 @@ -export { MeController } from './me/controller'; -export { TeamsController } from './teams/controller'; -export { TeamsMembersController } from './members/controller'; -export { TeamsInvitationsController } from './invitations/controller'; diff --git a/src/teams/domain/entities/index.ts b/src/teams/domain/entities/index.ts deleted file mode 100644 index 40d100b..0000000 --- a/src/teams/domain/entities/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './teams.domain'; diff --git a/src/teams/domain/repository/index.ts b/src/teams/domain/repository/index.ts deleted file mode 100644 index 0d97b36..0000000 --- a/src/teams/domain/repository/index.ts +++ /dev/null @@ -1,5 +0,0 @@ -export { - ITeamsRepository, - type RawMemberRow, - type RawMemberTeams, -} from './teams.repository.interface'; diff --git a/src/teams/infrastructure/persistence/repositories/index.ts b/src/teams/infrastructure/persistence/repositories/index.ts deleted file mode 100644 index 259ca0a..0000000 --- a/src/teams/infrastructure/persistence/repositories/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { TeamsRepository } from './teams.repository'; From 13aa72a6924e64178394ad72319cd5388296db2a Mon Sep 17 00:00:00 2001 From: Maxim Date: Thu, 18 Jun 2026 20:52:47 +0300 Subject: [PATCH 5/5] refactor(teams): update imports --- src/auth/auth.module.ts | 3 +-- src/auth/infrastructure/workers/user.processor.ts | 3 +-- src/project/application/mappers/project.mapper.ts | 2 +- .../application/use-cases/project/find-one.query.ts | 3 +-- src/project/domain/policy/project-access.policy.ts | 5 ++--- src/project/project.module.ts | 3 +-- src/shared/authorization/authorization.spec.ts | 4 ++-- .../authorization/permissions/permissions-map.ts | 2 +- src/shared/constants/roles.constant.ts | 2 +- .../controllers/invitations/controller.ts | 2 +- src/team/application/controllers/me/controller.ts | 3 +-- src/team/application/controllers/me/swagger.ts | 3 +-- .../application/controllers/members/controller.ts | 5 ++--- src/team/application/controllers/members/swagger.ts | 13 ++++++------- src/team/application/controllers/team/controller.ts | 5 ++--- src/team/application/controllers/team/swagger.ts | 9 ++++++--- .../use-cases/base/delete-team.use-case.ts | 3 +-- 17 files changed, 31 insertions(+), 39 deletions(-) diff --git a/src/auth/auth.module.ts b/src/auth/auth.module.ts index 04fa746..c75e30a 100644 --- a/src/auth/auth.module.ts +++ b/src/auth/auth.module.ts @@ -1,4 +1,5 @@ import { ProjectModule } from '@core/project'; +import { TeamModule } from '@core/team'; import { UserModule } from '@core/user'; import { BullModule } from '@nestjs/bullmq'; import { forwardRef, Module } from '@nestjs/common'; @@ -6,8 +7,6 @@ import { ConfigService } from '@nestjs/config'; import { JwtModule } from '@nestjs/jwt'; import { MailAdapter } from '@shared/adapters/mail'; -import { TeamModule } from '../team'; - import { AuthFacade } from './application/auth.facade'; import { CONTROLLERS } from './application/controllers'; import { AuthUseCases } from './application/use-cases'; diff --git a/src/auth/infrastructure/workers/user.processor.ts b/src/auth/infrastructure/workers/user.processor.ts index eb57eec..5907863 100644 --- a/src/auth/infrastructure/workers/user.processor.ts +++ b/src/auth/infrastructure/workers/user.processor.ts @@ -2,12 +2,11 @@ import { AuthQueues } from '@core/auth/domain/enums'; import { AuthUserJobs } from '@core/auth/domain/enums/auth-jobs.enum'; import { CreateUserWorkspaceEvent } from '@core/auth/domain/events'; import { CreateProjectUseCase } from '@core/project/application/use-cases'; +import { CreateTeamUseCase } from '@core/team/application/use-cases'; import { Processor, WorkerHost } from '@nestjs/bullmq'; import { Job } from 'bullmq'; import slugify from 'slugify'; -import { CreateTeamUseCase } from '../../../team/application/use-cases'; - @Processor(AuthQueues.AUTH_USER) export class UserProcessor extends WorkerHost { constructor( diff --git a/src/project/application/mappers/project.mapper.ts b/src/project/application/mappers/project.mapper.ts index e90343f..e4aeaa2 100644 --- a/src/project/application/mappers/project.mapper.ts +++ b/src/project/application/mappers/project.mapper.ts @@ -1,5 +1,5 @@ -import type { RawMemberRow } from '../../../team/domain/repository'; import type { Project } from '@core/project/domain/entities'; +import type { RawMemberRow } from '@core/team/domain/repository'; export class ProjectMapper { public static toDetailResponse(project: Project, member?: RawMemberRow | null, token?: string) { diff --git a/src/project/application/use-cases/project/find-one.query.ts b/src/project/application/use-cases/project/find-one.query.ts index 1aef4d9..25c9cc8 100644 --- a/src/project/application/use-cases/project/find-one.query.ts +++ b/src/project/application/use-cases/project/find-one.query.ts @@ -2,12 +2,11 @@ import { createHash } from 'node:crypto'; import { ProjectErrorCodes, ProjectErrorMessages } from '@core/project/domain/errors'; import { IProjectRepository } from '@core/project/domain/repository'; +import { FindTeamMemberQuery, FindTeamQuery } from '@core/team'; import { HttpStatus, Inject, Injectable } from '@nestjs/common'; import { isTeamRole, ROLE_PRIORITY } from '@shared/constants'; import { BaseException } from '@shared/error'; -import { FindTeamMemberQuery, FindTeamQuery } from '../../../../team'; - import type { Project } from '@core/project/domain/entities'; @Injectable() diff --git a/src/project/domain/policy/project-access.policy.ts b/src/project/domain/policy/project-access.policy.ts index 078a16d..88df8e8 100644 --- a/src/project/domain/policy/project-access.policy.ts +++ b/src/project/domain/policy/project-access.policy.ts @@ -1,9 +1,8 @@ +import { FindTeamMemberQuery, FindTeamQuery } from '@core/team'; import { HttpStatus, Inject, Injectable } from '@nestjs/common'; -import { ROLE_PRIORITY, PROJECT_ROLE_PRIORITY } from '@shared/constants'; +import { ROLE_PRIORITY, PROJECT_ROLE_PRIORITY, isTeamRole } from '@shared/constants'; import { BaseException } from '@shared/error'; -import { isTeamRole } from '../../../shared/constants/roles.constant'; -import { FindTeamMemberQuery, FindTeamQuery } from '../../../team'; import { ProjectErrorCodes, ProjectErrorMessages } from '../errors'; import { MemberErrorCodes, MemberErrorMessages } from '../errors/member.errors'; import { IMemberRepository, IProjectRepository } from '../repository'; diff --git a/src/project/project.module.ts b/src/project/project.module.ts index 6a894ce..bcb0685 100644 --- a/src/project/project.module.ts +++ b/src/project/project.module.ts @@ -1,8 +1,7 @@ +import { TeamModule } from '@core/team'; import { UserModule } from '@core/user'; import { forwardRef, Module } from '@nestjs/common'; -import { TeamModule } from '../team'; - import { CONTROLLERS } from './application/controllers'; import { ProjectFacade } from './application/project.facade'; import { CreateProjectUseCase, FindProjectQuery, USE_CASES } from './application/use-cases'; diff --git a/src/shared/authorization/authorization.spec.ts b/src/shared/authorization/authorization.spec.ts index 8b0cb8e..0f10cd8 100644 --- a/src/shared/authorization/authorization.spec.ts +++ b/src/shared/authorization/authorization.spec.ts @@ -6,8 +6,8 @@ import { describe, expect } from 'vitest'; import { AbilityFactory } from './ability.factory'; -import type { RawMemberRow } from '../../team/domain/repository'; -import type { TeamRole } from '../../team/infrastructure/persistence/models'; +import type { RawMemberRow } from '@core/team/domain/repository'; +import type { TeamRole } from '@core/team/infrastructure/persistence/models'; describe('AuthorizationService - Permissions Matrix', () => { const findTeamMemberMock = { diff --git a/src/shared/authorization/permissions/permissions-map.ts b/src/shared/authorization/permissions/permissions-map.ts index c02e2f2..3e8f74d 100644 --- a/src/shared/authorization/permissions/permissions-map.ts +++ b/src/shared/authorization/permissions/permissions-map.ts @@ -3,7 +3,7 @@ import { MEMBER_PERMISSIONS } from '@shared/authorization/permissions/member.per import { OWNER_PERMISSIONS } from '@shared/authorization/permissions/owner.permissions'; import { VIEWER_PERMISSIONS } from '@shared/authorization/permissions/viewer.permissions'; -import type { TeamRole } from '../../../team/infrastructure/persistence/models'; +import type { TeamRole } from '@core/team/infrastructure/persistence/models'; import type { PermissionRule } from '@shared/authorization/types/permission-rule.interface'; export const ROLE_PERMISSIONS_MAP: Record = { diff --git a/src/shared/constants/roles.constant.ts b/src/shared/constants/roles.constant.ts index 3fbe63e..5069be2 100644 --- a/src/shared/constants/roles.constant.ts +++ b/src/shared/constants/roles.constant.ts @@ -1,4 +1,4 @@ -import type { TeamRole } from '../../team/infrastructure/persistence/models'; +import type { TeamRole } from '@core/team/infrastructure/persistence/models'; export const TEAM_ROLES = ['owner', 'admin', 'member', 'viewer'] as const; diff --git a/src/team/application/controllers/invitations/controller.ts b/src/team/application/controllers/invitations/controller.ts index 0494d0b..82902af 100644 --- a/src/team/application/controllers/invitations/controller.ts +++ b/src/team/application/controllers/invitations/controller.ts @@ -1,8 +1,8 @@ +import { TeamFacade } from '@core/team/application/team.facade'; import { Body, Get, Param, Delete, Patch, Post } from '@nestjs/common'; import { ApiBaseController, GetUser, GetUserId } from '@shared/decorators'; import { InviteMemberDto, UpdateInvitationDto } from '../../dtos'; -import { TeamFacade } from '../../team.facade'; import { AcceptInviteSwagger, diff --git a/src/team/application/controllers/me/controller.ts b/src/team/application/controllers/me/controller.ts index 05fbb51..15660af 100644 --- a/src/team/application/controllers/me/controller.ts +++ b/src/team/application/controllers/me/controller.ts @@ -1,8 +1,7 @@ +import { TeamFacade } from '@core/team/application/team.facade'; import { Get } from '@nestjs/common'; import { ApiBaseController, GetUser, GetUserId } from '@shared/decorators'; -import { TeamFacade } from '../../team.facade'; - import { FindInvitesSwagger, FindTeamsSwagger } from './swagger'; import type { JwtPayload } from '@shared/types'; diff --git a/src/team/application/controllers/me/swagger.ts b/src/team/application/controllers/me/swagger.ts index fe316ad..f540566 100644 --- a/src/team/application/controllers/me/swagger.ts +++ b/src/team/application/controllers/me/swagger.ts @@ -1,10 +1,9 @@ +import { UserInvitesResponse, UserTeamsResponse } from '@core/team/application/dtos'; import { applyDecorators, SetMetadata } from '@nestjs/common'; import { ApiOperation, ApiResponse } from '@nestjs/swagger'; import { ApiUnauthorized } from '@shared/error'; import { ZOD_RESPONSE_TOKEN } from '@shared/interceptors'; -import { UserTeamsResponse, UserInvitesResponse } from '../../dtos'; - export const FindTeamsSwagger = () => applyDecorators( ApiOperation({ diff --git a/src/team/application/controllers/members/controller.ts b/src/team/application/controllers/members/controller.ts index 8408acf..42b75c3 100644 --- a/src/team/application/controllers/members/controller.ts +++ b/src/team/application/controllers/members/controller.ts @@ -1,9 +1,8 @@ +import { UpdateMemberDto } from '@core/team/application/dtos'; +import { TeamFacade } from '@core/team/application/team.facade'; import { Body, Delete, Get, Param, Patch } from '@nestjs/common'; import { ApiBaseController, GetUserId } from '@shared/decorators'; -import { UpdateMemberDto } from '../../dtos'; -import { TeamFacade } from '../../team.facade'; - import { GetMembersSwagger, RemoveMemberSwagger, UpdateMemberSwagger } from './swagger'; @ApiBaseController('teams/:teamId', 'Teams Members', true) diff --git a/src/team/application/controllers/members/swagger.ts b/src/team/application/controllers/members/swagger.ts index 0fda3d2..c1a9076 100644 --- a/src/team/application/controllers/members/swagger.ts +++ b/src/team/application/controllers/members/swagger.ts @@ -1,16 +1,15 @@ +import { + TeamMembersResponse, + UpdateMemberDto, + UserInvitesResponse, + UserTeamsResponse, +} from '@core/team/application/dtos'; import { applyDecorators, SetMetadata } from '@nestjs/common'; import { ApiBody, ApiOperation, ApiParam, ApiResponse } from '@nestjs/swagger'; import { ApiForbidden, ApiNotFound, ApiUnauthorized } from '@shared/error'; import { ZOD_RESPONSE_TOKEN } from '@shared/interceptors'; import { ActionResponse } from '@shared/schemas'; -import { - UpdateMemberDto, - TeamMembersResponse, - UserTeamsResponse, - UserInvitesResponse, -} from '../../dtos'; - export const FindTeamsSwagger = () => applyDecorators( ApiOperation({ diff --git a/src/team/application/controllers/team/controller.ts b/src/team/application/controllers/team/controller.ts index e297c08..2011b26 100644 --- a/src/team/application/controllers/team/controller.ts +++ b/src/team/application/controllers/team/controller.ts @@ -1,9 +1,8 @@ +import { CreateTeamDto, UpdateTeamDto } from '@core/team/application/dtos'; +import { TeamFacade } from '@core/team/application/team.facade'; import { Body, Delete, Get, HttpCode, HttpStatus, Param, Patch, Post } from '@nestjs/common'; import { ApiBaseController, GetUserId } from '@shared/decorators'; -import { CreateTeamDto, UpdateTeamDto } from '../../dtos'; -import { TeamFacade } from '../../team.facade'; - import { CreateTeamSwagger, FindOneTeamSwagger, diff --git a/src/team/application/controllers/team/swagger.ts b/src/team/application/controllers/team/swagger.ts index 318bade..a6e1462 100644 --- a/src/team/application/controllers/team/swagger.ts +++ b/src/team/application/controllers/team/swagger.ts @@ -1,12 +1,15 @@ +import { + CreateTeamDto, + CreateTeamResponse, + TeamResponse, + UpdateTeamDto, +} from '@core/team/application/dtos'; import { applyDecorators, SetMetadata } from '@nestjs/common'; import { ApiBody, ApiOperation, ApiParam, ApiResponse } from '@nestjs/swagger'; import { ApiForbidden, ApiNotFound, ApiUnauthorized, ApiValidationError } from '@shared/error'; import { ZOD_RESPONSE_TOKEN } from '@shared/interceptors'; import { ActionResponse } from '@shared/schemas'; -import { CreateTeamResponse } from '../../../application/dtos/team.dto'; -import { CreateTeamDto, UpdateTeamDto, TeamResponse } from '../../dtos'; - export const CreateTeamSwagger = () => applyDecorators( ApiOperation({ summary: 'Создать новую команду' }), diff --git a/src/team/application/use-cases/base/delete-team.use-case.ts b/src/team/application/use-cases/base/delete-team.use-case.ts index 738f586..44d95b1 100644 --- a/src/team/application/use-cases/base/delete-team.use-case.ts +++ b/src/team/application/use-cases/base/delete-team.use-case.ts @@ -1,11 +1,10 @@ +import { ITeamRepository, RawMemberRow } from '@core/team/domain/repository'; import { Inject, Injectable, HttpStatus } from '@nestjs/common'; import { AbilityFactory } from '@shared/authorization/ability.factory'; import { Action } from '@shared/authorization/types/action.enum'; import { Subject } from '@shared/authorization/types/subject.enum'; import { BaseException } from '@shared/error'; -import { ITeamRepository, RawMemberRow } from '../../../domain/repository'; - @Injectable() export class DeleteTeamUseCase { constructor(