From 8c8a71f2564786d7a21753d9d9a0eadfeddef7bf 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/6] chore: update dto, resolve error message at exchange and add condition (#99) --- libs/bootstrap/src/bootstrap.ts | 28 +++- .../controllers/auth/controller.ts | 30 +--- .../controllers/oauth/controller.ts | 15 +- .../application/controllers/oauth/swagger.ts | 43 ++++-- src/auth/application/dtos/oauth.dto.ts | 18 +-- .../use-cases/auth/sign-in.use-case.ts | 1 + .../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 | 9 +- .../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 | 49 ++++++ src/auth/domain/errors/oauth.error.ts | 12 -- .../infrastructure/security/token.service.ts | 31 +++- .../strategies/bearer.strategy.ts | 37 ++++- .../strategies/cookie.strategy.ts | 19 +++ .../strategies/github.strategy.ts | 2 +- 21 files changed, 330 insertions(+), 335 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..ddff267 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,27 @@ export async function bootstrapApp(options: BootstrapOptions) { } if (useCookieParser) { const secret = configService.getOrThrow('COOKIE_SECRET'); - await app.register(fastifyCookie, { secret }); + const domainCookie = domain ? `.${domain}` : undefined; + await app.register(fastifyCookie, { + secret, + parseOptions: { + httpOnly: true, + sameSite: 'lax', + signed: true, + secure: isProduction, + path: '/', + domain: domainCookie, + }, + }); await app.register(fastifyCsrf, { cookieOpts: { - signed: true, httpOnly: true, - sameSite: 'strict', - secure: configService.getOrThrow('NODE_ENV') === 'production', + sameSite: 'lax', + secure: isProduction, + path: '/', + domain: domainCookie, }, + 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..6313c69 100644 --- a/src/auth/application/controllers/auth/controller.ts +++ b/src/auth/application/controllers/auth/controller.ts @@ -1,6 +1,5 @@ import { getDeviceMeta } from '@core/auth/infrastructure/utils'; import { Body, HttpCode, HttpStatus, Post, Req, Res, UseGuards } from '@nestjs/common'; -import { ConfigService } from '@nestjs/config'; import { ApiBaseController } from '@shared/decorators'; import { BearerAuthGuard, CookieAuthGuard } from '@shared/guards'; @@ -20,16 +19,7 @@ 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'); - } + constructor(private readonly facade: AuthFacade) {} @Post('sign-up') @PostRegisterSwagger() @@ -82,14 +72,8 @@ export class AuthController { @PostLogoutSwagger() async logout(@Res({ passthrough: true }) res: FastifyReply, @Req() req: FastifyRequest) { const session = req.cookies?.['refresh']; - const response = await this.facade.signOut(session); - - res.clearCookie('refresh', { - path: '/', - domain: this.domain ? `.${this.domain}` : undefined, - }); - + res.clearCookie('refresh'); return response; } @@ -107,14 +91,10 @@ export class AuthController { return { token: tokens.access, ...response }; } - private setRefreshCookie(res: FastifyReply, refreshToken: string, expires: Date) { - res.setCookie('refresh', refreshToken, { - httpOnly: true, - secure: this.isProduction, - path: '/', + private setRefreshCookie(res: FastifyReply, token: string, expires: Date) { + res.setCookie('refresh', token, { + signed: false, 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-in.use-case.ts b/src/auth/application/use-cases/auth/sign-in.use-case.ts index f4d55a0..23b09ec 100644 --- a/src/auth/application/use-cases/auth/sign-in.use-case.ts +++ b/src/auth/application/use-cases/auth/sign-in.use-case.ts @@ -55,6 +55,7 @@ export class SignInUseCase { HttpStatus.UNAUTHORIZED, ); } + try { const sessionId = createId(); const { access, refresh, expiresAt } = await this.tokenService.generateTokens( 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..b937181 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,12 @@ export class ConnectOAuthProviderUseCase { `oauth:state:${state}`, ]); - return { user, isConnect: true, isNewUser: false }; + const query = new URLSearchParams({ + success: 'true', + provider: dto.provider, + }); + + 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..d5ae0ba --- /dev/null +++ b/src/auth/application/use-cases/oauth/process-oauth-sign.use-case.ts @@ -0,0 +1,49 @@ +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', + provider: dto.provider, + }); + + 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/security/token.service.ts b/src/auth/infrastructure/security/token.service.ts index 159291b..19911cf 100644 --- a/src/auth/infrastructure/security/token.service.ts +++ b/src/auth/infrastructure/security/token.service.ts @@ -12,15 +12,19 @@ export class TokenService { ) {} async generateTokens(user: { id: string; email: string }, sessionId: string) { - const iss = this.cfg.getOrThrow('JWT_ISSUER'); - const aud = this.cfg.getOrThrow('JWT_AUDIENCE'); + const issuer = this.cfg.getOrThrow('JWT_ISSUER'); + const audience = this.cfg.getOrThrow('JWT_AUDIENCE'); + + const now = Math.floor(Date.now() / 1000); const payload = { - jti: sessionId, - sub: user.id, email: user.email, - iss, - aud, + }; + + const sharedPayload = { + issuer, + audience, + notBefore: 0, }; /* eslint-disable-next-line @typescript-eslint/no-explicit-any */ @@ -30,10 +34,16 @@ export class TokenService { const [access, refresh] = await Promise.all([ this.jwtService.signAsync(payload, { + ...sharedPayload, secret: this.cfg.get('JWT_ACCESS_SECRET'), + subject: user.id, expiresIn: accessExp, + jwtid: `${sessionId}_access_${now}`, }), this.jwtService.signAsync(payload, { + ...sharedPayload, + jwtid: sessionId, + subject: user.id, secret: this.cfg.get('JWT_REFRESH_SECRET'), expiresIn: refreshExp, }), @@ -48,10 +58,17 @@ export class TokenService { try { const accessSecret = this.cfg.get('JWT_ACCESS_SECRET'); const refreshSecret = this.cfg.get('JWT_REFRESH_SECRET'); + const audience = this.cfg.get('JWT_AUDIENCE'); + const issuer = this.cfg.get('JWT_ISSUER'); const secret = type === 'access' ? accessSecret : refreshSecret; - return this.jwtService.verifyAsync(token, { secret }); + return this.jwtService.verifyAsync(token, { + secret, + issuer, + audience, + clockTolerance: 30, + }); } catch { return null; } diff --git a/src/auth/infrastructure/strategies/bearer.strategy.ts b/src/auth/infrastructure/strategies/bearer.strategy.ts index 9107022..7954beb 100644 --- a/src/auth/infrastructure/strategies/bearer.strategy.ts +++ b/src/auth/infrastructure/strategies/bearer.strategy.ts @@ -1,6 +1,7 @@ -import { Injectable } from '@nestjs/common'; +import { HttpStatus, Injectable } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { PassportStrategy } from '@nestjs/passport'; +import { BaseException } from '@shared/error'; import { Strategy, ExtractJwt } from 'passport-jwt'; import type { JwtPayload } from '@shared/types'; @@ -15,10 +16,44 @@ export class BearerStrategy extends PassportStrategy(Strategy, 'bearer') { secretOrKey: cfg.getOrThrow('JWT_ACCESS_SECRET'), issuer: cfg.getOrThrow('JWT_ISSUER'), audience, + ignoreExpiration: false, + jsonWebTokenOptions: { clockTolerance: 30 }, }); } validate(payload: JwtPayload) { + if (!payload || !payload.jti) { + throw new BaseException( + { + code: 'INVALID_ACCESS_TOKEN', + message: 'Access токен невалиден или протух', + details: [ + { + target: 'auth', + reason: 'Payload is missing or jti is invalid', + }, + ], + }, + HttpStatus.UNAUTHORIZED, + ); + } + + if (!payload.jti.includes('_access_')) { + throw new BaseException( + { + code: 'WRONG_TOKEN_TYPE', + message: 'Ожидался access токен', + details: [ + { + target: 'auth', + reason: 'Token type mismatch. Access token required.', + }, + ], + }, + HttpStatus.UNAUTHORIZED, + ); + } + return payload; } } diff --git a/src/auth/infrastructure/strategies/cookie.strategy.ts b/src/auth/infrastructure/strategies/cookie.strategy.ts index 96b8968..6d7e759 100644 --- a/src/auth/infrastructure/strategies/cookie.strategy.ts +++ b/src/auth/infrastructure/strategies/cookie.strategy.ts @@ -19,6 +19,9 @@ export class CookieStrategy extends PassportStrategy(Strategy, 'cookie') { ]), secretOrKey: configService.getOrThrow('JWT_REFRESH_SECRET'), passReqToCallback: true, + ignoreExpiration: false, + issuer: configService.getOrThrow('JWT_ISSUER'), + audience: configService.getOrThrow('JWT_AUDIENCE'), }); } @@ -34,6 +37,22 @@ export class CookieStrategy extends PassportStrategy(Strategy, 'cookie') { ); } + if (payload.jti?.includes('_access_')) { + throw new BaseException( + { + code: 'WRONG_TOKEN_TYPE', + message: 'Ожидался refresh токен, но получен access', + details: [ + { + target: 'auth', + reason: 'Token type mismatch', + }, + ], + }, + HttpStatus.UNAUTHORIZED, + ); + } + return payload; } } 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 aeb8b681b13d4598c14d89c7effe88611c8727b6 Mon Sep 17 00:00:00 2001 From: soorq Date: Tue, 16 Jun 2026 21:45:34 +0300 Subject: [PATCH 2/6] feat: issue cascades --- src/app.module.ts | 2 ++ src/issue/application/controllers/index.ts | 1 + src/issue/application/dtos/index.ts | 1 + src/issue/application/issue.facade.ts | 6 ++++++ src/issue/domain/entities/index.ts | 1 + src/issue/domain/repositories/index.ts | 1 + src/issue/index.ts | 1 + src/issue/infrastructure/persistence/models/index.ts | 1 + .../infrastructure/persistence/repositories/index.ts | 6 ++++++ src/issue/issue.module.ts | 12 ++++++++++++ 10 files changed, 32 insertions(+) create mode 100644 src/issue/application/controllers/index.ts create mode 100644 src/issue/application/dtos/index.ts create mode 100644 src/issue/application/issue.facade.ts create mode 100644 src/issue/domain/entities/index.ts create mode 100644 src/issue/domain/repositories/index.ts create mode 100644 src/issue/index.ts create mode 100644 src/issue/infrastructure/persistence/models/index.ts create mode 100644 src/issue/infrastructure/persistence/repositories/index.ts create mode 100644 src/issue/issue.module.ts diff --git a/src/app.module.ts b/src/app.module.ts index 3288e74..15a2c7f 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -19,6 +19,7 @@ import { ZodValidationPipe } from 'nestjs-zod'; import { AreaModule } from './area'; import { AuthModule } from './auth/auth.module'; +import { IssueModule } from './issue'; import { ProjectModule } from './project'; import * as schema from './shared/entities'; import { TeamsModule } from './teams'; @@ -55,6 +56,7 @@ import { UserModule } from './user'; TeamsModule, ProjectModule, AreaModule, + IssueModule, MetricsModule, HealthModule.registerAsync({ inject: [DatabaseHealthService, S3Service, CACHE_SERVICE], diff --git a/src/issue/application/controllers/index.ts b/src/issue/application/controllers/index.ts new file mode 100644 index 0000000..8ea5115 --- /dev/null +++ b/src/issue/application/controllers/index.ts @@ -0,0 +1 @@ +export const CONTROLLERS = []; diff --git a/src/issue/application/dtos/index.ts b/src/issue/application/dtos/index.ts new file mode 100644 index 0000000..a4a9fdc --- /dev/null +++ b/src/issue/application/dtos/index.ts @@ -0,0 +1 @@ +export {} from './'; diff --git a/src/issue/application/issue.facade.ts b/src/issue/application/issue.facade.ts new file mode 100644 index 0000000..b1d7ced --- /dev/null +++ b/src/issue/application/issue.facade.ts @@ -0,0 +1,6 @@ +import { Injectable } from '@nestjs/common'; + +@Injectable() +export class IssueFacade { + constructor() {} +} diff --git a/src/issue/domain/entities/index.ts b/src/issue/domain/entities/index.ts new file mode 100644 index 0000000..a4a9fdc --- /dev/null +++ b/src/issue/domain/entities/index.ts @@ -0,0 +1 @@ +export {} from './'; diff --git a/src/issue/domain/repositories/index.ts b/src/issue/domain/repositories/index.ts new file mode 100644 index 0000000..a4a9fdc --- /dev/null +++ b/src/issue/domain/repositories/index.ts @@ -0,0 +1 @@ +export {} from './'; diff --git a/src/issue/index.ts b/src/issue/index.ts new file mode 100644 index 0000000..85ce7b7 --- /dev/null +++ b/src/issue/index.ts @@ -0,0 +1 @@ +export { IssueModule } from './issue.module'; diff --git a/src/issue/infrastructure/persistence/models/index.ts b/src/issue/infrastructure/persistence/models/index.ts new file mode 100644 index 0000000..a4a9fdc --- /dev/null +++ b/src/issue/infrastructure/persistence/models/index.ts @@ -0,0 +1 @@ +export {} from './'; diff --git a/src/issue/infrastructure/persistence/repositories/index.ts b/src/issue/infrastructure/persistence/repositories/index.ts new file mode 100644 index 0000000..4e1a2b5 --- /dev/null +++ b/src/issue/infrastructure/persistence/repositories/index.ts @@ -0,0 +1,6 @@ +export const REPOSITORIES = [ + // { + // provide: '', + // useClass: null, + // }, +]; diff --git a/src/issue/issue.module.ts b/src/issue/issue.module.ts new file mode 100644 index 0000000..28fe46f --- /dev/null +++ b/src/issue/issue.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common'; + +import { CONTROLLERS } from './application/controllers'; +import { IssueFacade } from './application/issue.facade'; +import { REPOSITORIES } from './infrastructure/persistence/repositories'; + +@Module({ + imports: [], + controllers: CONTROLLERS, + providers: [...REPOSITORIES, IssueFacade], +}) +export class IssueModule {} From 80c7667619c9c41f9503e0796dd4a455f4accf0b Mon Sep 17 00:00:00 2001 From: soorq Date: Tue, 16 Jun 2026 22:27:23 +0300 Subject: [PATCH 3/6] feat(issue): bootstrap issue tracking module and CRUD --- src/issue/application/controllers/index.ts | 4 +- .../controllers/issues/controller.ts | 80 +++ .../application/controllers/issues/swagger.ts | 458 ++++++++++++++++++ src/issue/application/dtos/index.ts | 2 +- src/issue/application/dtos/issue.dto.ts | 14 + src/issue/application/issue.facade.ts | 46 +- .../use-cases/base/assign.use-case.ts | 18 + .../use-cases/base/create.use-case.ts | 17 + .../use-cases/base/delete.use-case.ts | 10 + .../use-cases/base/get-all.query.ts | 12 + .../use-cases/base/get-one.query.ts | 10 + .../use-cases/base/move.use-case.ts | 18 + .../use-cases/base/update.use-case.ts | 18 + src/issue/application/use-cases/index.ts | 25 + src/issue/issue.module.ts | 3 +- 15 files changed, 731 insertions(+), 4 deletions(-) create mode 100644 src/issue/application/controllers/issues/controller.ts create mode 100644 src/issue/application/controllers/issues/swagger.ts create mode 100644 src/issue/application/dtos/issue.dto.ts create mode 100644 src/issue/application/use-cases/base/assign.use-case.ts create mode 100644 src/issue/application/use-cases/base/create.use-case.ts create mode 100644 src/issue/application/use-cases/base/delete.use-case.ts create mode 100644 src/issue/application/use-cases/base/get-all.query.ts create mode 100644 src/issue/application/use-cases/base/get-one.query.ts create mode 100644 src/issue/application/use-cases/base/move.use-case.ts create mode 100644 src/issue/application/use-cases/base/update.use-case.ts create mode 100644 src/issue/application/use-cases/index.ts diff --git a/src/issue/application/controllers/index.ts b/src/issue/application/controllers/index.ts index 8ea5115..be17c2d 100644 --- a/src/issue/application/controllers/index.ts +++ b/src/issue/application/controllers/index.ts @@ -1 +1,3 @@ -export const CONTROLLERS = []; +import { IssuesController } from './issues/controller'; + +export const CONTROLLERS = [IssuesController]; diff --git a/src/issue/application/controllers/issues/controller.ts b/src/issue/application/controllers/issues/controller.ts new file mode 100644 index 0000000..5523ddc --- /dev/null +++ b/src/issue/application/controllers/issues/controller.ts @@ -0,0 +1,80 @@ +import { + Body, + Delete, + Get, + HttpCode, + HttpStatus, + Param, + Patch, + Post, + Put, + Query, +} from '@nestjs/common'; +import { ApiBaseController, GetUserId } from '@shared/decorators'; + +import { + AssignIssueDto, + CreateIssueDto, + IssueQueryDto, + MoveIssueDto, + UpdateIssueDto, +} from '../../dtos'; +import { IssueFacade } from '../../issue.facade'; + +import { + AssignIssueSwagger, + CreateIssueSwagger, + DeleteIssueSwagger, + GetAllIssuesSwagger, + GetOneIssueSwagger, + MoveIssueSwagger, + UpdateIssueSwagger, +} from './swagger'; + +@ApiBaseController('issues', 'Issues', true) +export class IssuesController { + constructor(private readonly facade: IssueFacade) {} + + @Post() + @CreateIssueSwagger() + create(@Body() dto: CreateIssueDto, @GetUserId() userId: string) { + return this.facade.create(dto, userId); + } + + @Get(':id') + @GetOneIssueSwagger() + getById(@Param('id') id: string, @GetUserId() userId: string) { + return this.facade.getOne(id, userId); + } + + @Get() + @GetAllIssuesSwagger() + getAll(@Query('boardId') query: IssueQueryDto, @GetUserId() userId: string) { + return this.facade.getAll(query, userId); + } + + @Patch(':id') + @UpdateIssueSwagger() + update(@Param('id') id: string, @Body() dto: UpdateIssueDto, @GetUserId() userId: string) { + return this.facade.update(id, dto, userId); + } + + @Post(':id/move') + @MoveIssueSwagger() + @HttpCode(HttpStatus.OK) + move(@Param('id') id: string, @Body() dto: MoveIssueDto, @GetUserId() userId: string) { + return this.facade.move(id, dto, userId); + } + + @Put(':id/assignee') + @AssignIssueSwagger() + assign(@Param('id') id: string, @Body() dto: AssignIssueDto, @GetUserId() userId: string) { + return this.facade.assign(id, dto, userId); + } + + @Delete(':id') + @DeleteIssueSwagger() + delete(@Param('id') id: string, @GetUserId() userId: string) { + return this.facade.delete(id, userId); + } +} diff --git a/src/issue/application/controllers/issues/swagger.ts b/src/issue/application/controllers/issues/swagger.ts new file mode 100644 index 0000000..e1f5189 --- /dev/null +++ b/src/issue/application/controllers/issues/swagger.ts @@ -0,0 +1,458 @@ +import { applyDecorators, SetMetadata } from '@nestjs/common'; +import { ApiBody, ApiOperation, ApiParam, ApiQuery, ApiResponse } from '@nestjs/swagger'; +import { ApiListQuery } from '@shared/decorators'; +import { + ApiConflict, + ApiForbidden, + ApiNotFound, + ApiUnauthorized, + ApiValidationError, +} from '@shared/error'; +import { ZOD_RESPONSE_TOKEN } from '@shared/interceptors'; +import { ActionResponse } from '@shared/schemas'; + +import { AssignIssueDto, CreateIssueDto, MoveIssueDto, UpdateIssueDto } from '../../dtos'; + +export const CreateIssueSwagger = () => + applyDecorators( + ApiOperation({ + summary: 'Создать новую задачу', + description: [ + 'Создаёт задачу на доске. По сути это карточка, которая появляется в одной из колонок.', + '', + '### Как работает', + '- Задача всегда создаётся в определённой колонке на доске (состоянии)', + '- Если колонка не указана — задача попадёт в крайнюю левую колонку (обычно «Бэклог»)', + '- Можно сразу назначить исполнителя, указать приоритет и тип задачи', + '', + '### Типы задач', + '- `TASK` — обычная задача', + '- `BUG` — баг (автоматически считается критичным)', + '- `EPIC` — эпик (крупная задача, может содержать подзадачи)', + '', + '### Приоритеты', + '- `LOW` — низкий (можно отложить)', + '- `MEDIUM` — средний (по умолчанию)', + '- `HIGH` — высокий (требует внимания)', + '- `CRITICAL` — критический (блокирует работу)', + '', + '### Иерархия', + '- Можно указать `parentId` — родительскую задачу', + '- Это позволяет строить деревья: Эпик → Задача → Подзадача', + '- Баг тоже может быть привязан к эпику или задаче', + '', + '### Метки', + '- Произвольный набор текстовых меток для категоризации', + '- Пример: `["backend", "auth", "high-priority"]`', + '- Метки создаются автоматически, если их ещё нет', + ].join('\n'), + }), + ApiBody({ + type: CreateIssueDto.Output, + description: 'Данные для создания задачи', + }), + ApiResponse({ + status: 201, + description: 'Задача успешно создана', + type: ActionResponse.Output, + }), + ApiValidationError(), + ApiUnauthorized(), + ApiForbidden('Нет прав для создания задач на этой доске'), + ApiNotFound('Указанная колонка не найдена'), + ApiConflict('Задача с таким заголовком уже существует в этой колонке'), + + SetMetadata(ZOD_RESPONSE_TOKEN, ActionResponse), + ); + +export const GetAllIssuesSwagger = () => + applyDecorators( + ApiOperation({ + summary: 'Получить список задач с фильтрацией', + description: [ + 'Возвращает задачи с учётом фильтров, сортировки и пагинации.', + '', + '### Основной сценарий — отображение доски', + '- Передайте `boardId` чтобы получить все задачи на конкретной доске', + '- Задачи будут сгруппированы по колонкам и отсортированы по позиции', + '- Это позволяет отрисовать Kanban-доску одним запросом', + '', + '### Фильтры', + '- `boardId` — показать задачи только с этой доски', + '- `columnId` — показать задачи в конкретной колонке', + '- `assigneeId` — задачи конкретного исполнителя', + '- `priority` — фильтр по приоритету (LOW, MEDIUM, HIGH, CRITICAL)', + '- `type` — фильтр по типу (TASK, BUG, EPIC)', + '- `labels` — фильтр по меткам (через запятую). Задача должна иметь ВСЕ указанные метки (AND)', + '- `search` — полнотекстовый поиск по заголовку и описанию', + '', + '### Сортировка', + '- `sortBy` — поле для сортировки: `positionInColumn`, `createdAt`, `updatedAt`, `priority`', + '- `sortOrder` — направление: `ASC` или `DESC`', + '- По умолчанию сортировка по `positionInColumn ASC` — как на доске', + '', + '### Пагинация', + '- `limit` — количество задач на страницу (по умолчанию 50, максимум 200)', + '- `offset` — смещение для пагинации (по умолчанию 0)', + '', + '### Примеры использования', + '- Все задачи на доске: `GET /v1/issues?boardId=xxx`', + '- Мои задачи: `GET /v1/issues?assigneeId=yyy`', + '- Критические баги: `GET /v1/issues?type=BUG&priority=CRITICAL`', + '- Поиск: `GET /v1/issues?search=oauth+error`', + ].join('\n'), + }), + ApiQuery({ + name: 'boardId', + required: false, + type: 'string', + format: 'uuid', + description: 'ID доски — показать задачи только с этой доски', + example: 'd4e5f6a7-b8c9-0123-defa-234567890123', + }), + ApiQuery({ + name: 'columnId', + required: false, + type: 'string', + format: 'uuid', + description: 'ID колонки — показать задачи в конкретной колонке', + example: 'b8c9d0e1-f2a3-4567-bcde-678901234567', + }), + ApiQuery({ + name: 'assigneeId', + required: false, + type: 'string', + format: 'uuid', + description: 'ID исполнителя — только задачи этого пользователя', + example: 'b2c3d4e5-f6a7-8901-bcde-f12345678901', + }), + ApiQuery({ + name: 'priority', + required: false, + enum: ['LOW', 'MEDIUM', 'HIGH', 'CRITICAL'], + description: 'Фильтр по приоритету', + example: 'HIGH', + }), + ApiQuery({ + name: 'type', + required: false, + enum: ['TASK', 'BUG', 'EPIC'], + description: 'Фильтр по типу задачи', + example: 'BUG', + }), + ApiQuery({ + name: 'labels', + required: false, + type: 'string', + description: 'Метки через запятую (AND — задача должна иметь все)', + example: 'backend,auth', + }), + ApiListQuery({ + sortableFields: ['position'], + withSearch: true, + defaultSortField: 'position', + defaultSortOrder: 'asc', + }), + ApiResponse({ + status: 200, + description: 'Список задач получен', + type: ActionResponse.Output, + }), + ApiUnauthorized(), + ApiNotFound('Доска не найдена'), + + SetMetadata(ZOD_RESPONSE_TOKEN, ActionResponse), + ); + +export const GetOneIssueSwagger = () => + applyDecorators( + ApiOperation({ + summary: 'Получить детали задачи', + description: [ + 'Возвращает полную информацию о задаче со всеми связями.', + '', + '### Что включает ответ', + '- Основные поля: заголовок, описание, приоритет, тип', + '- Позиционирование: на какой доске и в какой колонке находится, позиция в колонке', + '- Исполнитель: кто назначен (с именем и ID)', + '- Иерархия: ID родительской задачи, если есть', + '- Метки: список всех меток задачи', + '- Временные метки: когда создана и обновлена', + '', + '### Когда использовать', + '- Открытие карточки задачи в модальном окне', + '- Получение полных данных перед редактированием', + '- Просмотр деталей задачи из уведомления', + ].join('\n'), + }), + ApiParam({ + name: 'id', + type: 'string', + format: 'uuid', + description: 'ID задачи', + example: '33333333-3333-3333-3333-333333333333', + }), + ApiResponse({ + status: 200, + description: 'Информация о задаче получена', + type: ActionResponse.Output, + }), + ApiNotFound('Задача не найдена или удалена'), + ApiUnauthorized(), + + SetMetadata(ZOD_RESPONSE_TOKEN, ActionResponse), + ); + +export const UpdateIssueSwagger = () => + applyDecorators( + ApiOperation({ + summary: 'Обновить метаданные задачи', + description: [ + 'Частичное обновление полей задачи. Передаются только те поля, которые нужно изменить.', + '', + '### Что можно обновить', + '- `title` — заголовок задачи', + '- `description` — описание (поддерживает Markdown)', + '- `priority` — приоритет (LOW, MEDIUM, HIGH, CRITICAL)', + '- `type` — тип (TASK, BUG, EPIC)', + '- `parentId` — привязка к родительской задаче', + '- `labels` — полный список меток (перезаписывает существующие)', + '', + '### Важные моменты', + '- Это НЕ перемещение задачи по доске — используйте `POST /move`', + '- Это НЕ назначение исполнителя — используйте `PUT /assignee`', + '- Метки передаются полным списком — старые метки заменяются новыми', + '- Можно передать `parentId: null` чтобы отвязать от родителя', + '', + '### Примеры', + '- Повысить приоритет: `{ "priority": "CRITICAL" }`', + '- Обновить метки: `{ "labels": ["backend", "auth", "security"] }`', + '- Сделать подзадачей: `{ "parentId": "99999999-9999-..." }`', + ].join('\n'), + }), + ApiParam({ + name: 'id', + type: 'string', + format: 'uuid', + description: 'ID задачи', + example: '33333333-3333-3333-3333-333333333333', + }), + ApiBody({ + type: UpdateIssueDto.Output, + description: 'Обновляемые поля (только те, что нужно изменить)', + }), + ApiResponse({ + status: 200, + description: 'Задача обновлена', + type: ActionResponse.Output, + }), + ApiValidationError(), + ApiNotFound('Задача не найдена или удалена'), + ApiUnauthorized(), + ApiForbidden('Нет прав для редактирования этой задачи'), + ApiConflict('Задача с таким заголовком уже существует'), + + SetMetadata(ZOD_RESPONSE_TOKEN, ActionResponse), + ); + +export const MoveIssueSwagger = () => + applyDecorators( + ApiOperation({ + summary: 'Переместить задачу по доске', + description: [ + 'Перемещает задачу в другую колонку и/или меняет её позицию внутри колонки.', + 'Это основная операция Kanban — перетаскивание карточек между колонками.', + '', + '### Как работает перемещение', + '- Задача переносится в целевую колонку (`targetColumnId`)', + '- Вставляется на указанную позицию (`position`, начиная с 0)', + '- Все остальные задачи в обеих колонках автоматически сдвигаются', + '', + '### Смена доски', + '- Если целевая колонка принадлежит другой доске — задача автоматически меняет доску', + '- Это происходит прозрачно, дополнительно указывать `boardId` не нужно', + '', + '### Позиционирование', + '- `position: 0` — задача становится первой в колонке (самая верхняя)', + '- `position: 3` — задача вставляется на 4-ю позицию', + '- Задачи ниже указанной позиции сдвигаются вниз (+1)', + '', + '### Типичные сценарии', + '- Взял в работу: переместить из «To Do» в «In Progress» на позицию 0', + '- Отправил на ревью: переместить из «In Progress» в «Code Review» в конец', + '- Drag-and-drop: перетащил карточку мышкой — фронтенд отправляет этот запрос', + '- Перемещение бага между досками: из «Bug Tracker» в «Development Board»', + '', + '### Ограничения', + '- Нельзя переместить удалённую задачу', + '- Целевая колонка должна существовать и быть активной (не архивной)', + '- В будущем: проверка WIP-лимитов колонки', + ].join('\n'), + }), + ApiParam({ + name: 'id', + type: 'string', + format: 'uuid', + description: 'ID перемещаемой задачи', + example: '33333333-3333-3333-3333-333333333333', + }), + ApiBody({ + type: MoveIssueDto.Output, + description: 'Целевая колонка и новая позиция', + }), + ApiResponse({ + status: 200, + description: + 'Задача успешно перемещена. Возвращает обновлённую задачу с новыми column и position', + type: ActionResponse.Output, + }), + ApiValidationError(), + ApiNotFound('Задача или целевая колонка не найдена'), + ApiUnauthorized(), + ApiForbidden('Нет прав для перемещения задач на этой доске'), + ApiConflict('Нарушен WIP-лимит целевой колонки'), + + SetMetadata(ZOD_RESPONSE_TOKEN, ActionResponse), + ); + +export const AssignIssueSwagger = () => + applyDecorators( + ApiOperation({ + summary: 'Назначить или снять исполнителя', + description: [ + 'Операция назначения ответственного за задачу.', + '', + '### Как работает', + '- Передайте `assigneeId` — пользователь будет назначен исполнителем', + '- Передайте `assigneeId: null` — исполнитель будет снят (задача станет «ничьей»)', + '- Предыдущий исполнитель автоматически снимается', + '', + '### Бизнес-логика', + '- При назначении создаётся запись в истории изменений (audit log)', + '- В будущем: отправка уведомления новому исполнителю', + '- В будущем: проверка, не перегружен ли исполнитель', + '', + '### Сценарии', + '- Самоназначение: разработчик берёт задачу из бэклога', + '- Переназначение: техлид передаёт задачу другому разработчику', + '- Снятие: задача возвращается в бэклог без исполнителя', + '', + '### Отличие от PATCH', + '- Это отдельный эндпоинт, а не часть общего PATCH', + '- Назначение — это бизнес-операция со своими правилами и сайд-эффектами', + '- Позволяет явно логировать смену ответственного', + ].join('\n'), + }), + ApiParam({ + name: 'id', + type: 'string', + format: 'uuid', + description: 'ID задачи', + example: '33333333-3333-3333-3333-333333333333', + }), + ApiBody({ + type: AssignIssueDto.Output, + description: 'ID нового исполнителя (или null чтобы снять)', + }), + ApiResponse({ + status: 200, + description: 'Исполнитель назначен или снят', + type: ActionResponse.Output, + }), + ApiValidationError(), + ApiNotFound('Задача не найдена или удалена'), + ApiUnauthorized(), + ApiForbidden('Нет прав для назначения исполнителей'), + + SetMetadata(ZOD_RESPONSE_TOKEN, ActionResponse), + ); + +export const DeleteIssueSwagger = () => + applyDecorators( + ApiOperation({ + summary: 'Мягкое удаление задачи', + description: [ + 'Мягкое удаление задачи — она перестаёт отображаться на доске, но данные сохраняются.', + '', + '### Как работает мягкое удаление', + '- Задача помечается как удалённая (`deletedAt` устанавливается в текущее время)', + '- Задача исчезает с доски и из всех списков', + '- Данные задачи сохраняются в базе', + '- Связи с метками сохраняются', + '', + '### Ограничения', + '- Нельзя удалить задачу, если у неё есть активные подзадачи', + '- Нужно сначала удалить или переместить все подзадачи', + '- Это защита от случайного удаления родительской задачи', + '', + '### Восстановление', + '- Удалённую задачу можно восстановить через `POST /restore`', + '- При восстановлении задача вернётся в ту же колонку', + '- Позиция будет восстановлена в конец колонки', + '', + '### Отличие от полного удаления', + '- Полное удаление (hard delete) не предусмотрено в API', + '- Это обеспечивает сохранность данных и возможность аудита', + ].join('\n'), + }), + ApiParam({ + name: 'id', + type: 'string', + format: 'uuid', + description: 'ID задачи', + example: '33333333-3333-3333-3333-333333333333', + }), + ApiResponse({ + status: 204, + description: 'Задача успешно удалена (мягкое удаление)', + }), + ApiNotFound('Задача не найдена или уже удалена'), + ApiUnauthorized(), + ApiForbidden('Нет прав для удаления этой задачи'), + ApiConflict('Нельзя удалить задачу с активными подзадачами'), + + SetMetadata(ZOD_RESPONSE_TOKEN, ActionResponse), + ); + +export const RestoreIssueSwagger = () => + applyDecorators( + ApiOperation({ + summary: 'Восстановить удалённую задачу', + description: [ + 'Восстанавливает мягко удалённую задачу.', + '', + '### Что восстанавливается', + '- Сама задача со всеми метаданными', + '- Связи с метками', + '- Привязка к исполнителю (если был)', + '- Родительская связь (если была)', + '', + '### Позиционирование', + '- Задача возвращается в ту же колонку, где была до удаления', + '- Позиция: в конец колонки (последней)', + '- Это позволяет быстро вернуть задачу без конфликтов позиционирования', + '', + '### Когда использовать', + '- Задачу удалили по ошибке', + '- Решили вернуть отложенную задачу в работу', + '- Восстановление архивных задач при пересмотре планов', + ].join('\n'), + }), + ApiParam({ + name: 'id', + type: 'string', + format: 'uuid', + description: 'ID удалённой задачи', + example: '33333333-3333-3333-3333-333333333333', + }), + ApiResponse({ + status: 200, + description: 'Задача восстановлена', + type: ActionResponse.Output, + }), + ApiNotFound('Удалённая задача не найдена'), + ApiUnauthorized(), + ApiForbidden('Нет прав для восстановления задач'), + + SetMetadata(ZOD_RESPONSE_TOKEN, ActionResponse), + ); diff --git a/src/issue/application/dtos/index.ts b/src/issue/application/dtos/index.ts index a4a9fdc..4a3cba9 100644 --- a/src/issue/application/dtos/index.ts +++ b/src/issue/application/dtos/index.ts @@ -1 +1 @@ -export {} from './'; +export * from './issue.dto'; diff --git a/src/issue/application/dtos/issue.dto.ts b/src/issue/application/dtos/issue.dto.ts new file mode 100644 index 0000000..52eed97 --- /dev/null +++ b/src/issue/application/dtos/issue.dto.ts @@ -0,0 +1,14 @@ +import { createZodDto } from 'nestjs-zod'; +import { z } from 'zod/v4'; + +export const CreateIssueSchema = z.object({}); + +export class CreateIssueDto extends createZodDto(CreateIssueSchema) {} +export class UpdateIssueDto extends createZodDto(CreateIssueSchema.partial()) {} + +export class MoveIssueDto extends createZodDto(CreateIssueSchema) {} +export class AssignIssueDto extends createZodDto(CreateIssueSchema) {} + +export const IssueQuerySchema = z.object({}); + +export class IssueQueryDto extends createZodDto(IssueQuerySchema) {} diff --git a/src/issue/application/issue.facade.ts b/src/issue/application/issue.facade.ts index b1d7ced..5a0a6ea 100644 --- a/src/issue/application/issue.facade.ts +++ b/src/issue/application/issue.facade.ts @@ -1,6 +1,50 @@ import { Injectable } from '@nestjs/common'; +import { + AssignIssueDto, + CreateIssueDto, + IssueQueryDto, + MoveIssueDto, + UpdateIssueDto, +} from './dtos'; +import { + AssignIssueUseCase, + CreateIssueUseCase, + DeleteIssueUseCase, + GetAllIssueQuery, + GetOneIssueQuery, + MoveIssueUseCase, + UpdateIssueUseCase, +} from './use-cases'; + @Injectable() export class IssueFacade { - constructor() {} + constructor( + private readonly createIssueUC: CreateIssueUseCase, + private readonly updateIssueUC: UpdateIssueUseCase, + private readonly deleteIssueUC: DeleteIssueUseCase, + private readonly assignIssueUC: AssignIssueUseCase, + private readonly getOneIssueQ: GetOneIssueQuery, + private readonly getAllIssueQ: GetAllIssueQuery, + private readonly moveIssueUC: MoveIssueUseCase, + ) {} + + public create = async (dto: CreateIssueDto, userId: string) => + this.createIssueUC.execute(dto, userId); + + public getOne = async (id: string, userId: string) => this.getOneIssueQ.execute(id, userId); + + public getAll = async (query: IssueQueryDto, userId: string) => + this.getAllIssueQ.execute(query, userId); + + public update = async (id: string, dto: UpdateIssueDto, userId: string) => + this.updateIssueUC.execute(id, dto, userId); + + public move = async (id: string, dto: MoveIssueDto, userId: string) => + this.moveIssueUC.execute(id, dto, userId); + + public assign = async (id: string, dto: AssignIssueDto, userId: string) => + this.assignIssueUC.execute(id, dto, userId); + + public delete = async (id: string, userId: string) => this.deleteIssueUC.execute(id, userId); } diff --git a/src/issue/application/use-cases/base/assign.use-case.ts b/src/issue/application/use-cases/base/assign.use-case.ts new file mode 100644 index 0000000..92d17b1 --- /dev/null +++ b/src/issue/application/use-cases/base/assign.use-case.ts @@ -0,0 +1,18 @@ +import { Injectable } from '@nestjs/common'; + +import { AssignIssueDto } from '../../dtos'; + +@Injectable() +export class AssignIssueUseCase { + constructor() {} + + async execute(id: string, dto: AssignIssueDto, userId: string) { + return { + success: true, + message: '', + id, + dto, + userId, + }; + } +} diff --git a/src/issue/application/use-cases/base/create.use-case.ts b/src/issue/application/use-cases/base/create.use-case.ts new file mode 100644 index 0000000..ceb1d1f --- /dev/null +++ b/src/issue/application/use-cases/base/create.use-case.ts @@ -0,0 +1,17 @@ +import { Injectable } from '@nestjs/common'; + +import { CreateIssueDto } from '../../dtos'; + +@Injectable() +export class CreateIssueUseCase { + constructor() {} + + async execute(dto: CreateIssueDto, userId: string) { + return { + success: true, + message: '', + dto, + userId, + }; + } +} diff --git a/src/issue/application/use-cases/base/delete.use-case.ts b/src/issue/application/use-cases/base/delete.use-case.ts new file mode 100644 index 0000000..44f975e --- /dev/null +++ b/src/issue/application/use-cases/base/delete.use-case.ts @@ -0,0 +1,10 @@ +import { Injectable } from '@nestjs/common'; + +@Injectable() +export class DeleteIssueUseCase { + constructor() {} + + async execute(id: string, userId: string) { + return { id, userId }; + } +} diff --git a/src/issue/application/use-cases/base/get-all.query.ts b/src/issue/application/use-cases/base/get-all.query.ts new file mode 100644 index 0000000..27b5e52 --- /dev/null +++ b/src/issue/application/use-cases/base/get-all.query.ts @@ -0,0 +1,12 @@ +import { Injectable } from '@nestjs/common'; + +import { IssueQueryDto } from '../../dtos'; + +@Injectable() +export class GetAllIssueQuery { + constructor() {} + + async execute(query: IssueQueryDto, userId: string) { + return { query, userId }; + } +} diff --git a/src/issue/application/use-cases/base/get-one.query.ts b/src/issue/application/use-cases/base/get-one.query.ts new file mode 100644 index 0000000..a80dac2 --- /dev/null +++ b/src/issue/application/use-cases/base/get-one.query.ts @@ -0,0 +1,10 @@ +import { Injectable } from '@nestjs/common'; + +@Injectable() +export class GetOneIssueQuery { + constructor() {} + + async execute(id: string, userId: string) { + return { id, userId }; + } +} diff --git a/src/issue/application/use-cases/base/move.use-case.ts b/src/issue/application/use-cases/base/move.use-case.ts new file mode 100644 index 0000000..3b81428 --- /dev/null +++ b/src/issue/application/use-cases/base/move.use-case.ts @@ -0,0 +1,18 @@ +import { Injectable } from '@nestjs/common'; + +import { MoveIssueDto } from '../../dtos'; + +@Injectable() +export class MoveIssueUseCase { + constructor() {} + + async execute(id: string, dto: MoveIssueDto, userId: string) { + return { + success: true, + message: '', + id, + dto, + userId, + }; + } +} diff --git a/src/issue/application/use-cases/base/update.use-case.ts b/src/issue/application/use-cases/base/update.use-case.ts new file mode 100644 index 0000000..4a4f3fb --- /dev/null +++ b/src/issue/application/use-cases/base/update.use-case.ts @@ -0,0 +1,18 @@ +import { Injectable } from '@nestjs/common'; + +import { UpdateIssueDto } from '../../dtos'; + +@Injectable() +export class UpdateIssueUseCase { + constructor() {} + + async execute(id: string, dto: UpdateIssueDto, userId: string) { + return { + success: true, + message: '', + id, + dto, + userId, + }; + } +} diff --git a/src/issue/application/use-cases/index.ts b/src/issue/application/use-cases/index.ts new file mode 100644 index 0000000..beea343 --- /dev/null +++ b/src/issue/application/use-cases/index.ts @@ -0,0 +1,25 @@ +import { AssignIssueUseCase } from './base/assign.use-case'; +import { CreateIssueUseCase } from './base/create.use-case'; +import { DeleteIssueUseCase } from './base/delete.use-case'; +import { GetAllIssueQuery } from './base/get-all.query'; +import { GetOneIssueQuery } from './base/get-one.query'; +import { MoveIssueUseCase } from './base/move.use-case'; +import { UpdateIssueUseCase } from './base/update.use-case'; + +export * from './base/assign.use-case'; +export * from './base/create.use-case'; +export * from './base/delete.use-case'; +export * from './base/get-all.query'; +export * from './base/get-one.query'; +export * from './base/update.use-case'; +export * from './base/move.use-case'; + +export const USE_CASES = [ + CreateIssueUseCase, + UpdateIssueUseCase, + DeleteIssueUseCase, + AssignIssueUseCase, + MoveIssueUseCase, + GetOneIssueQuery, + GetAllIssueQuery, +]; diff --git a/src/issue/issue.module.ts b/src/issue/issue.module.ts index 28fe46f..bfa7c42 100644 --- a/src/issue/issue.module.ts +++ b/src/issue/issue.module.ts @@ -2,11 +2,12 @@ import { Module } from '@nestjs/common'; import { CONTROLLERS } from './application/controllers'; import { IssueFacade } from './application/issue.facade'; +import { USE_CASES } from './application/use-cases'; import { REPOSITORIES } from './infrastructure/persistence/repositories'; @Module({ imports: [], controllers: CONTROLLERS, - providers: [...REPOSITORIES, IssueFacade], + providers: [...REPOSITORIES, ...USE_CASES, IssueFacade], }) export class IssueModule {} From fd0639b282cccd5facd9b7ee5f5616d81b0e8f13 Mon Sep 17 00:00:00 2001 From: soorq Date: Wed, 17 Jun 2026 00:29:08 +0300 Subject: [PATCH 4/6] feat(issue): add migration to issues and apply repositories, with dtos --- migrations/0006_add_issue.sql | 75 + migrations/meta/0006_snapshot.json | 2392 +++++++++++++++++ migrations/meta/_journal.json | 7 + .../application/controllers/issues/swagger.ts | 17 +- src/issue/application/dtos/issue.dto.ts | 200 +- src/issue/domain/entities/enum.ts | 20 + src/issue/domain/entities/index.ts | 3 +- src/issue/domain/entities/issue.domain.ts | 5 + src/issue/domain/repositories/index.ts | 2 +- .../issue.repository.interface.ts | 12 + .../infrastructure/persistence/models/enum.ts | 7 + .../persistence/models/index.ts | 3 +- .../persistence/models/issue.model.ts | 74 + .../persistence/repositories/index.ts | 10 +- .../repositories/issue.repository.ts | 83 + src/shared/entities/index.ts | 1 + 16 files changed, 2894 insertions(+), 17 deletions(-) create mode 100644 migrations/0006_add_issue.sql create mode 100644 migrations/meta/0006_snapshot.json create mode 100644 src/issue/domain/entities/enum.ts create mode 100644 src/issue/domain/entities/issue.domain.ts create mode 100644 src/issue/domain/repositories/issue.repository.interface.ts create mode 100644 src/issue/infrastructure/persistence/models/enum.ts create mode 100644 src/issue/infrastructure/persistence/models/issue.model.ts create mode 100644 src/issue/infrastructure/persistence/repositories/issue.repository.ts diff --git a/migrations/0006_add_issue.sql b/migrations/0006_add_issue.sql new file mode 100644 index 0000000..eee9ccd --- /dev/null +++ b/migrations/0006_add_issue.sql @@ -0,0 +1,75 @@ +CREATE TYPE "base"."issue_type" AS ENUM ('bug', 'task', 'epic'); + +CREATE TYPE "base"."priority" AS ENUM ('critical', 'low', 'medium', 'high'); + +CREATE TABLE + "base"."issues" ( + "id" text PRIMARY KEY NOT NULL, + "title" varchar(255) NOT NULL, + "description" text, + "description_html" text, + "priority" "priority" DEFAULT 'medium' NOT NULL, + "type" "issue_type" DEFAULT 'task' NOT NULL, + "area_id" text NOT NULL, + "state_id" text, + "position" integer DEFAULT 0, + "assignee_id" text, + "reporter_id" text, + "parent_id" text, + "story_points" integer, + "due_date" timestamp + with + time zone, + "created_at" timestamp + with + time zone DEFAULT now () NOT NULL, + "updated_at" timestamp + with + time zone DEFAULT now () NOT NULL, + "deleted_at" timestamp + with + time zone, + CONSTRAINT "no_self_parent" CHECK ( + "base"."issues"."parent_id" IS NULL + OR "base"."issues"."parent_id" != "base"."issues"."id" + ) + ); + +ALTER TABLE "base"."issues" ADD CONSTRAINT "issues_area_id_areas_id_fk" FOREIGN KEY ("area_id") REFERENCES "base"."areas" ("id") ON DELETE cascade ON UPDATE no action; + +ALTER TABLE "base"."issues" ADD CONSTRAINT "issues_state_id_states_id_fk" FOREIGN KEY ("state_id") REFERENCES "base"."states" ("id") ON DELETE set null ON UPDATE no action; + +ALTER TABLE "base"."issues" ADD CONSTRAINT "issues_assignee_id_users_id_fk" FOREIGN KEY ("assignee_id") REFERENCES "base"."users" ("id") ON DELETE set null ON UPDATE no action; + +ALTER TABLE "base"."issues" ADD CONSTRAINT "issues_reporter_id_users_id_fk" FOREIGN KEY ("reporter_id") REFERENCES "base"."users" ("id") ON DELETE set null ON UPDATE no action; + +ALTER TABLE "base"."issues" ADD CONSTRAINT "issues_parent_id_issues_id_fk" FOREIGN KEY ("parent_id") REFERENCES "base"."issues" ("id") ON DELETE set null ON UPDATE no action; + +CREATE INDEX "idx_issue_area_state" ON "base"."issues" USING btree ("area_id", "state_id", "position") +WHERE + "base"."issues"."deleted_at" IS NULL; + +CREATE INDEX "idx_issue_assignee" ON "base"."issues" USING btree ("assignee_id") +WHERE + "base"."issues"."deleted_at" IS NULL; + +CREATE INDEX "idx_issue_parent" ON "base"."issues" USING btree ("parent_id") +WHERE + "base"."issues"."deleted_at" IS NULL; + +CREATE INDEX "idx_issue_priority" ON "base"."issues" USING btree ("priority") +WHERE + "base"."issues"."deleted_at" IS NULL; + +CREATE INDEX "idx_issue_type" ON "base"."issues" USING btree ("type") +WHERE + "base"."issues"."deleted_at" IS NULL; + +CREATE INDEX "idx_issue_search" ON "base"."issues" USING gin ( + to_tsvector ( + 'english', + COALESCE("title", '') || ' ' || COALESCE("description", '') + ) +) +WHERE + "base"."issues"."deleted_at" IS NULL; \ 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..d732bde --- /dev/null +++ b/migrations/meta/0006_snapshot.json @@ -0,0 +1,2392 @@ +{ + "id": "ff097bac-bc33-47f1-a242-773529e37d66", + "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.team_members": { + "name": "team_members", + "schema": "base", + "columns": { + "team_id": { + "name": "team_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "team_role", + "typeSchema": "base", + "primaryKey": false, + "notNull": true, + "default": "'member'" + }, + "status": { + "name": "status", + "type": "member_status", + "typeSchema": "base", + "primaryKey": false, + "notNull": true, + "default": "'inactive'" + }, + "joined_at": { + "name": "joined_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "member_status_idx": { + "name": "member_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "member_role_idx": { + "name": "member_role_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "role", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "team_members_team_id_teams_id_fk": { + "name": "team_members_team_id_teams_id_fk", + "tableFrom": "team_members", + "tableTo": "teams", + "schemaTo": "base", + "columnsFrom": [ + "team_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "team_members_user_id_users_id_fk": { + "name": "team_members_user_id_users_id_fk", + "tableFrom": "team_members", + "tableTo": "users", + "schemaTo": "base", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "team_members_team_id_user_id_pk": { + "name": "team_members_team_id_user_id_pk", + "columns": [ + "team_id", + "user_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "base.teams": { + "name": "teams", + "schema": "base", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "avatar_url": { + "name": "avatar_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cover_url": { + "name": "cover_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "owner_id": { + "name": "owner_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "team_owner_idx": { + "name": "team_owner_idx", + "columns": [ + { + "expression": "owner_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "team_deleted_at_idx": { + "name": "team_deleted_at_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "teams_owner_id_users_id_fk": { + "name": "teams_owner_id_users_id_fk", + "tableFrom": "teams", + "tableTo": "users", + "schemaTo": "base", + "columnsFrom": [ + "owner_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "base.project_members": { + "name": "project_members", + "schema": "base", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'member'" + }, + "added_by": { + "name": "added_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "project_member_unique_idx": { + "name": "project_member_unique_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "project_member_user_idx": { + "name": "project_member_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "project_member_project_idx": { + "name": "project_member_project_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "project_members_project_id_projects_id_fk": { + "name": "project_members_project_id_projects_id_fk", + "tableFrom": "project_members", + "tableTo": "projects", + "schemaTo": "base", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "project_members_user_id_users_id_fk": { + "name": "project_members_user_id_users_id_fk", + "tableFrom": "project_members", + "tableTo": "users", + "schemaTo": "base", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "project_members_added_by_users_id_fk": { + "name": "project_members_added_by_users_id_fk", + "tableFrom": "project_members", + "tableTo": "users", + "schemaTo": "base", + "columnsFrom": [ + "added_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "base.project_settings": { + "name": "project_settings", + "schema": "base", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "default_view": { + "name": "default_view", + "type": "layout_type", + "typeSchema": "base", + "primaryKey": false, + "notNull": true, + "default": "'kanban'" + }, + "task_prefix": { + "name": "task_prefix", + "type": "varchar(10)", + "primaryKey": false, + "notNull": false + }, + "auto_close_days": { + "name": "auto_close_days", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "max_tasks_per_area": { + "name": "max_tasks_per_area", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "max_members": { + "name": "max_members", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "max_areas": { + "name": "max_areas", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "allow_guests": { + "name": "allow_guests", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "time_tracking": { + "name": "time_tracking", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "time_tracking_mode": { + "name": "time_tracking_mode", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false, + "default": "'optional'" + }, + "default_assignee_id": { + "name": "default_assignee_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "project_settings_project_idx": { + "name": "project_settings_project_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "project_settings_project_id_projects_id_fk": { + "name": "project_settings_project_id_projects_id_fk", + "tableFrom": "project_settings", + "tableTo": "projects", + "schemaTo": "base", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "project_settings_default_assignee_id_users_id_fk": { + "name": "project_settings_default_assignee_id_users_id_fk", + "tableFrom": "project_settings", + "tableTo": "users", + "schemaTo": "base", + "columnsFrom": [ + "default_assignee_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "project_settings_project_id_unique": { + "name": "project_settings_project_id_unique", + "nullsNotDistinct": false, + "columns": [ + "project_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "base.project_shares": { + "name": "project_shares", + "schema": "base", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "token_idx": { + "name": "token_idx", + "columns": [ + { + "expression": "token", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "project_share_project_id_idx": { + "name": "project_share_project_id_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "project_shares_project_id_projects_id_fk": { + "name": "project_shares_project_id_projects_id_fk", + "tableFrom": "project_shares", + "tableTo": "projects", + "schemaTo": "base", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "project_shares_created_by_users_id_fk": { + "name": "project_shares_created_by_users_id_fk", + "tableFrom": "project_shares", + "tableTo": "users", + "schemaTo": "base", + "columnsFrom": [ + "created_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "project_shares_token_unique": { + "name": "project_shares_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "base.projects": { + "name": "projects", + "schema": "base", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "team_id": { + "name": "team_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "descriptionHtml": { + "name": "descriptionHtml", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "icon": { + "name": "icon", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "color": { + "name": "color", + "type": "varchar(7)", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "project_status", + "typeSchema": "base", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "sequence": { + "name": "sequence", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "owner_id": { + "name": "owner_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "visibility": { + "name": "visibility", + "type": "project_visibility", + "typeSchema": "base", + "primaryKey": false, + "notNull": true, + "default": "'public'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "project_team_slug_idx": { + "name": "project_team_slug_idx", + "columns": [ + { + "expression": "team_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"base\".\"projects\".\"deleted_at\" is null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "project_owner_id_idx": { + "name": "project_owner_id_idx", + "columns": [ + { + "expression": "owner_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "project_team_id_idx": { + "name": "project_team_id_idx", + "columns": [ + { + "expression": "team_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "projects_team_id_teams_id_fk": { + "name": "projects_team_id_teams_id_fk", + "tableFrom": "projects", + "tableTo": "teams", + "schemaTo": "base", + "columnsFrom": [ + "team_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "projects_owner_id_users_id_fk": { + "name": "projects_owner_id_users_id_fk", + "tableFrom": "projects", + "tableTo": "users", + "schemaTo": "base", + "columnsFrom": [ + "owner_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "projects_slug_unique": { + "name": "projects_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "slug" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "base.areas": { + "name": "areas", + "schema": "base", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description_html": { + "name": "description_html", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "color": { + "name": "color", + "type": "varchar(10)", + "primaryKey": false, + "notNull": false + }, + "tasks_count": { + "name": "tasks_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "default_view": { + "name": "default_view", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'kanban'" + }, + "icon": { + "name": "icon", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "max_tasks_limit": { + "name": "max_tasks_limit", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "is_locked": { + "name": "is_locked", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_areas_slug": { + "name": "idx_areas_slug", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_areas_project_active": { + "name": "idx_areas_project_active", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "position", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"base\".\"areas\".\"deleted_at\" is null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_areas_created_by": { + "name": "idx_areas_created_by", + "columns": [ + { + "expression": "created_by", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"base\".\"areas\".\"deleted_at\" is null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_areas_deleted_at": { + "name": "idx_areas_deleted_at", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"base\".\"areas\".\"deleted_at\" is not null", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "areas_project_id_projects_id_fk": { + "name": "areas_project_id_projects_id_fk", + "tableFrom": "areas", + "tableTo": "projects", + "schemaTo": "base", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "areas_created_by_users_id_fk": { + "name": "areas_created_by_users_id_fk", + "tableFrom": "areas", + "tableTo": "users", + "schemaTo": "base", + "columnsFrom": [ + "created_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "areas_slug_unique": { + "name": "areas_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "slug" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "base.states": { + "name": "states", + "schema": "base", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "area_id": { + "name": "area_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "state_type": { + "name": "state_type", + "type": "state_type", + "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 + }, + "base.issues": { + "name": "issues", + "schema": "base", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "title": { + "name": "title", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description_html": { + "name": "description_html", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "priority": { + "name": "priority", + "type": "priority", + "primaryKey": false, + "notNull": true, + "default": "'medium'" + }, + "type": { + "name": "type", + "type": "issue_type", + "primaryKey": false, + "notNull": true, + "default": "'task'" + }, + "area_id": { + "name": "area_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "state_id": { + "name": "state_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "assignee_id": { + "name": "assignee_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reporter_id": { + "name": "reporter_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "parent_id": { + "name": "parent_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "story_points": { + "name": "story_points", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "due_date": { + "name": "due_date", + "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()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_issue_area_state": { + "name": "idx_issue_area_state", + "columns": [ + { + "expression": "area_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "state_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "position", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"base\".\"issues\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_issue_assignee": { + "name": "idx_issue_assignee", + "columns": [ + { + "expression": "assignee_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"base\".\"issues\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_issue_parent": { + "name": "idx_issue_parent", + "columns": [ + { + "expression": "parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"base\".\"issues\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_issue_priority": { + "name": "idx_issue_priority", + "columns": [ + { + "expression": "priority", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"base\".\"issues\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_issue_type": { + "name": "idx_issue_type", + "columns": [ + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"base\".\"issues\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_issue_search": { + "name": "idx_issue_search", + "columns": [ + { + "expression": "to_tsvector('english', COALESCE(\"title\", '') || ' ' || COALESCE(\"description\", ''))", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"base\".\"issues\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "gin", + "with": {} + } + }, + "foreignKeys": { + "issues_area_id_areas_id_fk": { + "name": "issues_area_id_areas_id_fk", + "tableFrom": "issues", + "tableTo": "areas", + "schemaTo": "base", + "columnsFrom": [ + "area_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "issues_state_id_states_id_fk": { + "name": "issues_state_id_states_id_fk", + "tableFrom": "issues", + "tableTo": "states", + "schemaTo": "base", + "columnsFrom": [ + "state_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "issues_assignee_id_users_id_fk": { + "name": "issues_assignee_id_users_id_fk", + "tableFrom": "issues", + "tableTo": "users", + "schemaTo": "base", + "columnsFrom": [ + "assignee_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "issues_reporter_id_users_id_fk": { + "name": "issues_reporter_id_users_id_fk", + "tableFrom": "issues", + "tableTo": "users", + "schemaTo": "base", + "columnsFrom": [ + "reporter_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "issues_parent_id_issues_id_fk": { + "name": "issues_parent_id_issues_id_fk", + "tableFrom": "issues", + "tableTo": "issues", + "schemaTo": "base", + "columnsFrom": [ + "parent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "no_self_parent": { + "name": "no_self_parent", + "value": "\"base\".\"issues\".\"parent_id\" IS NULL OR \"base\".\"issues\".\"parent_id\" != \"base\".\"issues\".\"id\"" + } + }, + "isRLSEnabled": false + } + }, + "enums": { + "base.team_role": { + "name": "team_role", + "schema": "base", + "values": [ + "owner", + "admin", + "lead", + "moderator", + "member", + "viewer" + ] + }, + "base.member_status": { + "name": "member_status", + "schema": "base", + "values": [ + "active", + "banned", + "inactive" + ] + }, + "base.layout_type": { + "name": "layout_type", + "schema": "base", + "values": [ + "kanban", + "list", + "calendar", + "gantt" + ] + }, + "base.project_status": { + "name": "project_status", + "schema": "base", + "values": [ + "active", + "archived", + "template", + "deleted" + ] + }, + "base.project_visibility": { + "name": "project_visibility", + "schema": "base", + "values": [ + "public", + "private" + ] + }, + "base.state_category": { + "name": "state_category", + "schema": "base", + "values": [ + "backlog", + "active", + "review", + "completed", + "archived" + ] + }, + "base.state_type": { + "name": "state_type", + "schema": "base", + "values": [ + "backlog", + "todo", + "in_progress", + "review", + "done", + "archived", + "custom" + ] + }, + "base.issue_type": { + "name": "issue_type", + "schema": "base", + "values": [ + "bug", + "task", + "epic" + ] + }, + "base.priority": { + "name": "priority", + "schema": "base", + "values": [ + "critical", + "low", + "medium", + "high" + ] + } + }, + "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..f760a0f 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": 1781645047048, + "tag": "0006_add_issue", + "breakpoints": false } ] } \ No newline at end of file diff --git a/src/issue/application/controllers/issues/swagger.ts b/src/issue/application/controllers/issues/swagger.ts index e1f5189..83aae9b 100644 --- a/src/issue/application/controllers/issues/swagger.ts +++ b/src/issue/application/controllers/issues/swagger.ts @@ -11,7 +11,14 @@ import { import { ZOD_RESPONSE_TOKEN } from '@shared/interceptors'; import { ActionResponse } from '@shared/schemas'; -import { AssignIssueDto, CreateIssueDto, MoveIssueDto, UpdateIssueDto } from '../../dtos'; +import { + AssignIssueDto, + CreateIssueDto, + IssueResponse, + IssuesResponse, + MoveIssueDto, + UpdateIssueDto, +} from '../../dtos'; export const CreateIssueSwagger = () => applyDecorators( @@ -156,12 +163,12 @@ export const GetAllIssuesSwagger = () => ApiResponse({ status: 200, description: 'Список задач получен', - type: ActionResponse.Output, + type: IssuesResponse.Output, }), ApiUnauthorized(), ApiNotFound('Доска не найдена'), - SetMetadata(ZOD_RESPONSE_TOKEN, ActionResponse), + SetMetadata(ZOD_RESPONSE_TOKEN, IssuesResponse), ); export const GetOneIssueSwagger = () => @@ -195,12 +202,12 @@ export const GetOneIssueSwagger = () => ApiResponse({ status: 200, description: 'Информация о задаче получена', - type: ActionResponse.Output, + type: IssueResponse.Output, }), ApiNotFound('Задача не найдена или удалена'), ApiUnauthorized(), - SetMetadata(ZOD_RESPONSE_TOKEN, ActionResponse), + SetMetadata(ZOD_RESPONSE_TOKEN, IssueResponse), ); export const UpdateIssueSwagger = () => diff --git a/src/issue/application/dtos/issue.dto.ts b/src/issue/application/dtos/issue.dto.ts index 52eed97..d18553a 100644 --- a/src/issue/application/dtos/issue.dto.ts +++ b/src/issue/application/dtos/issue.dto.ts @@ -1,14 +1,204 @@ import { createZodDto } from 'nestjs-zod'; import { z } from 'zod/v4'; -export const CreateIssueSchema = z.object({}); +import { createSortingSchema, PaginationBaseSchema } from '../../../shared/schemas'; +import { ISSUE_TYPE_LIST, PRIORITY_LIST } from '../../domain/entities'; + +export const PriorityEnumSchema = z + .enum(PRIORITY_LIST) + .default('medium') + .describe('Приоритет задачи: low, medium, high, critical'); + +export const IssueTypeEnumSchema = z + .enum(ISSUE_TYPE_LIST) + .default('task') + .describe('Тип задачи: task, bug, epic'); + +const AssigneeInfoSchema = z.object({ + id: z.string().describe('ID пользователя'), + displayName: z.string().describe('Отображаемое имя'), + email: z.string().email().optional().describe('Email пользователя'), + avatarUrl: z.string().url().nullable().optional().describe('URL аватара'), +}); + +export const IssueSchema = z.object({ + id: z.string().min(1, 'ID не может быть пустым').describe('Уникальный идентификатор задачи'), + title: z + .string() + .min(1, 'Заголовок обязателен') + .max(255, 'Заголовок не должен превышать 255 символов') + .describe('Заголовок задачи (например: "Добавить экспорт в PDF")'), + description: z + .string() + .nullable() + .optional() + .describe('Markdown-описание задачи, детали реализации'), + priority: PriorityEnumSchema.describe('Приоритет задачи'), + type: IssueTypeEnumSchema.describe('Тип задачи'), + areaId: z + .string() + .min(1, 'ID области обязателен') + .describe('ID области, к которой привязана задача'), + stateId: z + .string() + .nullable() + .optional() + .describe('ID текущего состояния (колонки). Null — задача без состояния'), + position: z + .number() + .int('Позиция должна быть целым числом') + .min(0, 'Позиция не может быть отрицательной') + .default(0) + .describe('Порядковый номер задачи внутри колонки (0 — первая/верхняя)'), + assigneeId: z + .string() + .nullable() + .optional() + .describe('ID текущего исполнителя. Null — задача не назначена'), + assignee: AssigneeInfoSchema.nullable().describe( + 'Текущий исполнитель задачи. Null — задача не назначена', + ), + reporterId: z.string().nullable().optional().describe('ID автора задачи. Null — не указан'), + reporter: AssigneeInfoSchema.nullable() + .optional() + .describe('Автор задачи (кто создал). Null — не указан'), + parentId: z + .string() + .nullable() + .optional() + .describe('ID родительской задачи (для подзадач). Null — задача верхнего уровня'), + parentTitle: z + .string() + .nullable() + .optional() + .describe('Заголовок родительской задачи (денормализованное поле для отображения)'), + labels: z + .array(z.string().max(50, 'Метка не должна превышать 50 символов')) + .default([]) + .describe('Список текстовых меток для категоризации (например: ["backend", "auth"])'), + storyPoints: z + .number() + .int('Story points должны быть целым числом') + .min(0, 'Story points не могут быть отрицательными') + .nullable() + .optional() + .describe('Оценка сложности в story points (для Scrum)'), + dueDate: z + .string() + .datetime({ offset: true }) + .nullable() + .optional() + .describe('Крайний срок выполнения (ISO 8601). Null — без срока'), + createdAt: z + .string() + .datetime({ offset: true }) + .describe('Дата и время создания задачи (ISO 8601 с таймзоной)'), + updatedAt: z + .string() + .datetime({ offset: true }) + .describe('Дата и время последнего обновления задачи'), + createdBy: z.string().nullable().optional().describe('ID пользователя, создавшего задачу'), + deletedAt: z + .string() + .datetime({ offset: true }) + .nullable() + .optional() + .describe('Дата мягкого удаления (null — задача активна)'), +}); + +export const CreateIssueSchema = IssueSchema.omit({ + id: true, + assignee: true, + reporter: true, + parentTitle: true, + createdAt: true, + updatedAt: true, + createdBy: true, + deletedAt: true, +}) + .partial({ + description: true, + priority: true, + type: true, + stateId: true, + assigneeId: true, + reporterId: true, + parentId: true, + labels: true, + storyPoints: true, + dueDate: true, + position: true, + }) + .describe('Схема для создания новой задачи'); + +export const UpdateIssueSchema = CreateIssueSchema.partial() + .refine((data) => Object.keys(data).length > 0, { + error: 'Необходимо передать хотя бы одно поле для обновления', + abort: true, + }) + .describe('Схема для обновления задачи'); + +export const MoveIssueSchema = z + .object({ + targetAreaId: z + .string() + .optional() + .describe( + 'Целевая область (если перемещаем между областями). Если не указана — остаётся текущая', + ), + targetStateId: z + .string() + .nullable() + .optional() + .describe('Целевое состояние (колонка). Null — убрать из состояния'), + position: z + .number() + .int('Позиция должна быть целым числом') + .min(0, 'Позиция не может быть отрицательной') + .describe('Новая позиция в колонке (0 — первая/верхняя)'), + }) + .describe('Схема для перемещения задачи по доске или между областями'); + +export const AssignIssueSchema = z + .object({ + assigneeId: z + .string() + .nullable() + .describe('ID нового исполнителя. Null — снять текущего исполнителя'), + }) + .describe('Схема для назначения/снятия исполнителя'); export class CreateIssueDto extends createZodDto(CreateIssueSchema) {} -export class UpdateIssueDto extends createZodDto(CreateIssueSchema.partial()) {} +export class UpdateIssueDto extends createZodDto(UpdateIssueSchema) {} -export class MoveIssueDto extends createZodDto(CreateIssueSchema) {} -export class AssignIssueDto extends createZodDto(CreateIssueSchema) {} +export class MoveIssueDto extends createZodDto(MoveIssueSchema) {} +export class AssignIssueDto extends createZodDto(AssignIssueSchema) {} -export const IssueQuerySchema = z.object({}); +export const IssueQuerySchema = z + .object({ + areaId: z.string().optional().describe('Фильтр по области'), + stateId: z.string().optional().describe('Фильтр по состоянию (колонке)'), + assigneeId: z.string().optional().describe('Фильтр по исполнителю'), + reporterId: z.string().optional().describe('Фильтр по автору'), + priority: PriorityEnumSchema.optional().describe('Фильтр по приоритету'), + type: IssueTypeEnumSchema.optional().describe('Фильтр по типу задачи'), + parentId: z + .string() + .nullable() + .optional() + .describe('Фильтр по родителю (null — только задачи верхнего уровня)'), + labels: z + .string() + .optional() + .describe('Метки через запятую (AND — задача должна иметь все указанные)'), + }) + .extend(PaginationBaseSchema.shape) + .extend(createSortingSchema(['position', 'createdAt', 'priority']).shape) + .describe('Query параметры для получения списка задач с фильтрацией'); + +export const IssuesSchema = z.array(IssueSchema); export class IssueQueryDto extends createZodDto(IssueQuerySchema) {} + +export class IssueResponse extends createZodDto(IssueSchema) {} +export class IssuesResponse extends createZodDto(IssuesSchema) {} diff --git a/src/issue/domain/entities/enum.ts b/src/issue/domain/entities/enum.ts new file mode 100644 index 0000000..cb6be03 --- /dev/null +++ b/src/issue/domain/entities/enum.ts @@ -0,0 +1,20 @@ +export const PRIORITY = { + CRITICAL: 'critical', + LOW: 'low', + MEDIUM: 'medium', + HIGH: 'high', +} as const; + +export type PriorityType = (typeof PRIORITY)[keyof typeof PRIORITY]; + +export const PRIORITY_LIST = Object.values(PRIORITY); + +export const ISSUE_TYPE = { + BUG: 'bug', + TASK: 'task', + EPIC: 'epic', +} as const; + +export type IssueType = (typeof ISSUE_TYPE)[keyof typeof ISSUE_TYPE]; + +export const ISSUE_TYPE_LIST = Object.values(ISSUE_TYPE); diff --git a/src/issue/domain/entities/index.ts b/src/issue/domain/entities/index.ts index a4a9fdc..411c55e 100644 --- a/src/issue/domain/entities/index.ts +++ b/src/issue/domain/entities/index.ts @@ -1 +1,2 @@ -export {} from './'; +export * from './issue.domain'; +export * from './enum'; diff --git a/src/issue/domain/entities/issue.domain.ts b/src/issue/domain/entities/issue.domain.ts new file mode 100644 index 0000000..bc69043 --- /dev/null +++ b/src/issue/domain/entities/issue.domain.ts @@ -0,0 +1,5 @@ +import type { issues } from '../../infrastructure/persistence/models'; +import type { InferInsertModel, InferSelectModel } from 'drizzle-orm'; + +export type Issue = InferSelectModel; +export type NewIssue = InferInsertModel; diff --git a/src/issue/domain/repositories/index.ts b/src/issue/domain/repositories/index.ts index a4a9fdc..f929f20 100644 --- a/src/issue/domain/repositories/index.ts +++ b/src/issue/domain/repositories/index.ts @@ -1 +1 @@ -export {} from './'; +export * from './issue.repository.interface'; diff --git a/src/issue/domain/repositories/issue.repository.interface.ts b/src/issue/domain/repositories/issue.repository.interface.ts new file mode 100644 index 0000000..1084c0f --- /dev/null +++ b/src/issue/domain/repositories/issue.repository.interface.ts @@ -0,0 +1,12 @@ +// eslint-disable-next-line no-restricted-syntax +import type { IssueQueryDto } from '../../application/dtos'; +import type { Issue, NewIssue } from '../entities'; + +export interface IIssueRepository { + create(data: NewIssue, userId: string): Promise<{ id: string }>; + update(id: string, data: Partial, userId: string): Promise; + delete(id: string, userId: string): Promise; + findOne(id: string, userId: string): Promise; + find(query: IssueQueryDto): Promise; + restore(id: string, userId: string): Promise; +} diff --git a/src/issue/infrastructure/persistence/models/enum.ts b/src/issue/infrastructure/persistence/models/enum.ts new file mode 100644 index 0000000..289e92e --- /dev/null +++ b/src/issue/infrastructure/persistence/models/enum.ts @@ -0,0 +1,7 @@ +import { baseSchema } from '@shared/entities'; + +import { PRIORITY, ISSUE_TYPE } from '../../../domain/entities/enum'; + +export const priorityEnum = baseSchema.enum('priority', PRIORITY); + +export const issueTypeEnum = baseSchema.enum('issue_type', ISSUE_TYPE); diff --git a/src/issue/infrastructure/persistence/models/index.ts b/src/issue/infrastructure/persistence/models/index.ts index a4a9fdc..7b19b87 100644 --- a/src/issue/infrastructure/persistence/models/index.ts +++ b/src/issue/infrastructure/persistence/models/index.ts @@ -1 +1,2 @@ -export {} from './'; +export { issues } from './issue.model'; +export { issueTypeEnum, priorityEnum } from './enum'; diff --git a/src/issue/infrastructure/persistence/models/issue.model.ts b/src/issue/infrastructure/persistence/models/issue.model.ts new file mode 100644 index 0000000..62ccce9 --- /dev/null +++ b/src/issue/infrastructure/persistence/models/issue.model.ts @@ -0,0 +1,74 @@ +import { createId } from '@paralleldrive/cuid2'; +import { areas, baseSchema, states, users } from '@shared/entities'; +import { sql } from 'drizzle-orm'; +import { timestamp, integer, varchar, text, index, check } from 'drizzle-orm/pg-core'; + +import { issueTypeEnum, priorityEnum } from './enum'; + +export const issues = baseSchema.table( + 'issues', + { + id: text('id') + .primaryKey() + .$defaultFn(() => createId()), + title: varchar('title', { length: 255 }).notNull(), + description: text('description'), + descriptionHtml: text('description_html'), + priority: priorityEnum('priority').default('medium').notNull(), + type: issueTypeEnum('type').default('task').notNull(), + areaId: text('area_id') + .notNull() + .references(() => areas.id, { onDelete: 'cascade' }), + stateId: text('state_id').references(() => states.id, { + onDelete: 'set null', + }), + position: integer('position').default(0), + assigneeId: text('assignee_id').references(() => users.id, { + onDelete: 'set null', + }), + reporterId: text('reporter_id').references(() => users.id, { + onDelete: 'set null', + }), + parentId: text('parent_id').references((): any => issues.id, { + onDelete: 'set null', + }), + storyPoints: integer('story_points'), + dueDate: timestamp('due_date', { withTimezone: true, mode: 'string' }), + createdAt: timestamp('created_at', { withTimezone: true, mode: 'string' }) + .defaultNow() + .notNull(), + updatedAt: timestamp('updated_at', { withTimezone: true, mode: 'string' }) + .defaultNow() + .notNull(), + deletedAt: timestamp('deleted_at', { withTimezone: true, mode: 'string' }), + }, + (table) => ({ + areaStateIdx: index('idx_issue_area_state') + .on(table.areaId, table.stateId, table.position) + .where(sql`${table.deletedAt} IS NULL`), + assigneeIdx: index('idx_issue_assignee') + .on(table.assigneeId) + .where(sql`${table.deletedAt} IS NULL`), + parentIdx: index('idx_issue_parent') + .on(table.parentId) + .where(sql`${table.deletedAt} IS NULL`), + priorityIdx: index('idx_issue_priority') + .on(table.priority) + .where(sql`${table.deletedAt} IS NULL`), + typeIdx: index('idx_issue_type') + .on(table.type) + .where(sql`${table.deletedAt} IS NULL`), + + searchIdx: index('idx_issue_search') + .using( + 'gin', + sql`to_tsvector('english', COALESCE(${table.title}, '') || ' ' || COALESCE(${table.description}, ''))`, + ) + .where(sql`${table.deletedAt} IS NULL`), + + noSelfParent: check( + 'no_self_parent', + sql`${table.parentId} IS NULL OR ${table.parentId} != ${table.id}`, + ), + }), +); diff --git a/src/issue/infrastructure/persistence/repositories/index.ts b/src/issue/infrastructure/persistence/repositories/index.ts index 4e1a2b5..2cfbc09 100644 --- a/src/issue/infrastructure/persistence/repositories/index.ts +++ b/src/issue/infrastructure/persistence/repositories/index.ts @@ -1,6 +1,8 @@ +import { IssueRepository } from './issue.repository'; + export const REPOSITORIES = [ - // { - // provide: '', - // useClass: null, - // }, + { + provide: 'IIssueRepository', + useClass: IssueRepository, + }, ]; diff --git a/src/issue/infrastructure/persistence/repositories/issue.repository.ts b/src/issue/infrastructure/persistence/repositories/issue.repository.ts new file mode 100644 index 0000000..a371ff9 --- /dev/null +++ b/src/issue/infrastructure/persistence/repositories/issue.repository.ts @@ -0,0 +1,83 @@ +import { DATABASE_SERVICE, DatabaseService } from '@libs/database'; +import { Inject } from '@nestjs/common'; +import { and, eq, isNull, type SQL } from 'drizzle-orm'; + +import { IssueQueryDto } from '../../../application/dtos'; +import * as schema from '../models/issue.model'; + +import type { IIssueRepository } from '../../../domain/repositories'; + +export class IssueRepository implements IIssueRepository { + constructor( + @Inject(DATABASE_SERVICE) + private readonly db: DatabaseService, + ) {} + + public async create(data: typeof schema.issues.$inferInsert, userId: string) { + const [result] = await this.db + .insert(schema.issues) + .values({ ...data, reporterId: userId }) + .returning({ id: schema.issues.id }); + + if (!result) { + throw new Error('Failed to create issue: no issue returned'); + } + + return result; + } + + public async delete(id: string, _userId: string) { + const result = await this.db + .update(schema.issues) + .set({ + deletedAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }) + .where(and(eq(schema.issues.id, id), isNull(schema.issues.deletedAt))); + + return (result.count ?? 0) > 0; + } + + public async find(_query: IssueQueryDto) { + const conditions: SQL[] = [isNull(schema.issues.deletedAt)]; + + return this.db + .select() + .from(schema.issues) + .where(and(...conditions)); + } + + public async findOne(id: string, _userId: string) { + const [result] = await this.db + .select() + .from(schema.issues) + .where(and(eq(schema.issues.id, id), isNull(schema.issues.deletedAt))); + + return result ?? null; + } + + public async restore(id: string, _userId: string) { + const result = await this.db + .update(schema.issues) + .set({ + deletedAt: null, + updatedAt: new Date().toISOString(), + }) + .where(eq(schema.issues.id, id)); + + return (result.count ?? 0) > 0; + } + + public async update( + id: string, + data: Partial, + _userId: string, + ) { + const result = await this.db + .update(schema.issues) + .set(data) + .where(and(eq(schema.issues.id, id), isNull(schema.issues.deletedAt))); + + return (result.count ?? 0) > 0; + } +} diff --git a/src/shared/entities/index.ts b/src/shared/entities/index.ts index b357898..c385bea 100644 --- a/src/shared/entities/index.ts +++ b/src/shared/entities/index.ts @@ -4,3 +4,4 @@ export * from '../../auth/infrastructure/persistence/models'; export * from '../../teams/infrastructure/persistence/models'; export * from '../../project/infrastructure/persistence/models'; export * from '../../area/infrastructure/persistence/models'; +export * from '../../issue/infrastructure/persistence/models'; From 2a5540790dab568cbbb33389d680ece1d3fd4e4e Mon Sep 17 00:00:00 2001 From: Maxim Date: Sat, 20 Jun 2026 20:00:18 +0300 Subject: [PATCH 5/6] feat(issues): add standardized error codes and messages for issue domain --- src/issue/domain/errors/index.ts | 1 + src/issue/domain/errors/issue.errors.ts | 66 +++++++++++++++++++++++++ 2 files changed, 67 insertions(+) create mode 100644 src/issue/domain/errors/index.ts create mode 100644 src/issue/domain/errors/issue.errors.ts diff --git a/src/issue/domain/errors/index.ts b/src/issue/domain/errors/index.ts new file mode 100644 index 0000000..fcbf538 --- /dev/null +++ b/src/issue/domain/errors/index.ts @@ -0,0 +1 @@ +export * from './issue.errors'; diff --git a/src/issue/domain/errors/issue.errors.ts b/src/issue/domain/errors/issue.errors.ts new file mode 100644 index 0000000..2db2ba5 --- /dev/null +++ b/src/issue/domain/errors/issue.errors.ts @@ -0,0 +1,66 @@ +export const IssueErrorCodes = { + // 404 + NOT_FOUND: 'ISSUE.NOT_FOUND', + + // 409 — Conflict + TITLE_DUPLICATE: 'ISSUE.TITLE_DUPLICATE', + ALREADY_DELETED: 'ISSUE.ALREADY_DELETED', + NOT_DELETED: 'ISSUE.NOT_DELETED', + + // 400 — Bad Request + TITLE_REQUIRED: 'ISSUE.TITLE_REQUIRED', + TITLE_TOO_LONG: 'ISSUE.TITLE_TOO_LONG', + AREA_REQUIRED: 'ISSUE.AREA_REQUIRED', + INVALID_PRIORITY: 'ISSUE.INVALID_PRIORITY', + INVALID_TYPE: 'ISSUE.INVALID_TYPE', + INVALID_POSITION: 'ISSUE.INVALID_POSITION', + PARENT_NOT_FOUND: 'ISSUE.PARENT_NOT_FOUND', + SELF_PARENT: 'ISSUE.SELF_PARENT', + + // 403 — Forbidden + ACCESS_DENIED: 'ISSUE.ACCESS_DENIED', + + // 422 — Unprocessable + HAS_SUBTASKS: 'ISSUE.HAS_SUBTASKS', + CANNOT_MOVE_TO_DIFFERENT_AREA: 'ISSUE.CANNOT_MOVE_TO_DIFFERENT_AREA', + + // 500 — Internal + CREATE_FAILED: 'ISSUE.CREATE_FAILED', + UPDATE_FAILED: 'ISSUE.UPDATE_FAILED', + DELETE_FAILED: 'ISSUE.DELETE_FAILED', + RESTORE_FAILED: 'ISSUE.RESTORE_FAILED', + MOVE_FAILED: 'ISSUE.MOVE_FAILED', + ASSIGN_FAILED: 'ISSUE.ASSIGN_FAILED', +} as const; + +export type IssueErrorCode = (typeof IssueErrorCodes)[keyof typeof IssueErrorCodes]; + +export const IssueErrorMessages: Record = { + [IssueErrorCodes.NOT_FOUND]: 'Задача не найдена', + + [IssueErrorCodes.TITLE_DUPLICATE]: 'Задача с таким заголовком уже существует в этой колонке', + [IssueErrorCodes.ALREADY_DELETED]: 'Задача уже удалена', + [IssueErrorCodes.NOT_DELETED]: 'Задача не удалена, восстановление не требуется', + + [IssueErrorCodes.TITLE_REQUIRED]: 'Заголовок задачи не может быть пустым', + [IssueErrorCodes.TITLE_TOO_LONG]: 'Заголовок задачи слишком длинный (максимум 255 символов)', + [IssueErrorCodes.AREA_REQUIRED]: 'ID области обязателен для создания задачи', + [IssueErrorCodes.INVALID_PRIORITY]: 'Недопустимый приоритет задачи', + [IssueErrorCodes.INVALID_TYPE]: 'Недопустимый тип задачи', + [IssueErrorCodes.INVALID_POSITION]: 'Позиция должна быть неотрицательным целым числом', + [IssueErrorCodes.PARENT_NOT_FOUND]: 'Родительская задача не найдена', + [IssueErrorCodes.SELF_PARENT]: 'Задача не может быть родителем самой себя', + + [IssueErrorCodes.ACCESS_DENIED]: 'У вас нет доступа к этой задаче', + + [IssueErrorCodes.HAS_SUBTASKS]: 'Нельзя удалить задачу, у которой есть активные подзадачи', + [IssueErrorCodes.CANNOT_MOVE_TO_DIFFERENT_AREA]: + 'Нельзя переместить задачу в другую область через этот метод', + + [IssueErrorCodes.CREATE_FAILED]: 'Не удалось создать задачу', + [IssueErrorCodes.UPDATE_FAILED]: 'Не удалось обновить задачу', + [IssueErrorCodes.DELETE_FAILED]: 'Не удалось удалить задачу', + [IssueErrorCodes.RESTORE_FAILED]: 'Не удалось восстановить задачу', + [IssueErrorCodes.MOVE_FAILED]: 'Не удалось переместить задачу', + [IssueErrorCodes.ASSIGN_FAILED]: 'Не удалось назначить исполнителя', +} as const; From 3d2952a97d06709ca063c1dc3944b4776cb9b94f Mon Sep 17 00:00:00 2001 From: Maxim Date: Sat, 20 Jun 2026 21:29:33 +0300 Subject: [PATCH 6/6] feat(issues): implement issue use cases --- src/area/application/use-cases/index.ts | 5 + src/area/area.module.ts | 6 +- .../controllers/issues/controller.ts | 52 +++++-- .../application/controllers/issues/swagger.ts | 12 +- src/issue/application/dtos/issue.dto.ts | 38 +++-- src/issue/application/issue.facade.ts | 45 ++++-- src/issue/application/mappers/index.ts | 1 + src/issue/application/mappers/issue.mapper.ts | 5 + .../use-cases/base/assign.use-case.ts | 81 +++++++++-- .../use-cases/base/create.use-case.ts | 113 +++++++++++++-- .../use-cases/base/delete.use-case.ts | 51 ++++++- .../use-cases/base/find-all.query.ts | 33 +++++ .../use-cases/base/find-one.query.ts | 43 ++++++ .../use-cases/base/get-all.query.ts | 12 -- .../use-cases/base/get-one.query.ts | 10 -- .../use-cases/base/move.use-case.ts | 70 +++++++-- .../use-cases/base/restore.use-case.ts | 49 +++++++ .../use-cases/base/update.use-case.ts | 135 ++++++++++++++++-- src/issue/application/use-cases/index.ts | 17 ++- src/issue/issue.module.ts | 4 +- .../member/find-project-member.query.ts | 11 ++ .../application/use-cases/member/index.ts | 8 +- .../project/check-visibility.query.ts | 27 ++++ .../application/use-cases/project/index.ts | 3 + .../project.repository.interface.ts | 1 + .../repositories/project.repository.ts | 9 ++ src/project/project.module.ts | 16 ++- 27 files changed, 725 insertions(+), 132 deletions(-) create mode 100644 src/issue/application/mappers/index.ts create mode 100644 src/issue/application/mappers/issue.mapper.ts create mode 100644 src/issue/application/use-cases/base/find-all.query.ts create mode 100644 src/issue/application/use-cases/base/find-one.query.ts delete mode 100644 src/issue/application/use-cases/base/get-all.query.ts delete mode 100644 src/issue/application/use-cases/base/get-one.query.ts create mode 100644 src/issue/application/use-cases/base/restore.use-case.ts create mode 100644 src/project/application/use-cases/member/find-project-member.query.ts create mode 100644 src/project/application/use-cases/project/check-visibility.query.ts diff --git a/src/area/application/use-cases/index.ts b/src/area/application/use-cases/index.ts index 69b00cf..4801d01 100644 --- a/src/area/application/use-cases/index.ts +++ b/src/area/application/use-cases/index.ts @@ -1,2 +1,7 @@ +import { AreasUseCases } from './areas'; +import { StatesUseCases } from './states'; + export * from './states'; export * from './areas'; + +export const USE_CASES = [...AreasUseCases, ...StatesUseCases]; diff --git a/src/area/area.module.ts b/src/area/area.module.ts index 9dfbc90..9ffe21e 100644 --- a/src/area/area.module.ts +++ b/src/area/area.module.ts @@ -3,13 +3,13 @@ import { forwardRef, Module } from '@nestjs/common'; import { AreaFacade } from './application/area.facade'; import { CONTROLLERS } from './application/controllers'; -import { AreasUseCases, StatesUseCases } from './application/use-cases'; +import { GetAreaQuery, GetStateQuery, USE_CASES } from './application/use-cases'; import { REPOSITORIES } from './infrastructure/persistence/repositories'; @Module({ imports: [forwardRef(() => ProjectModule)], controllers: [...CONTROLLERS], - providers: [...REPOSITORIES, ...StatesUseCases, ...AreasUseCases, AreaFacade], - exports: [], + providers: [...REPOSITORIES, ...USE_CASES, AreaFacade], + exports: [GetAreaQuery, GetStateQuery], }) export class AreaModule {} diff --git a/src/issue/application/controllers/issues/controller.ts b/src/issue/application/controllers/issues/controller.ts index 5523ddc..f6e6640 100644 --- a/src/issue/application/controllers/issues/controller.ts +++ b/src/issue/application/controllers/issues/controller.ts @@ -10,11 +10,12 @@ import { Put, Query, } from '@nestjs/common'; -import { ApiBaseController, GetUserId } from '@shared/decorators'; +import { ApiBaseController, GetUserId, SkipContract } from '@shared/decorators'; import { AssignIssueDto, CreateIssueDto, + IssueFiltersQueryDto, IssueQueryDto, MoveIssueDto, UpdateIssueDto, @@ -37,44 +38,67 @@ export class IssuesController { @Post() @CreateIssueSwagger() - create(@Body() dto: CreateIssueDto, @GetUserId() userId: string) { - return this.facade.create(dto, userId); + create(@Body() dto: CreateIssueDto, @Query() q: IssueQueryDto, @GetUserId() userId: string) { + return this.facade.create(dto, q.slug, q.key, userId); } @Get(':id') + @SkipContract() @GetOneIssueSwagger() - getById(@Param('id') id: string, @GetUserId() userId: string) { - return this.facade.getOne(id, userId); + getById(@Query() q: IssueQueryDto, @Param('id') id: string, @GetUserId() userId: string) { + return this.facade.getOne(id, q.slug, userId); } @Get() + @SkipContract() @GetAllIssuesSwagger() - getAll(@Query('boardId') query: IssueQueryDto, @GetUserId() userId: string) { + getAll(@Query() query: IssueFiltersQueryDto, @GetUserId() userId: string) { return this.facade.getAll(query, userId); } @Patch(':id') @UpdateIssueSwagger() - update(@Param('id') id: string, @Body() dto: UpdateIssueDto, @GetUserId() userId: string) { - return this.facade.update(id, dto, userId); + update( + @Param('id') id: string, + @Query() q: IssueQueryDto, + @Body() dto: UpdateIssueDto, + @GetUserId() userId: string, + ) { + return this.facade.update(id, q.slug, q.key, dto, userId); } @Post(':id/move') @MoveIssueSwagger() @HttpCode(HttpStatus.OK) - move(@Param('id') id: string, @Body() dto: MoveIssueDto, @GetUserId() userId: string) { - return this.facade.move(id, dto, userId); + move( + @Param('id') id: string, + @Query() q: IssueQueryDto, + @Body() dto: MoveIssueDto, + @GetUserId() userId: string, + ) { + return this.facade.move(id, q.slug, q.key, dto, userId); } @Put(':id/assignee') @AssignIssueSwagger() - assign(@Param('id') id: string, @Body() dto: AssignIssueDto, @GetUserId() userId: string) { - return this.facade.assign(id, dto, userId); + assign( + @Param('id') id: string, + @Query() q: IssueQueryDto, + @Body() dto: AssignIssueDto, + @GetUserId() userId: string, + ) { + return this.facade.assign(id, q.slug, dto, userId); + } + + @Post(':id/restore') + @DeleteIssueSwagger() + restore(@Param('id') id: string, @Query() q: IssueQueryDto, @GetUserId() userId: string) { + return this.facade.restore(id, q.slug, userId); } @Delete(':id') @DeleteIssueSwagger() - delete(@Param('id') id: string, @GetUserId() userId: string) { - return this.facade.delete(id, userId); + delete(@Param('id') id: string, @Query() q: IssueQueryDto, @GetUserId() userId: string) { + return this.facade.delete(id, q.slug, userId); } } diff --git a/src/issue/application/controllers/issues/swagger.ts b/src/issue/application/controllers/issues/swagger.ts index 83aae9b..9d9f3c4 100644 --- a/src/issue/application/controllers/issues/swagger.ts +++ b/src/issue/application/controllers/issues/swagger.ts @@ -80,13 +80,13 @@ export const GetAllIssuesSwagger = () => 'Возвращает задачи с учётом фильтров, сортировки и пагинации.', '', '### Основной сценарий — отображение доски', - '- Передайте `boardId` чтобы получить все задачи на конкретной доске', + '- Передайте `areaId` чтобы получить все задачи конкретной области', '- Задачи будут сгруппированы по колонкам и отсортированы по позиции', '- Это позволяет отрисовать Kanban-доску одним запросом', '', '### Фильтры', - '- `boardId` — показать задачи только с этой доски', - '- `columnId` — показать задачи в конкретной колонке', + '- `areaId` — показать задачи только с этой области (доски)', + '- `stateId` — показать задачи конкретного состояния (колонки)', '- `assigneeId` — задачи конкретного исполнителя', '- `priority` — фильтр по приоритету (LOW, MEDIUM, HIGH, CRITICAL)', '- `type` — фильтр по типу (TASK, BUG, EPIC)', @@ -103,14 +103,14 @@ export const GetAllIssuesSwagger = () => '- `offset` — смещение для пагинации (по умолчанию 0)', '', '### Примеры использования', - '- Все задачи на доске: `GET /v1/issues?boardId=xxx`', + '- Все задачи на доске: `GET /v1/issues?areaId=xxx`', '- Мои задачи: `GET /v1/issues?assigneeId=yyy`', '- Критические баги: `GET /v1/issues?type=BUG&priority=CRITICAL`', '- Поиск: `GET /v1/issues?search=oauth+error`', ].join('\n'), }), ApiQuery({ - name: 'boardId', + name: 'areaId', required: false, type: 'string', format: 'uuid', @@ -118,7 +118,7 @@ export const GetAllIssuesSwagger = () => example: 'd4e5f6a7-b8c9-0123-defa-234567890123', }), ApiQuery({ - name: 'columnId', + name: 'stateId', required: false, type: 'string', format: 'uuid', diff --git a/src/issue/application/dtos/issue.dto.ts b/src/issue/application/dtos/issue.dto.ts index d18553a..3258c38 100644 --- a/src/issue/application/dtos/issue.dto.ts +++ b/src/issue/application/dtos/issue.dto.ts @@ -80,6 +80,7 @@ export const IssueSchema = z.object({ .number() .int('Story points должны быть целым числом') .min(0, 'Story points не могут быть отрицательными') + .max(10000, 'Story points не могут быть больше 1000') .nullable() .optional() .describe('Оценка сложности в story points (для Scrum)'), @@ -176,22 +177,28 @@ export class AssignIssueDto extends createZodDto(AssignIssueSchema) {} export const IssueQuerySchema = z .object({ - areaId: z.string().optional().describe('Фильтр по области'), - stateId: z.string().optional().describe('Фильтр по состоянию (колонке)'), - assigneeId: z.string().optional().describe('Фильтр по исполнителю'), - reporterId: z.string().optional().describe('Фильтр по автору'), - priority: PriorityEnumSchema.optional().describe('Фильтр по приоритету'), - type: IssueTypeEnumSchema.optional().describe('Фильтр по типу задачи'), - parentId: z - .string() - .nullable() - .optional() - .describe('Фильтр по родителю (null — только задачи верхнего уровня)'), - labels: z - .string() - .optional() - .describe('Метки через запятую (AND — задача должна иметь все указанные)'), + slug: z.string().describe('Слаг проекта'), + key: z.string().describe('Слаг области'), }) + + .describe('Обязательные Query параметры для управления задачами'); + +export const IssueFiltersQuerySchema = IssueQuerySchema.extend({ + stateId: z.string().optional().describe('Фильтр по состоянию (колонке)'), + assigneeId: z.string().optional().describe('Фильтр по исполнителю'), + reporterId: z.string().optional().describe('Фильтр по автору'), + priority: PriorityEnumSchema.optional().describe('Фильтр по приоритету'), + type: IssueTypeEnumSchema.optional().describe('Фильтр по типу задачи'), + parentId: z + .string() + .nullable() + .optional() + .describe('Фильтр по родителю (null — только задачи верхнего уровня)'), + labels: z + .string() + .optional() + .describe('Метки через запятую (AND — задача должна иметь все указанные)'), +}) .extend(PaginationBaseSchema.shape) .extend(createSortingSchema(['position', 'createdAt', 'priority']).shape) .describe('Query параметры для получения списка задач с фильтрацией'); @@ -199,6 +206,7 @@ export const IssueQuerySchema = z export const IssuesSchema = z.array(IssueSchema); export class IssueQueryDto extends createZodDto(IssueQuerySchema) {} +export class IssueFiltersQueryDto extends createZodDto(IssueFiltersQuerySchema) {} export class IssueResponse extends createZodDto(IssueSchema) {} export class IssuesResponse extends createZodDto(IssuesSchema) {} diff --git a/src/issue/application/issue.facade.ts b/src/issue/application/issue.facade.ts index 5a0a6ea..8d107ed 100644 --- a/src/issue/application/issue.facade.ts +++ b/src/issue/application/issue.facade.ts @@ -11,9 +11,10 @@ import { AssignIssueUseCase, CreateIssueUseCase, DeleteIssueUseCase, - GetAllIssueQuery, - GetOneIssueQuery, + FindAllIssueQuery, + FindOneIssueQuery, MoveIssueUseCase, + RestoreIssueUseCase, UpdateIssueUseCase, } from './use-cases'; @@ -24,27 +25,43 @@ export class IssueFacade { private readonly updateIssueUC: UpdateIssueUseCase, private readonly deleteIssueUC: DeleteIssueUseCase, private readonly assignIssueUC: AssignIssueUseCase, - private readonly getOneIssueQ: GetOneIssueQuery, - private readonly getAllIssueQ: GetAllIssueQuery, + private readonly getOneIssueQ: FindOneIssueQuery, + private readonly getAllIssueQ: FindAllIssueQuery, private readonly moveIssueUC: MoveIssueUseCase, + private readonly restoreIssueUC: RestoreIssueUseCase, ) {} - public create = async (dto: CreateIssueDto, userId: string) => - this.createIssueUC.execute(dto, userId); + public create = async (dto: CreateIssueDto, slug: string, key: string, userId: string) => + this.createIssueUC.execute(dto, slug, key, userId); - public getOne = async (id: string, userId: string) => this.getOneIssueQ.execute(id, userId); + public getOne = async (id: string, slug: string, userId: string) => + this.getOneIssueQ.execute(id, slug, userId); public getAll = async (query: IssueQueryDto, userId: string) => this.getAllIssueQ.execute(query, userId); - public update = async (id: string, dto: UpdateIssueDto, userId: string) => - this.updateIssueUC.execute(id, dto, userId); + public update = async ( + id: string, + slug: string, + key: string, + dto: UpdateIssueDto, + userId: string, + ) => this.updateIssueUC.execute(id, slug, key, dto, userId); - public move = async (id: string, dto: MoveIssueDto, userId: string) => - this.moveIssueUC.execute(id, dto, userId); + public move = async ( + id: string, + slug: string, + key: string, + dto: MoveIssueDto, + userId: string, + ) => this.moveIssueUC.execute(id, slug, key, dto, userId); - public assign = async (id: string, dto: AssignIssueDto, userId: string) => - this.assignIssueUC.execute(id, dto, userId); + public assign = async (id: string, slug: string, dto: AssignIssueDto, userId: string) => + this.assignIssueUC.execute(id, slug, dto, userId); - public delete = async (id: string, userId: string) => this.deleteIssueUC.execute(id, userId); + public delete = async (id: string, slug: string, userId: string) => + this.deleteIssueUC.execute(id, slug, userId); + + public restore = async (id: string, slug: string, userId: string) => + this.restoreIssueUC.execute(id, slug, userId); } diff --git a/src/issue/application/mappers/index.ts b/src/issue/application/mappers/index.ts new file mode 100644 index 0000000..53a5178 --- /dev/null +++ b/src/issue/application/mappers/index.ts @@ -0,0 +1 @@ +export * from './issue.mapper'; diff --git a/src/issue/application/mappers/issue.mapper.ts b/src/issue/application/mappers/issue.mapper.ts new file mode 100644 index 0000000..ed9c83b --- /dev/null +++ b/src/issue/application/mappers/issue.mapper.ts @@ -0,0 +1,5 @@ +export class IssueMapper { + public static toDomain(data: any) { + return data; + } +} diff --git a/src/issue/application/use-cases/base/assign.use-case.ts b/src/issue/application/use-cases/base/assign.use-case.ts index 92d17b1..e06efd6 100644 --- a/src/issue/application/use-cases/base/assign.use-case.ts +++ b/src/issue/application/use-cases/base/assign.use-case.ts @@ -1,18 +1,77 @@ -import { Injectable } from '@nestjs/common'; +import { IssueErrorCodes, IssueErrorMessages } from '@core/issue/domain/errors'; +import { IIssueRepository } from '@core/issue/domain/repositories'; +import { FindProjectMemberQuery } from '@core/project/application/use-cases'; +import { MemberErrorCodes, MemberErrorMessages } from '@core/project/domain/errors'; +import { ProjectAccessPolicy } from '@core/project/domain/policy'; +import { HttpStatus, Inject, Injectable } from '@nestjs/common'; +import { BaseException } from '@shared/error'; import { AssignIssueDto } from '../../dtos'; @Injectable() export class AssignIssueUseCase { - constructor() {} - - async execute(id: string, dto: AssignIssueDto, userId: string) { - return { - success: true, - message: '', - id, - dto, - userId, - }; + constructor( + @Inject('IIssueRepository') + private readonly issueRepo: IIssueRepository, + private readonly getProjectMember: FindProjectMemberQuery, + private readonly projectPolicy: ProjectAccessPolicy, + ) {} + + async execute(id: string, slug: string, dto: AssignIssueDto, userId: string) { + try { + const { project } = await this.projectPolicy.ensureProjectAccess(slug, userId, [ + 'owner', + 'admin', + ]); + + const issue = await this.issueRepo.findOne(id, userId); + + if (!issue) { + throw new BaseException( + { + code: IssueErrorCodes.NOT_FOUND, + message: IssueErrorMessages[IssueErrorCodes.NOT_FOUND], + }, + HttpStatus.NOT_FOUND, + ); + } + + if (dto.assigneeId) { + const member = await this.getProjectMember.execute(project.id, dto.assigneeId); + + if (!member) { + throw new BaseException( + { + code: MemberErrorCodes.NOT_FOUND, + message: MemberErrorMessages[MemberErrorCodes.NOT_FOUND], + }, + HttpStatus.NOT_FOUND, + ); + } + } + + const result = await this.issueRepo.update(id, { assigneeId: dto.assigneeId }, userId); + + return { + success: result, + message: result + ? dto.assigneeId + ? 'Исполнитель успешно назначен' + : 'Исполнитель успешно снят' + : 'Не удалось назначить исполнителя', + }; + } catch (e) { + if (e instanceof BaseException) { + throw e; + } + + throw new BaseException( + { + code: IssueErrorCodes.ASSIGN_FAILED, + message: IssueErrorMessages[IssueErrorCodes.ASSIGN_FAILED], + }, + HttpStatus.INTERNAL_SERVER_ERROR, + ); + } } } diff --git a/src/issue/application/use-cases/base/create.use-case.ts b/src/issue/application/use-cases/base/create.use-case.ts index ceb1d1f..c1bbcb4 100644 --- a/src/issue/application/use-cases/base/create.use-case.ts +++ b/src/issue/application/use-cases/base/create.use-case.ts @@ -1,17 +1,110 @@ -import { Injectable } from '@nestjs/common'; +import { GetAreaQuery, GetStateQuery } from '@core/area/application/use-cases'; +import { IssueErrorCodes, IssueErrorMessages } from '@core/issue/domain/errors'; +import { IIssueRepository } from '@core/issue/domain/repositories'; +import { FindProjectMemberQuery } from '@core/project/application/use-cases'; +import { MemberErrorCodes, MemberErrorMessages } from '@core/project/domain/errors'; +import { ProjectAccessPolicy } from '@core/project/domain/policy'; +import { HttpStatus, Inject, Injectable } from '@nestjs/common'; +import { BaseException } from '@shared/error'; import { CreateIssueDto } from '../../dtos'; @Injectable() export class CreateIssueUseCase { - constructor() {} - - async execute(dto: CreateIssueDto, userId: string) { - return { - success: true, - message: '', - dto, - userId, - }; + constructor( + @Inject('IIssueRepository') + private readonly issueRepo: IIssueRepository, + private readonly getArea: GetAreaQuery, + private readonly getState: GetStateQuery, + private readonly getProjectMember: FindProjectMemberQuery, + private readonly projectPolicy: ProjectAccessPolicy, + ) {} + + async execute(dto: CreateIssueDto, slug: string, key: string, userId: string) { + try { + const { project } = await this.projectPolicy.ensureProjectAccess(slug, userId, [ + 'owner', + 'admin', + ]); + + await this.validateContext(dto, userId, project.id, key); + + const result = await this.issueRepo.create(dto, userId); + + return { + success: true, + message: 'Задача успешно создана', + id: result.id, + }; + } catch (e) { + if (e instanceof BaseException) { + throw e; + } + + throw new BaseException( + { + code: IssueErrorCodes.CREATE_FAILED, + message: IssueErrorMessages[IssueErrorCodes.CREATE_FAILED], + }, + HttpStatus.INTERNAL_SERVER_ERROR, + ); + } + } + + private async validateContext( + dto: CreateIssueDto, + userId: string, + projectId: string, + key: string, + ) { + if (dto.stateId) { + await this.getState.execute(key, dto.stateId, userId); + } else { + await this.getArea.execute({ key }, userId); + } + + if (dto.assigneeId) { + const projectMember = await this.getProjectMember.execute(projectId, dto.assigneeId); + + if (!projectMember) { + throw new BaseException( + { + code: MemberErrorCodes.NOT_FOUND, + message: MemberErrorMessages[MemberErrorCodes.NOT_FOUND], + details: [{ target: 'assignee' }], + }, + HttpStatus.NOT_FOUND, + ); + } + } + + if (dto.reporterId) { + const projectMember = await this.getProjectMember.execute(projectId, dto.reporterId); + + if (!projectMember) { + throw new BaseException( + { + code: MemberErrorCodes.NOT_FOUND, + message: MemberErrorMessages[MemberErrorCodes.NOT_FOUND], + details: [{ target: 'reporter' }], + }, + HttpStatus.NOT_FOUND, + ); + } + } + + if (dto.parentId) { + const parent = await this.issueRepo.findOne(dto.parentId, userId); + + if (!parent) { + throw new BaseException( + { + code: IssueErrorCodes.PARENT_NOT_FOUND, + message: IssueErrorMessages[IssueErrorCodes.PARENT_NOT_FOUND], + }, + HttpStatus.NOT_FOUND, + ); + } + } } } diff --git a/src/issue/application/use-cases/base/delete.use-case.ts b/src/issue/application/use-cases/base/delete.use-case.ts index 44f975e..31938fe 100644 --- a/src/issue/application/use-cases/base/delete.use-case.ts +++ b/src/issue/application/use-cases/base/delete.use-case.ts @@ -1,10 +1,53 @@ -import { Injectable } from '@nestjs/common'; +import { IssueErrorCodes, IssueErrorMessages } from '@core/issue/domain/errors'; +import { IIssueRepository } from '@core/issue/domain/repositories'; +import { ProjectAccessPolicy } from '@core/project/domain/policy'; +import { HttpStatus, Inject, Injectable } from '@nestjs/common'; +import { BaseException } from '@shared/error'; @Injectable() export class DeleteIssueUseCase { - constructor() {} + constructor( + @Inject('IIssueRepository') + private readonly issueRepo: IIssueRepository, + private readonly projectPolicy: ProjectAccessPolicy, + ) {} - async execute(id: string, userId: string) { - return { id, userId }; + async execute(id: string, slug: string, userId: string) { + try { + await this.projectPolicy.ensureProjectAccess(slug, userId, ['owner', 'admin']); + + const issue = await this.issueRepo.findOne(id, userId); + + if (!issue) { + throw new BaseException( + { + code: IssueErrorCodes.NOT_FOUND, + message: IssueErrorMessages[IssueErrorCodes.NOT_FOUND], + }, + HttpStatus.NOT_FOUND, + ); + } + + const result = await this.issueRepo.delete(id, userId); + + return { + success: result, + message: result + ? 'Задача успешно удалена' + : 'Не удалось удалить задачу: запись не найдена или уже удалена', + }; + } catch (e) { + if (e instanceof BaseException) { + throw e; + } + + throw new BaseException( + { + code: IssueErrorCodes.DELETE_FAILED, + message: IssueErrorMessages[IssueErrorCodes.DELETE_FAILED], + }, + HttpStatus.INTERNAL_SERVER_ERROR, + ); + } } } diff --git a/src/issue/application/use-cases/base/find-all.query.ts b/src/issue/application/use-cases/base/find-all.query.ts new file mode 100644 index 0000000..90fe003 --- /dev/null +++ b/src/issue/application/use-cases/base/find-all.query.ts @@ -0,0 +1,33 @@ +import { IIssueRepository } from '@core/issue/domain/repositories'; +import { CheckVisibilityOrThrowQuery } from '@core/project/application/use-cases'; +import { ProjectAccessPolicy } from '@core/project/domain/policy'; +import { Inject, Injectable } from '@nestjs/common'; + +import { IssueQueryDto } from '../../dtos'; + +@Injectable() +export class FindAllIssueQuery { + constructor( + @Inject('IIssueRepository') + private readonly issueRepo: IIssueRepository, + private readonly projectVisibility: CheckVisibilityOrThrowQuery, + private readonly projectPolicy: ProjectAccessPolicy, + ) {} + + async execute(query: IssueQueryDto, userId: string) { + const visibility = await this.projectVisibility.execute(query.slug); + + if (visibility === 'private') { + await this.projectPolicy.ensureProjectAccess(query.slug, userId); + } + + const issues = await this.issueRepo.find(query); + + return issues.map((issue) => ({ + ...issue, + createdAt: new Date(issue.createdAt).toISOString(), + updatedAt: new Date(issue.updatedAt).toISOString(), + ...(issue.deletedAt && { deletedAt: new Date(issue.deletedAt).toISOString() }), + })); + } +} diff --git a/src/issue/application/use-cases/base/find-one.query.ts b/src/issue/application/use-cases/base/find-one.query.ts new file mode 100644 index 0000000..0f49f57 --- /dev/null +++ b/src/issue/application/use-cases/base/find-one.query.ts @@ -0,0 +1,43 @@ +import { IssueErrorCodes, IssueErrorMessages } from '@core/issue/domain/errors'; +import { IIssueRepository } from '@core/issue/domain/repositories'; +import { CheckVisibilityOrThrowQuery } from '@core/project/application/use-cases'; +import { ProjectAccessPolicy } from '@core/project/domain/policy'; +import { HttpStatus, Inject, Injectable } from '@nestjs/common'; +import { BaseException } from '@shared/error'; + +@Injectable() +export class FindOneIssueQuery { + constructor( + @Inject('IIssueRepository') + private readonly issueRepo: IIssueRepository, + private readonly projectVisibility: CheckVisibilityOrThrowQuery, + private readonly projectPolicy: ProjectAccessPolicy, + ) {} + + async execute(id: string, slug: string, userId: string) { + const visibility = await this.projectVisibility.execute(slug); + + if (visibility === 'private') { + await this.projectPolicy.ensureProjectAccess(slug, userId); + } + + const issue = await this.issueRepo.findOne(id, userId); + + if (!issue) { + throw new BaseException( + { + code: IssueErrorCodes.NOT_FOUND, + message: IssueErrorMessages[IssueErrorCodes.NOT_FOUND], + }, + HttpStatus.NOT_FOUND, + ); + } + + return { + ...issue, + createdAt: new Date(issue.createdAt).toISOString(), + updatedAt: new Date(issue.updatedAt).toISOString(), + ...(issue.deletedAt && { deletedAt: new Date(issue.deletedAt).toISOString() }), + }; + } +} diff --git a/src/issue/application/use-cases/base/get-all.query.ts b/src/issue/application/use-cases/base/get-all.query.ts deleted file mode 100644 index 27b5e52..0000000 --- a/src/issue/application/use-cases/base/get-all.query.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { Injectable } from '@nestjs/common'; - -import { IssueQueryDto } from '../../dtos'; - -@Injectable() -export class GetAllIssueQuery { - constructor() {} - - async execute(query: IssueQueryDto, userId: string) { - return { query, userId }; - } -} diff --git a/src/issue/application/use-cases/base/get-one.query.ts b/src/issue/application/use-cases/base/get-one.query.ts deleted file mode 100644 index a80dac2..0000000 --- a/src/issue/application/use-cases/base/get-one.query.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { Injectable } from '@nestjs/common'; - -@Injectable() -export class GetOneIssueQuery { - constructor() {} - - async execute(id: string, userId: string) { - return { id, userId }; - } -} diff --git a/src/issue/application/use-cases/base/move.use-case.ts b/src/issue/application/use-cases/base/move.use-case.ts index 3b81428..2de6f05 100644 --- a/src/issue/application/use-cases/base/move.use-case.ts +++ b/src/issue/application/use-cases/base/move.use-case.ts @@ -1,18 +1,66 @@ -import { Injectable } from '@nestjs/common'; +import { GetAreaQuery, GetStateQuery } from '@core/area/application/use-cases'; +import { IssueErrorCodes, IssueErrorMessages } from '@core/issue/domain/errors'; +import { IIssueRepository } from '@core/issue/domain/repositories'; +import { ProjectAccessPolicy } from '@core/project/domain/policy'; +import { HttpStatus, Inject, Injectable } from '@nestjs/common'; +import { BaseException } from '@shared/error'; import { MoveIssueDto } from '../../dtos'; @Injectable() export class MoveIssueUseCase { - constructor() {} - - async execute(id: string, dto: MoveIssueDto, userId: string) { - return { - success: true, - message: '', - id, - dto, - userId, - }; + constructor( + @Inject('IIssueRepository') + private readonly issueRepo: IIssueRepository, + private readonly getArea: GetAreaQuery, + private readonly getState: GetStateQuery, + private readonly projectPolicy: ProjectAccessPolicy, + ) {} + + async execute(id: string, slug: string, key: string, dto: MoveIssueDto, userId: string) { + try { + await this.projectPolicy.ensureProjectAccess(slug, userId, ['owner', 'admin']); + + const issue = await this.issueRepo.findOne(id, userId); + + if (!issue) { + throw new BaseException( + { + code: IssueErrorCodes.NOT_FOUND, + message: IssueErrorMessages[IssueErrorCodes.NOT_FOUND], + }, + HttpStatus.NOT_FOUND, + ); + } + await this.validateContext(dto, key, userId); + + const result = await this.issueRepo.update(id, dto, userId); + + return { + success: result, + message: result ? 'Задача успешно перемещена' : 'Не удалось переместить задачу', + }; + } catch (e) { + if (e instanceof BaseException) { + throw e; + } + + throw new BaseException( + { + code: IssueErrorCodes.MOVE_FAILED, + message: IssueErrorMessages[IssueErrorCodes.MOVE_FAILED], + }, + HttpStatus.INTERNAL_SERVER_ERROR, + ); + } + } + + private async validateContext(dto: MoveIssueDto, key: string, userId: string) { + if (dto.targetAreaId) { + await this.getArea.execute({ key }, userId); + } + if (dto.targetStateId) { + await this.getState.execute(key, dto.targetStateId, userId); + } } } diff --git a/src/issue/application/use-cases/base/restore.use-case.ts b/src/issue/application/use-cases/base/restore.use-case.ts new file mode 100644 index 0000000..56110a7 --- /dev/null +++ b/src/issue/application/use-cases/base/restore.use-case.ts @@ -0,0 +1,49 @@ +import { IssueErrorCodes, IssueErrorMessages } from '@core/issue/domain/errors'; +import { IIssueRepository } from '@core/issue/domain/repositories'; +import { ProjectAccessPolicy } from '@core/project/domain/policy'; +import { HttpStatus, Inject, Injectable } from '@nestjs/common'; +import { BaseException } from '@shared/error'; + +@Injectable() +export class RestoreIssueUseCase { + constructor( + @Inject('IIssueRepository') + private readonly issueRepo: IIssueRepository, + private readonly projectPolicy: ProjectAccessPolicy, + ) {} + + async execute(id: string, slug: string, userId: string) { + try { + await this.projectPolicy.ensureProjectAccess(slug, userId, ['owner', 'admin']); + + const result = await this.issueRepo.restore(id, userId); + + if (!result) { + throw new BaseException( + { + code: IssueErrorCodes.NOT_FOUND, + message: IssueErrorMessages[IssueErrorCodes.NOT_FOUND], + }, + HttpStatus.NOT_FOUND, + ); + } + + return { + success: true, + message: 'Задача успешно восстановлена', + }; + } catch (e) { + if (e instanceof BaseException) { + throw e; + } + + throw new BaseException( + { + code: IssueErrorCodes.RESTORE_FAILED, + message: IssueErrorMessages[IssueErrorCodes.RESTORE_FAILED], + }, + HttpStatus.INTERNAL_SERVER_ERROR, + ); + } + } +} diff --git a/src/issue/application/use-cases/base/update.use-case.ts b/src/issue/application/use-cases/base/update.use-case.ts index 4a4f3fb..2625f59 100644 --- a/src/issue/application/use-cases/base/update.use-case.ts +++ b/src/issue/application/use-cases/base/update.use-case.ts @@ -1,18 +1,131 @@ -import { Injectable } from '@nestjs/common'; +import { GetStateQuery } from '@core/area/application/use-cases'; +import { IssueErrorCodes, IssueErrorMessages } from '@core/issue/domain/errors'; +import { IIssueRepository } from '@core/issue/domain/repositories'; +import { FindProjectMemberQuery } from '@core/project/application/use-cases'; +import { MemberErrorCodes, MemberErrorMessages } from '@core/project/domain/errors'; +import { ProjectAccessPolicy } from '@core/project/domain/policy'; +import { HttpStatus, Inject, Injectable } from '@nestjs/common'; +import { BaseException } from '@shared/error'; import { UpdateIssueDto } from '../../dtos'; @Injectable() export class UpdateIssueUseCase { - constructor() {} - - async execute(id: string, dto: UpdateIssueDto, userId: string) { - return { - success: true, - message: '', - id, - dto, - userId, - }; + constructor( + @Inject('IIssueRepository') + private readonly issueRepo: IIssueRepository, + private readonly getState: GetStateQuery, + private readonly getProjectMember: FindProjectMemberQuery, + private readonly projectPolicy: ProjectAccessPolicy, + ) {} + + async execute(id: string, slug: string, key: string, dto: UpdateIssueDto, userId: string) { + try { + const { project } = await this.projectPolicy.ensureProjectAccess(slug, userId, [ + 'owner', + 'admin', + ]); + + const issue = await this.issueRepo.findOne(id, userId); + + if (!issue) { + throw new BaseException( + { + code: IssueErrorCodes.NOT_FOUND, + message: IssueErrorMessages[IssueErrorCodes.NOT_FOUND], + }, + HttpStatus.NOT_FOUND, + ); + } + + await this.validateContext(id, dto, project.id, key, userId); + + const result = await this.issueRepo.update(id, dto, userId); + + return { + success: result, + message: result + ? 'Задача успешно обновлена' + : 'Не удалось обновить задачу: запись не найдена', + }; + } catch (e) { + if (e instanceof BaseException) { + throw e; + } + + throw new BaseException( + { + code: IssueErrorCodes.UPDATE_FAILED, + message: IssueErrorMessages[IssueErrorCodes.UPDATE_FAILED], + }, + HttpStatus.INTERNAL_SERVER_ERROR, + ); + } + } + + private async validateContext( + id: string, + dto: UpdateIssueDto, + projectId: string, + key: string, + userId: string, + ) { + if (dto.assigneeId) { + const member = await this.getProjectMember.execute(projectId, dto.assigneeId); + + if (!member) { + throw new BaseException( + { + code: MemberErrorCodes.NOT_FOUND, + message: MemberErrorMessages[MemberErrorCodes.NOT_FOUND], + details: [{ target: 'assignee' }], + }, + HttpStatus.NOT_FOUND, + ); + } + } + + if (dto.reporterId) { + const member = await this.getProjectMember.execute(projectId, dto.reporterId); + + if (!member) { + throw new BaseException( + { + code: MemberErrorCodes.NOT_FOUND, + message: MemberErrorMessages[MemberErrorCodes.NOT_FOUND], + details: [{ target: 'reporter' }], + }, + HttpStatus.NOT_FOUND, + ); + } + } + + if (dto.stateId) { + await this.getState.execute(key, dto.stateId, userId); + } + + if (dto.parentId) { + if (dto.parentId === id) { + throw new BaseException( + { + code: IssueErrorCodes.SELF_PARENT, + message: IssueErrorMessages[IssueErrorCodes.SELF_PARENT], + }, + HttpStatus.BAD_REQUEST, + ); + } + + const parent = await this.issueRepo.findOne(dto.parentId, userId); + + if (!parent) { + throw new BaseException( + { + code: IssueErrorCodes.PARENT_NOT_FOUND, + message: IssueErrorMessages[IssueErrorCodes.PARENT_NOT_FOUND], + }, + HttpStatus.NOT_FOUND, + ); + } + } } } diff --git a/src/issue/application/use-cases/index.ts b/src/issue/application/use-cases/index.ts index beea343..2e82199 100644 --- a/src/issue/application/use-cases/index.ts +++ b/src/issue/application/use-cases/index.ts @@ -1,18 +1,20 @@ import { AssignIssueUseCase } from './base/assign.use-case'; import { CreateIssueUseCase } from './base/create.use-case'; import { DeleteIssueUseCase } from './base/delete.use-case'; -import { GetAllIssueQuery } from './base/get-all.query'; -import { GetOneIssueQuery } from './base/get-one.query'; +import { FindAllIssueQuery } from './base/find-all.query'; +import { FindOneIssueQuery } from './base/find-one.query'; import { MoveIssueUseCase } from './base/move.use-case'; +import { RestoreIssueUseCase } from './base/restore.use-case'; import { UpdateIssueUseCase } from './base/update.use-case'; export * from './base/assign.use-case'; export * from './base/create.use-case'; export * from './base/delete.use-case'; -export * from './base/get-all.query'; -export * from './base/get-one.query'; -export * from './base/update.use-case'; +export * from './base/find-all.query'; +export * from './base/find-one.query'; export * from './base/move.use-case'; +export * from './base/restore.use-case'; +export * from './base/update.use-case'; export const USE_CASES = [ CreateIssueUseCase, @@ -20,6 +22,7 @@ export const USE_CASES = [ DeleteIssueUseCase, AssignIssueUseCase, MoveIssueUseCase, - GetOneIssueQuery, - GetAllIssueQuery, + RestoreIssueUseCase, + FindOneIssueQuery, + FindAllIssueQuery, ]; diff --git a/src/issue/issue.module.ts b/src/issue/issue.module.ts index bfa7c42..b90c9e6 100644 --- a/src/issue/issue.module.ts +++ b/src/issue/issue.module.ts @@ -1,3 +1,5 @@ +import { AreaModule } from '@core/area'; +import { ProjectModule } from '@core/project'; import { Module } from '@nestjs/common'; import { CONTROLLERS } from './application/controllers'; @@ -6,7 +8,7 @@ import { USE_CASES } from './application/use-cases'; import { REPOSITORIES } from './infrastructure/persistence/repositories'; @Module({ - imports: [], + imports: [AreaModule, ProjectModule], controllers: CONTROLLERS, providers: [...REPOSITORIES, ...USE_CASES, IssueFacade], }) diff --git a/src/project/application/use-cases/member/find-project-member.query.ts b/src/project/application/use-cases/member/find-project-member.query.ts new file mode 100644 index 0000000..0e7c1e7 --- /dev/null +++ b/src/project/application/use-cases/member/find-project-member.query.ts @@ -0,0 +1,11 @@ +import { IMemberRepository } from '@core/project/domain/repository'; +import { Inject, Injectable } from '@nestjs/common'; + +@Injectable() +export class FindProjectMemberQuery { + constructor(@Inject('IMemberRepository') private readonly memberRepo: IMemberRepository) {} + + execute(projectId: string, memberId: string) { + return this.memberRepo.findByProjectAndUser(projectId, memberId); + } +} diff --git a/src/project/application/use-cases/member/index.ts b/src/project/application/use-cases/member/index.ts index b25b53c..f3373d4 100644 --- a/src/project/application/use-cases/member/index.ts +++ b/src/project/application/use-cases/member/index.ts @@ -1,6 +1,7 @@ import { AddProjectMemberUseCase } from './add.use-case'; import { DeleteProjectMemberUseCase } from './delete.use-case'; import { FindAllProjectMembersQuery } from './find-all.query'; +import { FindProjectMemberQuery } from './find-project-member.query'; import { GetAvailableTeamMemberQuery } from './get-available.query'; import { UpdateProjectMemberUseCase } from './update.use-case'; @@ -9,8 +10,13 @@ export * from './delete.use-case'; export * from './find-all.query'; export * from './get-available.query'; export * from './update.use-case'; +export * from './find-project-member.query'; -export const MemberQueries = [FindAllProjectMembersQuery, GetAvailableTeamMemberQuery]; +export const MemberQueries = [ + FindAllProjectMembersQuery, + GetAvailableTeamMemberQuery, + FindProjectMemberQuery, +]; export const MemberUseCases = [ AddProjectMemberUseCase, diff --git a/src/project/application/use-cases/project/check-visibility.query.ts b/src/project/application/use-cases/project/check-visibility.query.ts new file mode 100644 index 0000000..0aeb6ed --- /dev/null +++ b/src/project/application/use-cases/project/check-visibility.query.ts @@ -0,0 +1,27 @@ +import { ProjectErrorCodes, ProjectErrorMessages } from '@core/project/domain/errors'; +import { IProjectRepository } from '@core/project/domain/repository'; +import { HttpStatus, Inject, Injectable } from '@nestjs/common'; +import { BaseException } from '@shared/error'; + +@Injectable() +export class CheckVisibilityOrThrowQuery { + constructor( + @Inject('IProjectRepository') + private readonly projectsRepo: IProjectRepository, + ) {} + async execute(slug: string) { + const result = await this.projectsRepo.checkVisibility(slug); + + if (!result) { + throw new BaseException( + { + code: ProjectErrorCodes.NOT_FOUND, + message: ProjectErrorMessages[ProjectErrorCodes.NOT_FOUND], + }, + HttpStatus.NOT_FOUND, + ); + } + + return result.visibility; + } +} diff --git a/src/project/application/use-cases/project/index.ts b/src/project/application/use-cases/project/index.ts index 3039bda..1bfe4e4 100644 --- a/src/project/application/use-cases/project/index.ts +++ b/src/project/application/use-cases/project/index.ts @@ -1,4 +1,5 @@ import { CheckSlugAvailabilityQuery } from './check-slug.use-case'; +import { CheckVisibilityOrThrowQuery } from './check-visibility.query'; import { CreateProjectUseCase } from './create.use-case'; import { DeleteProjectUseCase } from './delete.use-case'; import { FindProjectsByTeamQuery } from './find-by-team.query'; @@ -17,6 +18,7 @@ export * from './get-detail.query'; export * from './set-status.use-case'; export * from './update.use-case'; export * from './check-slug.use-case'; +export * from './check-visibility.query'; export const ProjectUseCases = [ CreateProjectUseCase, @@ -24,6 +26,7 @@ export const ProjectUseCases = [ GenerateShareTokenUseCase, SetProjectStatusUseCase, UpdateProjectUseCase, + CheckVisibilityOrThrowQuery, ]; export const ProjectQueries = [ diff --git a/src/project/domain/repository/project.repository.interface.ts b/src/project/domain/repository/project.repository.interface.ts index 319fa6b..144547d 100644 --- a/src/project/domain/repository/project.repository.interface.ts +++ b/src/project/domain/repository/project.repository.interface.ts @@ -17,4 +17,5 @@ export interface IProjectRepository { revokeAllShares(projectId: string): Promise; countByTeam(teamId: string): Promise; + checkVisibility(slug: string): Promise | null>; } diff --git a/src/project/infrastructure/persistence/repositories/project.repository.ts b/src/project/infrastructure/persistence/repositories/project.repository.ts index 6d84c14..3a24871 100644 --- a/src/project/infrastructure/persistence/repositories/project.repository.ts +++ b/src/project/infrastructure/persistence/repositories/project.repository.ts @@ -174,4 +174,13 @@ export class ProjectRepository implements IProjectRepository { return result?.count ?? 0; }; + + public readonly checkVisibility = async (slug: string) => { + const [result] = await this.db + .select({ visibility: schema.projects.visibility }) + .from(schema.projects) + .where(and(eq(schema.projects.slug, slug), isNull(schema.projects.deletedAt))); + + return result ?? null; + }; } diff --git a/src/project/project.module.ts b/src/project/project.module.ts index 6fbd286..fe4d037 100644 --- a/src/project/project.module.ts +++ b/src/project/project.module.ts @@ -4,7 +4,13 @@ import { forwardRef, Module } from '@nestjs/common'; import { CONTROLLERS } from './application/controllers'; import { ProjectFacade } from './application/project.facade'; -import { CreateProjectUseCase, FindProjectQuery, USE_CASES } from './application/use-cases'; +import { + CreateProjectUseCase, + FindProjectQuery, + FindProjectMemberQuery, + USE_CASES, + CheckVisibilityOrThrowQuery, +} from './application/use-cases'; import { POLICIES, ProjectAccessPolicy } from './domain/policy'; import { REPOSITORIES } from './infrastructure/persistence/repositories'; @@ -12,6 +18,12 @@ import { REPOSITORIES } from './infrastructure/persistence/repositories'; imports: [UserModule, forwardRef(() => TeamsModule)], controllers: CONTROLLERS, providers: [...REPOSITORIES, ...POLICIES, ...USE_CASES, ProjectFacade], - exports: [FindProjectQuery, ProjectAccessPolicy, CreateProjectUseCase], + exports: [ + FindProjectQuery, + ProjectAccessPolicy, + CreateProjectUseCase, + FindProjectMemberQuery, + CheckVisibilityOrThrowQuery, + ], }) export class ProjectModule {}