diff --git a/drizzle.config.ts b/drizzle.config.ts index 12eb06bc..025c8ab8 100644 --- a/drizzle.config.ts +++ b/drizzle.config.ts @@ -9,7 +9,7 @@ export default defineConfig({ strict: true, verbose: true, dbCredentials: { - url: process.env.DATABASE_URL!, + url: process.env['DATABASE_URL']!, }, introspect: { casing: 'camel' }, schemaFilter: ['*'], diff --git a/libs/bootstrap/src/bootstrap.ts b/libs/bootstrap/src/bootstrap.ts index 7ec37da0..5131df08 100644 --- a/libs/bootstrap/src/bootstrap.ts +++ b/libs/bootstrap/src/bootstrap.ts @@ -10,13 +10,14 @@ import fastifyCompress from '@fastify/compress'; import fastifyMultipart from '@fastify/multipart'; import fastifyCsrf from '@fastify/csrf-protection'; import { createId } from '@paralleldrive/cuid2'; +import type { IncomingMessage } from 'http'; export async function bootstrapApp(options: BootstrapOptions) { const startTime = performance.now(); const adapter = new FastifyAdapter({ requestIdHeader: 'x-request-id', requestIdLogLabel: 'request', - genReqId: (req) => { + genReqId: (req: IncomingMessage) => { return (req.headers['x-request-id'] as string) || createId(); }, }); @@ -46,7 +47,7 @@ export async function bootstrapApp(options: BootstrapOptions) { bufferLogs: false, }); - const logger = new Logger(serviceName[0].toUpperCase() + serviceName.slice(1)); + const logger = new Logger(serviceName?.[0]?.toUpperCase() + serviceName.slice(1)); const configService = app.get(ConfigService); const port = configService.getOrThrow(portEnvKey, defaultPort); const origins = configService.getOrThrow('CORS_ALLOWED_ORIGINS'); @@ -152,7 +153,7 @@ export async function bootstrapApp(options: BootstrapOptions) { } const startupTime = (performance.now() - startTime).toFixed(2); - logger.verbose(`Environment: ${process.env.NODE_ENV || 'development'}`); + logger.verbose(`Environment: ${process.env['NODE_ENV'] || 'development'}`); logger.verbose(`API Endpoint: ${baseUrl}`); logger.verbose(`Health Check: ${baseUrl}/health`); logger.verbose(`Swagger UI: ${swaggerBase}/${swaggerPath}`); diff --git a/libs/bootstrap/src/configs/throttler.ts b/libs/bootstrap/src/configs/throttler.ts index 95532f94..b186fcbf 100644 --- a/libs/bootstrap/src/configs/throttler.ts +++ b/libs/bootstrap/src/configs/throttler.ts @@ -3,8 +3,8 @@ import type { ThrottlerModuleOptions } from '@nestjs/throttler'; export const DEFAULT_THROTTLER_OPTIONS: ThrottlerModuleOptions = [ { - ttl: process.env.THROTTLE_TTL ? parseInt(process.env.THROTTLE_LIMIT) : 60000, - limit: process.env.THROTTLE_LIMIT ? parseInt(process.env.THROTTLE_LIMIT) : 100, + ttl: process.env['THROTTLE_TTL'] ? parseInt(process.env['THROTTLE_LIMIT'] ?? '') : 60000, + limit: process.env['THROTTLE_LIMIT'] ? parseInt(process.env['THROTTLE_LIMIT']) : 100, skipIf: (context) => context.getType() !== 'http', }, ]; diff --git a/libs/bootstrap/src/setups/logger.ts b/libs/bootstrap/src/setups/logger.ts index c0456b3a..df7dc9a2 100644 --- a/libs/bootstrap/src/setups/logger.ts +++ b/libs/bootstrap/src/setups/logger.ts @@ -94,26 +94,26 @@ export class LoggingInterceptor implements NestInterceptor { ); } - private sanitize(data: T) { + private sanitize(data: T): T { if (!data || typeof data !== 'object') return data; - if (Array.isArray(data)) return data.map((v) => this.sanitize(v)); + if (Array.isArray(data)) return data.map((v) => this.sanitize(v)) as T; - const cleanData = JSON.parse(JSON.stringify(data)); + const cleanData = JSON.parse(JSON.stringify(data)) as Record; - return Object.keys(cleanData).reduce((acc, key) => { + return Object.keys(cleanData).reduce>((acc, key) => { const isSensitive = this.sensitiveFields.some((field) => key.toLowerCase().includes(field), ); if (isSensitive) { acc[key] = '***'; - } else if (typeof cleanData[key] === 'object') { + } else if (typeof cleanData[key] === 'object' && cleanData[key] !== null) { acc[key] = this.sanitize(cleanData[key]); } else { acc[key] = cleanData[key]; } return acc; - }, {}); + }, {}) as T; } } diff --git a/libs/bootstrap/src/setups/swagger.ts b/libs/bootstrap/src/setups/swagger.ts index b18afe42..d4347cf1 100644 --- a/libs/bootstrap/src/setups/swagger.ts +++ b/libs/bootstrap/src/setups/swagger.ts @@ -15,7 +15,13 @@ async function getCustomCSS() { } export async function setupSwagger(app: NestFastifyApplication, options: SwaggerOptions = {}) { - const { title, description, version, path, server } = { + const { + title = 'Api', + description = '', + version = 'v0.0.1', + path = 'api', + server, + } = { ...SWAGGER_DEFAULTS, ...options, }; diff --git a/libs/config/src/config.types.d.ts b/libs/config/src/config.types.d.ts index c47f988f..d387eab1 100644 --- a/libs/config/src/config.types.d.ts +++ b/libs/config/src/config.types.d.ts @@ -7,6 +7,8 @@ declare module '@nestjs/config' { * Переопределяем метод get, чтобы он предлагал ключи из нашей схемы */ get(key: T): Config[T]; + get(key: T, defaultValue: Config[T]): Config[T]; + /** * Переопределяем метод getOrThrow, чтобы он предлагал ключи из нашей схемы */ diff --git a/libs/health/src/health.service.ts b/libs/health/src/health.service.ts index c5de0a38..b1299377 100644 --- a/libs/health/src/health.service.ts +++ b/libs/health/src/health.service.ts @@ -21,7 +21,11 @@ export class HealthService { const results = await Promise.all( Object.entries(indicators).map(async ([name, check]) => { - let timeoutId: NodeJS.Timeout; + if (!check || typeof check !== 'function') { + return { name, ok: false, error: 'Health check not configured' }; + } + + let timeoutId: NodeJS.Timeout | undefined; const timeoutPromise = new Promise((_, reject) => { timeoutId = setTimeout(() => reject(new Error('Timeout')), 5000); @@ -42,6 +46,8 @@ export class HealthService { const isAllOk = results.every((r) => r.ok); const components = Object.fromEntries(results.map((r) => [r.name, r.ok ? 'up' : 'down'])); + const loaded = os.loadavg()[0]; + return { service: serviceName, status: isAllOk, @@ -56,7 +62,7 @@ export class HealthService { uptime: this.formatUptime(uptimeSeconds), uptimeSeconds: uptimeSeconds, }, - loaded: os.loadavg()[0].toFixed(2), + loaded: loaded?.toFixed(2), }; } diff --git a/libs/metrics/src/metrics.controller.ts b/libs/metrics/src/metrics.controller.ts index 7ef1d6a2..7dfce547 100644 --- a/libs/metrics/src/metrics.controller.ts +++ b/libs/metrics/src/metrics.controller.ts @@ -1,12 +1,12 @@ import { Controller, Get, Res } from '@nestjs/common'; import * as client from 'prom-client'; import { FastifyReply } from 'fastify'; -import { SkipContractHandle } from '@shared/decorators'; +import { SkipContract } from '@shared/decorators'; @Controller('metrics') export class MetricsController { @Get() - @SkipContractHandle() + @SkipContract() async getMetrics(@Res() reply: FastifyReply) { const metrics = await client.register.metrics(); reply.type(client.register.contentType).send(metrics); diff --git a/libs/metrics/src/metrics.module.ts b/libs/metrics/src/metrics.module.ts index 545da6e8..2e50db29 100644 --- a/libs/metrics/src/metrics.module.ts +++ b/libs/metrics/src/metrics.module.ts @@ -9,7 +9,7 @@ import { APP_INTERCEPTOR } from '@nestjs/core'; PrometheusModule.register({ controller: MetricsController, defaultMetrics: { - enabled: process.env.NODE_ENV !== 'test', + enabled: process.env['NODE_ENV'] !== 'test', }, }), ], diff --git a/src/app.module.ts b/src/app.module.ts index 1b3e30db..be2f4dbd 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -62,7 +62,7 @@ import { MetricsModule } from '@libs/metrics'; HealthModule.registerAsync({ inject: [DatabaseHealthService, S3Service, CACHE_SERVICE], useFactory: (db: DatabaseHealthService, s3: S3Service, cache: ICacheService) => { - const version = process.env.npm_package_version; + const version = process.env['npm_package_version'] ?? ''; return { serviceName: 'gateway', diff --git a/src/area/infrastructure/persistence/repositories/area.repository.ts b/src/area/infrastructure/persistence/repositories/area.repository.ts index 3bc9aae0..5a095cdf 100644 --- a/src/area/infrastructure/persistence/repositories/area.repository.ts +++ b/src/area/infrastructure/persistence/repositories/area.repository.ts @@ -20,6 +20,10 @@ export class AreaRepository implements IAreaRepository { .values(data) .returning({ id: schema.areas.id, slug: schema.areas.slug }); + if (!area) { + throw new Error('Failed to create area: no area returned'); + } + const statesData = DEFAULT_STATES.map((state) => ({ areaId: area.id, title: state.title, diff --git a/src/area/infrastructure/persistence/repositories/state.repository.ts b/src/area/infrastructure/persistence/repositories/state.repository.ts index 066168be..8bc1b530 100644 --- a/src/area/infrastructure/persistence/repositories/state.repository.ts +++ b/src/area/infrastructure/persistence/repositories/state.repository.ts @@ -18,6 +18,10 @@ export class StateRepository implements IStateRepository { .values(data) .returning({ id: schema.states.id }); + if (!result) { + throw new Error('Failed to create state: no state returned'); + } + return result; } @@ -107,12 +111,12 @@ export class StateRepository implements IStateRepository { return result ?? null; } - public async countByArea(areaId: string) { + public countByArea = async (areaId: string) => { const [result] = await this.db .select({ count: count() }) .from(schema.states) .where(and(eq(schema.states.areaId, areaId), isNull(schema.states.deletedAt))); - return result.count; - } + return result?.count ?? 0; + }; } diff --git a/src/auth/application/auth.facade.ts b/src/auth/application/auth.facade.ts index a3c9d831..839ef46d 100644 --- a/src/auth/application/auth.facade.ts +++ b/src/auth/application/auth.facade.ts @@ -62,11 +62,11 @@ export class AuthFacade { return this.signUpVerifyUseCase.execute(dto, device); } - async signOut(userId: string) { - return this.signOutUseCase.execute(userId); + async signOut(token?: string) { + return this.signOutUseCase.execute(token); } - async refreshTokens(token: string, device: DeviceMetadata) { + async refreshTokens(token: string | undefined, device: DeviceMetadata) { return this.refreshTokensUseCase.execute(token, device); } diff --git a/src/auth/application/controller/auth/controller.ts b/src/auth/application/controller/auth/controller.ts index d393b7f7..5c7ffcf8 100644 --- a/src/auth/application/controller/auth/controller.ts +++ b/src/auth/application/controller/auth/controller.ts @@ -18,7 +18,7 @@ import { ConfigService } from '@nestjs/config'; @ApiBaseController('auth', 'Auth') export class AuthController { private readonly isProduction: boolean = false; - private readonly domain: string | null = null; + private readonly domain?: string | null = null; constructor( private readonly facade: AuthFacade, @@ -79,6 +79,7 @@ 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', { diff --git a/src/auth/application/controller/oauth/controller.ts b/src/auth/application/controller/oauth/controller.ts index a7064d06..35de0687 100644 --- a/src/auth/application/controller/oauth/controller.ts +++ b/src/auth/application/controller/oauth/controller.ts @@ -12,13 +12,13 @@ import type { FastifyReply, FastifyRequest } from 'fastify'; import { BearerAuthGuard, OAuthGuard } from '@shared/guards'; import { AuthFacade } from '../../auth.facade'; import { getDeviceMeta } from '@core/auth/infrastructure/utils'; -import { ApiBaseController, GetUserId, SkipContractHandle } from '@shared/decorators'; +import { ApiBaseController, GetUserId, SkipContract } from '@shared/decorators'; import { ConfigService } from '@nestjs/config'; @ApiBaseController('auth/oauth', 'OAuth') export class OAuthController { private readonly isProduction: boolean = false; - private readonly domain: string | null = null; + private readonly domain?: string | null = null; constructor( private readonly facade: AuthFacade, @@ -31,13 +31,13 @@ export class OAuthController { @Get(':provider') @OAuthLoginSwagger() @UseGuards(OAuthGuard) - @SkipContractHandle() + @SkipContract() async oauthLogin() {} @Get(':provider/callback') @OAuthCallbackSwagger() @UseGuards(OAuthGuard) - @SkipContractHandle() + @SkipContract() async oauthCallback( @Query() query: { code?: string; state?: string }, @Param('provider') provider: 'google' | 'yandex' | 'github' | 'vkontakte', @@ -63,7 +63,7 @@ export class OAuthController { const baseUrl = `https://dev.${this.domain}`; - if (result.isSign) { + if (result.isSign && result.refresh) { this.setRefreshCookie(res, result.refresh, result.expiresAt); res.redirect(`${baseUrl}/oauth?${result.query.toString()}`, 302); } else { diff --git a/src/auth/application/use-cases/confirm-reset-password.use-case.ts b/src/auth/application/use-cases/confirm-reset-password.use-case.ts index 5545b795..ffb36460 100644 --- a/src/auth/application/use-cases/confirm-reset-password.use-case.ts +++ b/src/auth/application/use-cases/confirm-reset-password.use-case.ts @@ -11,7 +11,7 @@ export class ConfirmResetPasswordUseCase { constructor( @Inject(CACHE_SERVICE) private readonly cacheService: ICacheService, - private readonly updatePasswordUserUseCase: UpdatePasswordUseCase, + private readonly updatePasswordUserUC: UpdatePasswordUseCase, ) {} async execute(dto: PasswordResetConfirmDto) { @@ -43,7 +43,7 @@ export class ConfirmResetPasswordUseCase { } const hashed = await argon.hash(dto.password); - const isUpdated = await this.updatePasswordUserUseCase.execute(dto.email, hashed); + const isUpdated = await this.updatePasswordUserUC.execute(dto.email, hashed); if (!isUpdated) { throw new BaseException( 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 14ba5c7a..d1347a71 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 @@ -1,4 +1,4 @@ -import { IIdentitiesRepository } from '@core/auth/domain/repository'; +import { IIdentityRepository } from '@core/auth/domain/repository'; import { FindUserQuery } from '@core/user'; import { HttpStatus, Inject, Injectable } from '@nestjs/common'; import { CACHE_SERVICE } from '@shared/adapters/cache/constants'; @@ -9,11 +9,11 @@ import { BaseException } from '@shared/error'; @Injectable() export class ConnectOAuthProviderUseCase { constructor( - @Inject('IIdentitiesRepository') - private readonly identitiesRepo: IIdentitiesRepository, + @Inject('IIdentityRepository') + private readonly identityRepo: IIdentityRepository, @Inject(CACHE_SERVICE) private readonly cacheService: ICacheService, - private readonly findUserQuery: FindUserQuery, + private readonly findUserQ: FindUserQuery, ) {} async execute(dto: OAuthResponse, state: string) { @@ -25,7 +25,7 @@ export class ConnectOAuthProviderUseCase { await this.validateProviderNotConnected(user.id, dto.provider, dto.id); - await this.identitiesRepo.create({ + await this.identityRepo.create({ userId: user.id, avatarUrl: dto.avatar_url, provider: dto.provider as any, @@ -78,7 +78,7 @@ export class ConnectOAuthProviderUseCase { } private async getUser(userId: string) { - const result = await this.findUserQuery.execute({ id: userId }); + const result = await this.findUserQ.execute({ id: userId }); if (!result?.user) { throw new BaseException( @@ -98,7 +98,7 @@ export class ConnectOAuthProviderUseCase { provider: string, providerUserId: string, ) { - const existingIdentity = await this.identitiesRepo.findByProvider( + const existingIdentity = await this.identityRepo.findByProvider( provider as any, providerUserId, ); @@ -113,7 +113,7 @@ export class ConnectOAuthProviderUseCase { ); } - const userIdentities = await this.identitiesRepo.findAllByUserId(userId); + const userIdentities = await this.identityRepo.findAllByUserId(userId); const alreadyConnected = userIdentities.some((i) => i.provider === provider); if (alreadyConnected) { 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 2b68879d..134e0a39 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 @@ -4,16 +4,16 @@ 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 { IIdentitiesRepository } from '@core/auth/domain/repository'; +import { IIdentityRepository } from '@core/auth/domain/repository'; @Injectable() export class ConnectProviderUseCase { constructor( @Inject(CACHE_SERVICE) private readonly cacheService: ICacheService, - @Inject('IIdentitiesRepository') - private readonly identitiesRepo: IIdentitiesRepository, - private readonly findUserQuery: FindUserQuery, + @Inject('IIdentityRepository') + private readonly identityRepo: IIdentityRepository, + private readonly findUserQ: FindUserQuery, ) {} private readonly STATE_TTL = 180; // 3 минуты @@ -57,7 +57,7 @@ export class ConnectProviderUseCase { } private async validateUser(userId: string) { - const entity = await this.findUserQuery.execute({ id: userId }); + const entity = await this.findUserQ.execute({ id: userId }); if (!entity?.user) { throw new BaseException( { @@ -70,7 +70,7 @@ export class ConnectProviderUseCase { } private async validateProviderNotConnected(userId: string, provider: string) { - const identities = await this.identitiesRepo.findAllByUserId(userId); + const identities = await this.identityRepo.findAllByUserId(userId); const isConnected = identities.some((identity) => identity.provider === provider); if (isConnected) { diff --git a/src/auth/application/use-cases/oauth/disconnect-provider.use-case.ts b/src/auth/application/use-cases/oauth/disconnect-provider.use-case.ts index 4489e27b..f35adf3e 100644 --- a/src/auth/application/use-cases/oauth/disconnect-provider.use-case.ts +++ b/src/auth/application/use-cases/oauth/disconnect-provider.use-case.ts @@ -1,18 +1,18 @@ import { HttpStatus, Inject, Injectable } from '@nestjs/common'; -import { IIdentitiesRepository } from '@core/auth/domain/repository'; +import { IIdentityRepository } from '@core/auth/domain/repository'; import { FindUserQuery } from '@core/user'; import { BaseException } from '@shared/error'; @Injectable() export class DisconnectProviderUseCase { constructor( - @Inject('IIdentitiesRepository') - private readonly identitiesRepo: IIdentitiesRepository, - private readonly findUserQuery: FindUserQuery, + @Inject('IIdentityRepository') + private readonly identityRepo: IIdentityRepository, + private readonly findUserQ: FindUserQuery, ) {} async execute(provider: string, userId: string) { - const entity = await this.findUserQuery.execute({ id: userId }); + const entity = await this.findUserQ.execute({ id: userId }); if (!entity?.user) { throw new BaseException( @@ -24,7 +24,7 @@ export class DisconnectProviderUseCase { ); } - const providers = await this.identitiesRepo.findAllByUserId(entity.user.id); + const providers = await this.identityRepo.findAllByUserId(entity.user.id); const targetProvider = providers.find((p) => p.provider === provider); if (!targetProvider) { @@ -52,7 +52,7 @@ export class DisconnectProviderUseCase { ); } - await this.identitiesRepo.delete(targetProvider.id); + await this.identityRepo.delete(targetProvider.id); return { success: true, diff --git a/src/auth/application/use-cases/oauth/get-connected-providers.query.ts b/src/auth/application/use-cases/oauth/get-connected-providers.query.ts index b4d9fc13..b4ac1e6f 100644 --- a/src/auth/application/use-cases/oauth/get-connected-providers.query.ts +++ b/src/auth/application/use-cases/oauth/get-connected-providers.query.ts @@ -1,11 +1,11 @@ -import { IIdentitiesRepository } from '@core/auth/domain/repository'; +import { IIdentityRepository } from '@core/auth/domain/repository'; import { Inject, Injectable } from '@nestjs/common'; @Injectable() export class GetConnectedProvidersQuery { constructor( - @Inject('IIdentitiesRepository') - private readonly identityRepo: IIdentitiesRepository, + @Inject('IIdentityRepository') + private readonly identityRepo: IIdentityRepository, ) {} async execute(userId: string) { 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 index 5ce8d397..18af65b7 100644 --- 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 @@ -1,19 +1,19 @@ import { HttpStatus, Inject, Injectable } from '@nestjs/common'; import { OAuthResponse } from '../../dtos'; -import { IIdentitiesRepository } from '@core/auth/domain/repository'; +import { IIdentityRepository } from '@core/auth/domain/repository'; import { FindUserQuery } from '@core/user'; import { BaseException } from '@shared/error'; @Injectable() export class ProcessOAuthLoginUseCase { constructor( - @Inject('IIdentitiesRepository') - private readonly identitiesRepo: IIdentitiesRepository, - private readonly findUserQuery: FindUserQuery, + @Inject('IIdentityRepository') + private readonly identityRepo: IIdentityRepository, + private readonly findUserQ: FindUserQuery, ) {} async execute(dto: OAuthResponse) { - const identity = await this.identitiesRepo.findByProvider(dto.provider as any, dto.id); + const identity = await this.identityRepo.findByProvider(dto.provider as any, dto.id); if (!identity) { throw new BaseException( @@ -25,7 +25,7 @@ export class ProcessOAuthLoginUseCase { ); } - const result = await this.findUserQuery.execute({ id: identity.userId }); + const result = await this.findUserQ.execute({ id: identity.userId }); if (!result?.user) { throw new BaseException( 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 index b259044f..461d114f 100644 --- 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 @@ -1,4 +1,4 @@ -import { IIdentitiesRepository } from '@core/auth/domain/repository'; +import { IIdentityRepository } from '@core/auth/domain/repository'; import { FindUserQuery, RegisterUserUseCase } from '@core/user'; import { HttpStatus, Inject, Injectable } from '@nestjs/common'; import { OAuthResponse } from '../../dtos'; @@ -6,17 +6,17 @@ import { BaseException } from '@shared/error'; import { InjectQueue } from '@nestjs/bullmq'; import { AuthQueues, AuthUserJobs } from '@core/auth/domain/enums'; import { Queue } from 'bullmq'; -import { CreateUserWorkspaceEvent } from '@core/auth/domain/events/create-user-workspace.event'; +import { CreateUserWorkspaceEvent } from '@core/auth/domain/events'; @Injectable() export class ProcessOAuthRegistrationUseCase { constructor( @InjectQueue(AuthQueues.AUTH_USER) private readonly queue: Queue, - @Inject('IIdentitiesRepository') - private readonly identitiesRepo: IIdentitiesRepository, - private readonly findUserQuery: FindUserQuery, - private readonly registerUserUseCase: RegisterUserUseCase, + @Inject('IIdentityRepository') + private readonly identityRepo: IIdentityRepository, + private readonly findUserQ: FindUserQuery, + private readonly registerUserUC: RegisterUserUseCase, ) {} async execute(dto: OAuthResponse) { @@ -33,7 +33,7 @@ export class ProcessOAuthRegistrationUseCase { ); } - const user = await this.registerUserUseCase.execute({ + const user = await this.registerUserUC.execute({ email: dto.email, firstName: dto.first_name || 'User', lastName: dto.last_name ?? '', @@ -43,7 +43,7 @@ export class ProcessOAuthRegistrationUseCase { avatarUrl: dto.avatar_url, }); - await this.identitiesRepo.create({ + await this.identityRepo.create({ userId: user.id, avatarUrl: dto.avatar_url, provider: dto.provider as any, @@ -58,7 +58,7 @@ export class ProcessOAuthRegistrationUseCase { } private async findUserByEmail(email: string) { - const result = await this.findUserQuery.execute({ email }); + const result = await this.findUserQ.execute({ email }); return result?.user; } } diff --git a/src/auth/application/use-cases/refresh-tokens.use-case.ts b/src/auth/application/use-cases/refresh-tokens.use-case.ts index 73e1019b..a65ccdc6 100644 --- a/src/auth/application/use-cases/refresh-tokens.use-case.ts +++ b/src/auth/application/use-cases/refresh-tokens.use-case.ts @@ -12,10 +12,20 @@ export class RefreshTokensUseCase { @Inject('ISessionRepository') private readonly sessionRepo: ISessionRepository, private readonly tokenService: TokenService, - private readonly findUserQuery: FindUserQuery, + private readonly findUserQ: FindUserQuery, ) {} - async execute(token: string, metadata: DeviceMetadata) { + async execute(token: string | undefined, metadata: DeviceMetadata) { + if (!token) { + throw new BaseException( + { + code: 'SESSION_REQUIRED', + message: 'Session required', + }, + HttpStatus.UNAUTHORIZED, + ); + } + const payload = await this.tokenService.validateToken(token, 'refresh'); if (!payload?.jti) { @@ -40,7 +50,7 @@ export class RefreshTokensUseCase { ); } - const entity = await this.findUserQuery.execute({ id: session.userId }); + const entity = await this.findUserQ.execute({ id: session.userId }); if (!entity?.user) { await this.sessionRepo.revoke(session.id); diff --git a/src/auth/application/use-cases/reset-password.use-case.ts b/src/auth/application/use-cases/reset-password.use-case.ts index d257efec..cbc410e8 100644 --- a/src/auth/application/use-cases/reset-password.use-case.ts +++ b/src/auth/application/use-cases/reset-password.use-case.ts @@ -22,7 +22,7 @@ export class ResetPasswordUseCase { private readonly cacheService: ICacheService, @InjectQueue(AuthQueues.AUTH_MAIL) private readonly mailQueue: Queue, - private readonly findUserQuery: FindUserQuery, + private readonly findUserQ: FindUserQuery, ) {} async execute(dto: ResetPasswordDto) { @@ -45,7 +45,7 @@ export class ResetPasswordUseCase { ); } - const entity = await this.findUserQuery.execute({ email: dto.email }); + const entity = await this.findUserQ.execute({ email: dto.email }); if (!entity?.user) { throw new BaseException( diff --git a/src/auth/application/use-cases/sign-in.use-case.ts b/src/auth/application/use-cases/sign-in.use-case.ts index a78aa8b9..07be802d 100644 --- a/src/auth/application/use-cases/sign-in.use-case.ts +++ b/src/auth/application/use-cases/sign-in.use-case.ts @@ -14,11 +14,11 @@ export class SignInUseCase { @Inject('ISessionRepository') private readonly sessionRepo: ISessionRepository, private readonly tokenService: TokenService, - private readonly findUserQuery: FindUserQuery, + private readonly findUserQ: FindUserQuery, ) {} async execute(dto: SignInDto, meta: DeviceMetadata) { - const entities = await this.findUserQuery.execute({ email: dto.email }); + const entities = await this.findUserQ.execute({ email: dto.email }); if (!entities?.user || !entities?.security) { throw new BaseException( @@ -31,7 +31,8 @@ export class SignInUseCase { } const { security, user } = entities; - const isPasswordValid = await argon.verify(security.passwordHash, dto.password); + // TODO: FIX + const isPasswordValid = await argon.verify(security.passwordHash ?? '', dto.password); if (!isPasswordValid) { throw new BaseException( diff --git a/src/auth/application/use-cases/sign-out.use-case.ts b/src/auth/application/use-cases/sign-out.use-case.ts index 23d85fc6..437d6a19 100644 --- a/src/auth/application/use-cases/sign-out.use-case.ts +++ b/src/auth/application/use-cases/sign-out.use-case.ts @@ -11,7 +11,17 @@ export class SignOutUseCase { private readonly tokenService: TokenService, ) {} - async execute(token: string) { + async execute(token?: string) { + if (!token) { + throw new BaseException( + { + code: 'SESSION_REQUIRED', + message: 'Session required', + }, + HttpStatus.UNAUTHORIZED, + ); + } + const payload = await this.tokenService.validateToken(token, 'refresh'); if (!payload?.jti) { diff --git a/src/auth/application/use-cases/sign-up-verify.use-case.ts b/src/auth/application/use-cases/sign-up-verify.use-case.ts index 6c950b44..ba41c8ee 100644 --- a/src/auth/application/use-cases/sign-up-verify.use-case.ts +++ b/src/auth/application/use-cases/sign-up-verify.use-case.ts @@ -27,7 +27,7 @@ export class SignUpVerifyUseCase { @Inject('ISessionRepository') private readonly sessionRepo: ISessionRepository, private readonly tokenService: TokenService, - private readonly registerUserUseCase: RegisterUserUseCase, + private readonly registerUserUC: RegisterUserUseCase, ) {} async execute(dto: VerifyDto, meta: DeviceMetadata) { @@ -87,7 +87,7 @@ export class SignUpVerifyUseCase { ); } - const user = await this.registerUserUseCase.execute({ + const user = await this.registerUserUC.execute({ ...userData.user, emailVerified: true, emailVerifiedAt: new Date().toISOString(), diff --git a/src/auth/application/use-cases/sign-up.use-case.ts b/src/auth/application/use-cases/sign-up.use-case.ts index eee31fbc..03febe49 100644 --- a/src/auth/application/use-cases/sign-up.use-case.ts +++ b/src/auth/application/use-cases/sign-up.use-case.ts @@ -20,7 +20,7 @@ export class SignUpUseCase { private readonly cacheService: ICacheService, @InjectQueue(AuthQueues.AUTH_MAIL) private readonly mailQueue: Queue, - private readonly findUserQuery: FindUserQuery, + private readonly findUserQ: FindUserQuery, ) {} async execute(dto: SignUpDto) { @@ -37,7 +37,7 @@ export class SignUpUseCase { ); } - const isExists = await this.findUserQuery.execute({ email: dto.email }); + const isExists = await this.findUserQ.execute({ email: dto.email }); if (isExists) { throw new BaseException( diff --git a/src/auth/domain/enums/auth-jobs.enum.ts b/src/auth/domain/enums/auth-jobs.enum.ts index edfcde89..3135613c 100644 --- a/src/auth/domain/enums/auth-jobs.enum.ts +++ b/src/auth/domain/enums/auth-jobs.enum.ts @@ -1,14 +1,14 @@ -export enum AuthQueues { +export const enum AuthQueues { AUTH_MAIL = 'AUTH_MAIL_QUEUE', AUTH_USER = 'AUTH_USER_QUEUE', } -export enum AuthMailJobs { +export const enum AuthMailJobs { SEND_REGISTER_CODE = 'AUTH_SEND_REGISTER_CODE', SEND_RESET_PASSWORD = 'AUTH_SEND_RESET_PASSWORD', SEND_CHANGE_EMAIL = 'AUTH_SEND_CHANGE_EMAIL', } -export enum AuthUserJobs { +export const enum AuthUserJobs { CREATE_WORKSPACE = 'AUTH_CREATE_WORKSPACE', } diff --git a/src/auth/domain/repository/identities.repository.interface.ts b/src/auth/domain/repository/identities.repository.interface.ts deleted file mode 100644 index 205f7d85..00000000 --- a/src/auth/domain/repository/identities.repository.interface.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { userIdentities } from '../../infrastructure/persistence/models/identities.model'; - -export type IdentitiesInsert = typeof userIdentities.$inferInsert; -export type IdentitiesSelect = typeof userIdentities.$inferSelect; - -export interface IIdentitiesRepository { - create(data: IdentitiesInsert): Promise; - findByProvider( - provider: 'google' | 'yandex' | 'github', - providerUserId: string, - ): Promise; - findAllByUserId(userId: string): Promise; - delete(id: string): Promise; -} diff --git a/src/auth/domain/repository/identity.repository.interface.ts b/src/auth/domain/repository/identity.repository.interface.ts new file mode 100644 index 00000000..55393944 --- /dev/null +++ b/src/auth/domain/repository/identity.repository.interface.ts @@ -0,0 +1,14 @@ +import { userIdentities } from '../../infrastructure/persistence/models/identity.model'; + +export type IdentitiyInsert = typeof userIdentities.$inferInsert; +export type IdentitiySelect = typeof userIdentities.$inferSelect; + +export interface IIdentityRepository { + create(data: IdentitiyInsert): Promise; + findByProvider( + provider: 'google' | 'yandex' | 'github', + providerUserId: string, + ): Promise; + findAllByUserId(userId: string): Promise; + delete(id: string): Promise; +} diff --git a/src/auth/domain/repository/index.ts b/src/auth/domain/repository/index.ts index 2281d4ed..18f175e0 100644 --- a/src/auth/domain/repository/index.ts +++ b/src/auth/domain/repository/index.ts @@ -1,2 +1,2 @@ export * from './session.repository.interface'; -export * from './identities.repository.interface'; +export * from './identity.repository.interface'; diff --git a/src/auth/infrastructure/persistence/models/identities.model.ts b/src/auth/infrastructure/persistence/models/identity.model.ts similarity index 100% rename from src/auth/infrastructure/persistence/models/identities.model.ts rename to src/auth/infrastructure/persistence/models/identity.model.ts diff --git a/src/auth/infrastructure/persistence/models/index.ts b/src/auth/infrastructure/persistence/models/index.ts index d60f4426..3be30728 100644 --- a/src/auth/infrastructure/persistence/models/index.ts +++ b/src/auth/infrastructure/persistence/models/index.ts @@ -1,2 +1,2 @@ export { sessions } from './session.model'; -export { userIdentities } from './identities.model'; +export { userIdentities } from './identity.model'; diff --git a/src/auth/infrastructure/persistence/repositories/identities.repository.ts b/src/auth/infrastructure/persistence/repositories/identity.repository.ts similarity index 61% rename from src/auth/infrastructure/persistence/repositories/identities.repository.ts rename to src/auth/infrastructure/persistence/repositories/identity.repository.ts index 8f4d6d8f..787f9efc 100644 --- a/src/auth/infrastructure/persistence/repositories/identities.repository.ts +++ b/src/auth/infrastructure/persistence/repositories/identity.repository.ts @@ -1,37 +1,45 @@ import { DATABASE_SERVICE, DatabaseService } from '@libs/database'; -import * as schema from '../models/identities.model'; +import * as schema from '../models/identity.model'; import { Inject, Injectable } from '@nestjs/common'; -import { IIdentitiesRepository } from '@core/auth/domain/repository'; +import { IIdentityRepository } from '@core/auth/domain/repository'; import { and, eq } from 'drizzle-orm'; @Injectable() -export class IdentitiesRepository implements IIdentitiesRepository { +export class IdentitiyRepository implements IIdentityRepository { constructor( @Inject(DATABASE_SERVICE) private readonly db: DatabaseService, ) {} - public async create(data: typeof schema.userIdentities.$inferInsert) { + public create = async (data: typeof schema.userIdentities.$inferInsert) => { const [result] = await this.db.insert(schema.userIdentities).values(data).returning(); - return result ?? null; - } - public async delete(id: string) { + if (!result) { + throw new Error('Failed to create identity: no identity returned'); + } + + return result; + }; + + public delete = async (id: string) => { const result = await this.db .delete(schema.userIdentities) .where(eq(schema.userIdentities.id, id)); return result.count.valueOf() > 0; - } + }; - public async findAllByUserId(userId: string) { + public findAllByUserId = async (userId: string) => { return this.db .select() .from(schema.userIdentities) .where(eq(schema.userIdentities.userId, userId)); - } + }; - public async findByProvider(provider: 'google' | 'yandex' | 'github', providerUserId: string) { + public findByProvider = async ( + provider: 'google' | 'yandex' | 'github', + providerUserId: string, + ) => { const [result] = await this.db .select() .from(schema.userIdentities) @@ -43,5 +51,5 @@ export class IdentitiesRepository implements IIdentitiesRepository { ); return result ?? null; - } + }; } diff --git a/src/auth/infrastructure/persistence/repositories/index.ts b/src/auth/infrastructure/persistence/repositories/index.ts index 5b847ae9..26d31eef 100644 --- a/src/auth/infrastructure/persistence/repositories/index.ts +++ b/src/auth/infrastructure/persistence/repositories/index.ts @@ -1,7 +1,7 @@ -import { IdentitiesRepository } from './identities.repository'; +import { IdentitiyRepository } from './identity.repository'; import { SessionRepository } from './session.repository'; export const REPOSITORIES = [ { provide: 'ISessionRepository', useClass: SessionRepository }, - { provide: 'IIdentitiesRepository', useClass: IdentitiesRepository }, + { provide: 'IIdentityRepository', useClass: IdentitiyRepository }, ]; diff --git a/src/auth/infrastructure/persistence/repositories/session.repository.ts b/src/auth/infrastructure/persistence/repositories/session.repository.ts index 47aaac79..b5445199 100644 --- a/src/auth/infrastructure/persistence/repositories/session.repository.ts +++ b/src/auth/infrastructure/persistence/repositories/session.repository.ts @@ -13,6 +13,11 @@ export class SessionRepository implements ISessionRepository { async create(data: SessionInsert) { const [result] = await this.db.insert(schema.sessions).values(data).returning(); + + if (!result) { + throw new Error('Failed to create session: no session returned'); + } + return result; } diff --git a/src/auth/infrastructure/security/token.service.ts b/src/auth/infrastructure/security/token.service.ts index 78ae7c0f..5cbe5527 100644 --- a/src/auth/infrastructure/security/token.service.ts +++ b/src/auth/infrastructure/security/token.service.ts @@ -12,14 +12,14 @@ export class TokenService { ) {} async generateTokens(user: User, sessionId: string) { - const domain = this.cfg.get('DOMAIN'); + const domain = this.cfg.get('DOMAIN'); const audConstraint = this.cfg.getOrThrow('JWT_AUDIENCE'); const payload = { jti: sessionId, sub: user.id, email: user.email, - iss: btoa(domain), + iss: btoa(domain ?? 'localhost'), aud: btoa(audConstraint), }; @@ -42,15 +42,15 @@ export class TokenService { return { access, refresh, expiresAt: new Date(refreshDecodedData?.exp * 1000) }; } - async validateToken(token: string, type: 'access' | 'refresh'): Promise { + async validateToken(token: string, type: 'access' | 'refresh'): Promise { try { const accessSecret = this.cfg.get('JWT_ACCESS_SECRET'); const refreshSecret = this.cfg.get('JWT_REFRESH_SECRET'); const secret = type === 'access' ? accessSecret : refreshSecret; - return this.jwtService.verifyAsync(token, { secret }); - } catch (e) { + return this.jwtService.verifyAsync(token, { secret }); + } catch { return null; } } diff --git a/src/auth/infrastructure/strategies/bearer.strategy.ts b/src/auth/infrastructure/strategies/bearer.strategy.ts index c14ed7df..06e735d2 100644 --- a/src/auth/infrastructure/strategies/bearer.strategy.ts +++ b/src/auth/infrastructure/strategies/bearer.strategy.ts @@ -12,8 +12,8 @@ export class BearerStrategy extends PassportStrategy(Strategy, 'bearer') { super({ jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), - secretOrKey: cfg.get('JWT_ACCESS_SECRET'), - issuer: cfg.get('JWT_ISSUER'), + secretOrKey: cfg.getOrThrow('JWT_ACCESS_SECRET'), + issuer: cfg.getOrThrow('JWT_ISSUER'), audience, }); } diff --git a/src/auth/infrastructure/strategies/cookie.strategy.ts b/src/auth/infrastructure/strategies/cookie.strategy.ts index d9334ebb..c7b65b9f 100644 --- a/src/auth/infrastructure/strategies/cookie.strategy.ts +++ b/src/auth/infrastructure/strategies/cookie.strategy.ts @@ -13,10 +13,10 @@ export class CookieStrategy extends PassportStrategy(Strategy, 'cookie') { jwtFromRequest: ExtractJwt.fromExtractors([ (request: FastifyRequest) => { const token = request?.cookies?.['refresh']; - return token; + return token ?? null; }, ]), - secretOrKey: configService.get('JWT_REFRESH_SECRET'), + secretOrKey: configService.getOrThrow('JWT_REFRESH_SECRET'), passReqToCallback: true, }); } diff --git a/src/auth/infrastructure/strategies/github.strategy.ts b/src/auth/infrastructure/strategies/github.strategy.ts index ac77d67e..ee7404db 100644 --- a/src/auth/infrastructure/strategies/github.strategy.ts +++ b/src/auth/infrastructure/strategies/github.strategy.ts @@ -25,8 +25,8 @@ export class GithubStrategy extends PassportStrategy(Strategy, 'github-oauth') { : `http://localhost:${port || 3000}/${apiPath}`; super({ - clientID: cfg.getOrThrow('GITHUB_CLIENT_ID'), - clientSecret: cfg.getOrThrow('GITHUB_CLIENT_SECRET'), + clientID: cfg.getOrThrow('GITHUB_CLIENT_ID'), + clientSecret: cfg.getOrThrow('GITHUB_CLIENT_SECRET'), callbackURL, scope: ['user:email', 'read:user'], passReqToCallback: true, diff --git a/src/auth/infrastructure/strategies/vkontakte.strategy.ts b/src/auth/infrastructure/strategies/vkontakte.strategy.ts index 96800638..cc4d39da 100644 --- a/src/auth/infrastructure/strategies/vkontakte.strategy.ts +++ b/src/auth/infrastructure/strategies/vkontakte.strategy.ts @@ -102,8 +102,8 @@ export class VkontakteStrategy extends PassportStrategy(Strategy, 'vkontakte-oau super({ authorizationURL: 'https://oauth.vk.com/authorize', tokenURL: 'https://oauth.vk.com/access_token', - clientID: cfg.getOrThrow('VKONTAKTE_CLIENT_ID'), - clientSecret: cfg.getOrThrow('VKONTAKTE_CLIENT_SECRET'), + clientID: cfg.getOrThrow('VKONTAKTE_CLIENT_ID'), + clientSecret: cfg.getOrThrow('VKONTAKTE_CLIENT_SECRET'), callbackURL, scope: ['email', 'photos', 'status', 'wall', 'groups'], scopeSeparator: ',', @@ -120,7 +120,7 @@ export class VkontakteStrategy extends PassportStrategy(Strategy, 'vkontakte-oau ) { const user = { id: profile.id, - email: `${profile.screen_name}@vk.placholder.internal`, + email: `${profile.displayName}@vk.placholder.internal`, first_name: profile.name.givenName, last_name: profile.name.familyName, sex: profile.gender === 'male' ? 'male' : profile.gender === 'female' ? 'female' : null, @@ -268,19 +268,22 @@ export class VkontakteStrategy extends PassportStrategy(Strategy, 'vkontakte-oau return profile; } - userProfile(accessToken: string, done: (err?: Error | null, profile?: any) => void): void { + override userProfile( + accessToken: string, + done: (err?: Error | null, profile?: any) => void, + ): void { this.getUserProfile(accessToken) .then((profile) => done(null, profile)) .catch((err) => done(err, null)); } - authorizationParams( + override authorizationParams( options: { display?: 'page' | 'popup' | 'mobile' } = {}, ): Record { const params: Record = {}; if (options.display) { - params.display = options.display; + params['display'] = options.display; } return params; diff --git a/src/auth/infrastructure/strategies/yandex.strategy.ts b/src/auth/infrastructure/strategies/yandex.strategy.ts index 5a7c32e4..bab171af 100644 --- a/src/auth/infrastructure/strategies/yandex.strategy.ts +++ b/src/auth/infrastructure/strategies/yandex.strategy.ts @@ -59,8 +59,8 @@ export class YandexStrategy extends PassportStrategy(Strategy, 'yandex-oauth') { super({ authorizationURL: 'https://oauth.yandex.ru/authorize', tokenURL: 'https://oauth.yandex.ru/token', - clientID: cfg.getOrThrow('YANDEX_CLIENT_ID'), - clientSecret: cfg.getOrThrow('YANDEX_CLIENT_SECRET'), + clientID: cfg.getOrThrow('YANDEX_CLIENT_ID'), + clientSecret: cfg.getOrThrow('YANDEX_CLIENT_SECRET'), callbackURL, scope: ['login:email', 'login:info'], passReqToCallback: true, @@ -140,7 +140,10 @@ export class YandexStrategy extends PassportStrategy(Strategy, 'yandex-oauth') { } } - userProfile(accessToken: string, done: (err?: Error | null, profile?: any) => void): void { + override userProfile( + accessToken: string, + done: (err?: Error | null, profile?: any) => void, + ): void { this.getUserProfile(accessToken) .then((profile) => done(null, profile)) .catch((err) => done(err, null)); diff --git a/src/projects/application/controller/members/controller.ts b/src/projects/application/controller/members/controller.ts index 654eb817..d1ba6b6a 100644 --- a/src/projects/application/controller/members/controller.ts +++ b/src/projects/application/controller/members/controller.ts @@ -1,5 +1,5 @@ import { Body, Delete, Get, Param, Post, Put, Query } from '@nestjs/common'; -import { ApiBaseController, GetUserId, SkipContractHandle } from '@shared/decorators'; +import { ApiBaseController, GetUserId, SkipContract } from '@shared/decorators'; import { ProjectFacade } from '../../project.facade'; import { AddProjectMemberDto, UpdateProjectMemberDto } from '../../dtos'; import { @@ -52,7 +52,7 @@ export class ProjectMembersController { } @Get('available') - @SkipContractHandle() + @SkipContract() @FindAvailableUsersSwagger() async getAvailableUsers( @Param('slug') slug: string, diff --git a/src/projects/application/dtos/project.dto.ts b/src/projects/application/dtos/project.dto.ts index a0bb334a..d5f74fa5 100644 --- a/src/projects/application/dtos/project.dto.ts +++ b/src/projects/application/dtos/project.dto.ts @@ -131,15 +131,13 @@ export const ProjectFilterSchema = z .partial() .describe('Фильтры для списка проектов'); -export const CreateShareTokenSchema = z - .object({ - ttl: z - .string() - .datetime() - .nullish() - .describe('Дата истечения ссылки. Если не указана — ставится дефолт 3 месяца'), - }) - .optional(); +export const CreateShareTokenSchema = z.object({ + ttl: z + .string() + .datetime() + .nullish() + .describe('Дата истечения ссылки. Если не указана — ставится дефолт 3 месяца'), +}); export const CreateShareTokenResponseSchema = ActionResponseSchema.extend({ payload: z.object({ diff --git a/src/projects/application/mappers/project.mapper.ts b/src/projects/application/mappers/project.mapper.ts index 0ae2684a..b568a19b 100644 --- a/src/projects/application/mappers/project.mapper.ts +++ b/src/projects/application/mappers/project.mapper.ts @@ -2,7 +2,7 @@ import type { RawMemberRow } from '@core/teams/domain/repository'; import type { Project } from '@core/projects/domain/entities'; export class ProjectMapper { - public static toDetailResponse(project: Project, member?: RawMemberRow, token?: string) { + public static toDetailResponse(project: Project, member?: RawMemberRow | null, token?: string) { const { id, slug, diff --git a/src/projects/application/use-cases/member/find-all.query.ts b/src/projects/application/use-cases/member/find-all.query.ts index 98ea2330..127dd670 100644 --- a/src/projects/application/use-cases/member/find-all.query.ts +++ b/src/projects/application/use-cases/member/find-all.query.ts @@ -20,10 +20,15 @@ export class FindAllProjectMembersQuery { const users = await this.findUsersQ.execute(userIds); const map = new Map(users.map((u) => [u.id, u])); - const result = members.map((m) => ({ - ...m, - user: map.get(m.userId), - })); + const result = members + .map((m) => ({ + ...m, + user: map.get(m.userId), + })) + .filter( + (item): item is typeof item & { user: NonNullable } => + item.user !== undefined, + ); return MemberMapper.toMemberListResponse(result); } diff --git a/src/projects/application/use-cases/project/find-by-team.query.ts b/src/projects/application/use-cases/project/find-by-team.query.ts index 9e217b27..cacf374e 100644 --- a/src/projects/application/use-cases/project/find-by-team.query.ts +++ b/src/projects/application/use-cases/project/find-by-team.query.ts @@ -16,8 +16,6 @@ export class FindProjectsByTeamQuery { const projects = await this.projectsRepo.findByTeam(team.id); const items = projects.map((p) => ProjectMapper.toListResponse(p, member)); - console.log(items); - return { // TODO: реализовать полноценную пагинацию для проектов команды. items, diff --git a/src/projects/application/use-cases/project/find-one.query.ts b/src/projects/application/use-cases/project/find-one.query.ts index 031e6107..220a4456 100644 --- a/src/projects/application/use-cases/project/find-one.query.ts +++ b/src/projects/application/use-cases/project/find-one.query.ts @@ -2,7 +2,7 @@ import { HttpStatus, Inject, Injectable } from '@nestjs/common'; import { FindTeamMemberQuery, FindTeamQuery } from '@core/teams'; import { createHash } from 'crypto'; import { BaseException } from '@shared/error'; -import { ROLE_PRIORITY } from '@shared/constants'; +import { isTeamRole, ROLE_PRIORITY } from '@shared/constants'; import { IProjectRepository } from '@core/projects/domain/repository'; import type { Project } from '@core/projects/domain/entities'; import { ProjectErrorCodes, ProjectErrorMessages } from '@core/projects/domain/errors'; @@ -78,7 +78,7 @@ export class FindProjectQuery { ); } - if (ROLE_PRIORITY[member.role] < ROLE_PRIORITY[minRole]) { + if (isTeamRole(member.role) && ROLE_PRIORITY[member.role] < ROLE_PRIORITY[minRole]) { throw new BaseException( { code: 'INSUFFICIENT_PERMISSIONS', diff --git a/src/projects/application/use-cases/project/generate-share-token.use-case.ts b/src/projects/application/use-cases/project/generate-share-token.use-case.ts index 4490059b..c7e60dea 100644 --- a/src/projects/application/use-cases/project/generate-share-token.use-case.ts +++ b/src/projects/application/use-cases/project/generate-share-token.use-case.ts @@ -73,7 +73,7 @@ export class GenerateShareTokenUseCase { * Вычисляет дату истечения токена. * Если ttl передан — использует его, иначе +3 месяца от текущей даты. */ - private resolveExpiration(ttl?: string): Date { + private resolveExpiration(ttl?: string | null): Date { if (ttl) { const date = new Date(ttl); diff --git a/src/projects/application/use-cases/project/update.use-case.ts b/src/projects/application/use-cases/project/update.use-case.ts index 7d9c7970..539ab571 100644 --- a/src/projects/application/use-cases/project/update.use-case.ts +++ b/src/projects/application/use-cases/project/update.use-case.ts @@ -43,16 +43,16 @@ export class UpdateProjectUseCase { const data: Record = {}; - if (dto.slug) data.slug = slugify(dto.slug, { lower: true, strict: true }); - if (dto.name) data.name = dto.name.trim(); - if (dto.description !== undefined) data.description = dto.description?.trim() || null; + if (dto.slug) data['slug'] = slugify(dto.slug, { lower: true, strict: true }); + if (dto.name) data['name'] = dto.name.trim(); + if (dto.description !== undefined) data['description'] = dto.description?.trim() || null; if (dto.descriptionHtml !== undefined) { - data.descriptionHtml = dto.descriptionHtml?.trim() || null; + data['descriptionHtml'] = dto.descriptionHtml?.trim() || null; } - if (dto.icon !== undefined) data.icon = dto.icon || null; - if (dto.color !== undefined) data.color = dto.color || null; - if (dto.sequence !== undefined) data.sequence = dto.sequence; - if (dto.visibility) data.visibility = dto.visibility; + if (dto.icon !== undefined) data['icon'] = dto.icon || null; + if (dto.color !== undefined) data['color'] = dto.color || null; + if (dto.sequence !== undefined) data['sequence'] = dto.sequence; + if (dto.visibility) data['visibility'] = dto.visibility; if (Object.keys(data).length === 0 && !dto.settings) { return { diff --git a/src/projects/domain/policy/project-access.policy.ts b/src/projects/domain/policy/project-access.policy.ts index b7f8da42..409777ee 100644 --- a/src/projects/domain/policy/project-access.policy.ts +++ b/src/projects/domain/policy/project-access.policy.ts @@ -6,6 +6,7 @@ import { ROLE_PRIORITY, PROJECT_ROLE_PRIORITY } from '@shared/constants'; import type { MemberRole } from '../entities'; import { MemberErrorCodes, MemberErrorMessages } from '../errors/member.errors'; import { ProjectErrorCodes, ProjectErrorMessages } from '../errors'; +import { isTeamRole } from '../../../shared/constants/roles.constant'; @Injectable() export class ProjectAccessPolicy { @@ -42,7 +43,7 @@ export class ProjectAccessPolicy { ); } - if (ROLE_PRIORITY[member.role] < ROLE_PRIORITY[minRole]) { + if (isTeamRole(member.role) && ROLE_PRIORITY[member.role] < ROLE_PRIORITY[minRole]) { throw new BaseException( { code: 'INSUFFICIENT_PERMISSIONS', @@ -87,9 +88,14 @@ export class ProjectAccessPolicy { ); } - const hasRole = minRoles.some( - (role) => PROJECT_ROLE_PRIORITY[member.role] >= PROJECT_ROLE_PRIORITY[role], - ); + const hasRole = minRoles.some((role) => { + if (!isTeamRole(member.role) || !isTeamRole(role)) return false; + + const memberPriority = PROJECT_ROLE_PRIORITY[member.role] ?? -1; + const rolePriority = PROJECT_ROLE_PRIORITY[role] ?? -1; + + return memberPriority >= rolePriority; + }); if (!hasRole) { throw new BaseException( @@ -137,7 +143,7 @@ export class ProjectAccessPolicy { ); } - if (ROLE_PRIORITY[member.role] < ROLE_PRIORITY[minRole]) { + if (isTeamRole(member.role) && ROLE_PRIORITY[member.role] < ROLE_PRIORITY[minRole]) { throw new BaseException( { code: 'INSUFFICIENT_PERMISSIONS', diff --git a/src/projects/infrastructure/persistence/repositories/member.repository.ts b/src/projects/infrastructure/persistence/repositories/member.repository.ts index 108381da..42ae2da8 100644 --- a/src/projects/infrastructure/persistence/repositories/member.repository.ts +++ b/src/projects/infrastructure/persistence/repositories/member.repository.ts @@ -18,7 +18,11 @@ export class MemberRepository implements IMemberRepository { .values(data) .returning({ id: schema.projectMembers.id }); - return { id: result.id }; + if (!result) { + throw new Error('Failed to create member: no member returned'); + } + + return { id: result?.id }; }; public findById = async (memberId: string) => { @@ -68,7 +72,7 @@ export class MemberRepository implements IMemberRepository { return result || null; }; - async getUserRole(projectId: string, userId: string) { + public getUserRole = async (projectId: string, userId: string) => { const [result] = await this.db .select({ role: schema.projectMembers.role }) .from(schema.projectMembers) @@ -81,18 +85,18 @@ export class MemberRepository implements IMemberRepository { .limit(1); return (result?.role as MemberRole) ?? null; - } + }; - async countByProject(projectId: string) { + public countByProject = async (projectId: string) => { const [result] = await this.db .select({ count: sql`count(*)` }) .from(schema.projectMembers) .where(eq(schema.projectMembers.projectId, projectId)); - return result.count; - } + return result?.count ?? 0; + }; - async updateRole(memberId: string, role: MemberRole) { + public updateRole = async (memberId: string, role: MemberRole) => { const [result] = await this.db .update(schema.projectMembers) .set({ role }) @@ -100,14 +104,14 @@ export class MemberRepository implements IMemberRepository { .returning(); return result ?? null; - } + }; - async delete(memberId: string): Promise { + public delete = async (memberId: string) => { const [result] = await this.db .delete(schema.projectMembers) .where(eq(schema.projectMembers.id, memberId)) .returning({ id: schema.projectMembers.id }); return result !== undefined; - } + }; } diff --git a/src/projects/infrastructure/persistence/repositories/project.repository.ts b/src/projects/infrastructure/persistence/repositories/project.repository.ts index 5d37851d..f53a1602 100644 --- a/src/projects/infrastructure/persistence/repositories/project.repository.ts +++ b/src/projects/infrastructure/persistence/repositories/project.repository.ts @@ -19,6 +19,10 @@ export class ProjectRepository implements IProjectRepository { .values(data) .returning({ slug: schema.projects.slug, id: schema.projects.id }); + if (!project[0]) { + throw new Error('Failed to create project: no project returned'); + } + const member = await tx .insert(schema.projectMembers) .values({ @@ -163,6 +167,6 @@ export class ProjectRepository implements IProjectRepository { ), ); - return result.count; + return result?.count ?? 0; }; } diff --git a/src/shared/constants/roles.constant.ts b/src/shared/constants/roles.constant.ts index f6056d40..b81d36e0 100644 --- a/src/shared/constants/roles.constant.ts +++ b/src/shared/constants/roles.constant.ts @@ -1,11 +1,19 @@ -export const ROLE_PRIORITY: Record = { +export const TEAM_ROLES = ['owner', 'admin', 'moderator', 'lead', 'member', 'viewer'] as const; +export type TeamRole = (typeof TEAM_ROLES)[number]; + +export const ROLE_PRIORITY: Record = { owner: 4, admin: 3, + lead: 2, moderator: 2, member: 1, viewer: 0, }; +export const isTeamRole = (role: string): role is TeamRole => { + return TEAM_ROLES.includes(role as TeamRole); +}; + export const PROJECT_ROLE_PRIORITY: Record = { owner: 4, admin: 3, diff --git a/src/shared/decorators/api-controller.decorator.ts b/src/shared/decorators/api-controller.decorator.ts index bcbb4af1..72e75813 100644 --- a/src/shared/decorators/api-controller.decorator.ts +++ b/src/shared/decorators/api-controller.decorator.ts @@ -13,7 +13,7 @@ export const ApiBaseController = (path: string, tag: string, hasJWTGuard?: boole 'INTERNAL_SERVER_ERROR', 'Произошла критическая ошибка на стороне сервера', ), - ].filter(Boolean); + ].filter((decorator): decorator is Exclude => decorator !== null); return applyDecorators(...decorators); }; diff --git a/src/shared/decorators/skip-response-validation.decorator.ts b/src/shared/decorators/skip-response-validation.decorator.ts deleted file mode 100644 index c8c22250..00000000 --- a/src/shared/decorators/skip-response-validation.decorator.ts +++ /dev/null @@ -1,4 +0,0 @@ -import { SetMetadata } from '@nestjs/common'; - -export const SKIP_RESPONSE_VALIDATION_KEY = 'SKIP_RESPONSE_VALIDATION_KEY'; -export const SkipResponseValidation = () => SetMetadata(SKIP_RESPONSE_VALIDATION_KEY, true); diff --git a/src/shared/decorators/skip-zod-validation.decorator.ts b/src/shared/decorators/skip-zod-validation.decorator.ts index 6b793d5d..189fc73e 100644 --- a/src/shared/decorators/skip-zod-validation.decorator.ts +++ b/src/shared/decorators/skip-zod-validation.decorator.ts @@ -1,4 +1,4 @@ import { SetMetadata } from '@nestjs/common'; -export const SKIP_CONTRACT_HANDLE = 'SKIP_CONTRACT_HANDLE'; -export const SkipContractHandle = () => SetMetadata(SKIP_CONTRACT_HANDLE, true); +export const SKIP_CONTRACT = 'SKIP_CONTRACT'; +export const SkipContract = () => SetMetadata(SKIP_CONTRACT, true); diff --git a/src/shared/error/filter.ts b/src/shared/error/filter.ts index b06dc7ff..5e1fd965 100644 --- a/src/shared/error/filter.ts +++ b/src/shared/error/filter.ts @@ -17,7 +17,7 @@ import { DATABASE_ERRORS } from './swagger'; @Catch() export class GlobalExceptionFilter implements ExceptionFilter { private readonly logger = new Logger(GlobalExceptionFilter.name); - private isDev = process.env.NODE_ENV === 'development'; + private isDev = process.env['NODE_ENV'] === 'development'; catch(exception: unknown, host: ArgumentsHost) { if (exception instanceof ZodValidationException) { @@ -129,12 +129,15 @@ export class GlobalExceptionFilter implements ExceptionFilter { const res = exception.getResponse(); const message = - typeof res === 'object' && res['message'] ? res['message'] : exception.message; - - const code = - typeof res === 'object' && res['error'] - ? res['error'].toUpperCase().replace(/\s+/g, '_') - : 'HTTP_EXCEPTION'; + typeof res === 'object' && res !== null && 'message' in res + ? String(res.message) + : exception.message; + + const errorCode = + typeof res === 'object' && res !== null && 'error' in res && res.error + ? String(res.error) + : null; + const code = errorCode ? errorCode.toUpperCase().replace(/\s+/g, '_') : 'HTTP_EXCEPTION'; this.log(exception, host, status, { httpCode: code, diff --git a/src/shared/guards/bearer.guard.ts b/src/shared/guards/bearer.guard.ts index a7b2b028..25926b92 100644 --- a/src/shared/guards/bearer.guard.ts +++ b/src/shared/guards/bearer.guard.ts @@ -12,7 +12,7 @@ export class BearerAuthGuard extends AuthGuard('bearer') { super(); } - async canActivate(context: ExecutionContext): Promise { + override async canActivate(context: ExecutionContext): Promise { try { return super.canActivate(context) as Promise; } catch (e) { @@ -24,7 +24,7 @@ export class BearerAuthGuard extends AuthGuard('bearer') { } } - handleRequest( + override handleRequest( err: unknown, user: TUser, info: unknown, @@ -35,7 +35,7 @@ export class BearerAuthGuard extends AuthGuard('bearer') { } if (this.isPublicOrHasToken(context)) { - return null; + return null as TUser; } throw new BaseException( diff --git a/src/shared/guards/cookie.guard.ts b/src/shared/guards/cookie.guard.ts index 89300ab9..3b7df53c 100644 --- a/src/shared/guards/cookie.guard.ts +++ b/src/shared/guards/cookie.guard.ts @@ -5,7 +5,7 @@ import type { JwtPayload } from '@shared/types'; @Injectable() export class CookieAuthGuard extends AuthGuard('cookie') { - handleRequest(err: unknown, user: TUser, info: any): TUser { + override handleRequest(err: unknown, user: TUser, info: any): TUser { if (err || !user) { throw new BaseException( { diff --git a/src/shared/guards/oauth.guard.ts b/src/shared/guards/oauth.guard.ts index 132a13ba..0e806edf 100644 --- a/src/shared/guards/oauth.guard.ts +++ b/src/shared/guards/oauth.guard.ts @@ -31,12 +31,12 @@ export class OAuthGuard implements CanActivate { const passportOptions: Record = { session: false }; if (query) { - passportOptions.state = query; + passportOptions['state'] = query; } if (provider === 'google') { - passportOptions.accessType = 'offline'; - passportOptions.prompt = 'consent'; + passportOptions['accessType'] = 'offline'; + passportOptions['prompt'] = 'consent'; } const targetGuard = new GuardClass(passportOptions); diff --git a/src/shared/interceptors/http-metrics.interceptor.ts b/src/shared/interceptors/http-metrics.interceptor.ts index 02a99de0..347ad2d8 100644 --- a/src/shared/interceptors/http-metrics.interceptor.ts +++ b/src/shared/interceptors/http-metrics.interceptor.ts @@ -1,9 +1,14 @@ -import { CallHandler, ExecutionContext, Injectable, NestInterceptor } from '@nestjs/common'; +import { + type CallHandler, + type ExecutionContext, + Injectable, + NestInterceptor, +} from '@nestjs/common'; import { Observable, throwError } from 'rxjs'; import { tap, catchError } from 'rxjs/operators'; import { Histogram } from 'prom-client'; import { InjectMetric } from '@willsoto/nestjs-prometheus'; -import { FastifyReply, FastifyRequest } from 'fastify'; +import type { FastifyReply, FastifyRequest } from 'fastify'; @Injectable() export class HttpMetricsInterceptor implements NestInterceptor { diff --git a/src/shared/interceptors/zod-validation.interceptor.ts b/src/shared/interceptors/zod-validation.interceptor.ts index be9e1364..dd8a8970 100644 --- a/src/shared/interceptors/zod-validation.interceptor.ts +++ b/src/shared/interceptors/zod-validation.interceptor.ts @@ -9,7 +9,7 @@ import { Reflector } from '@nestjs/core'; import { map, Observable } from 'rxjs'; import { BaseException } from '@shared/error'; import { z } from 'zod/v4'; -import { SKIP_CONTRACT_HANDLE } from '@shared/decorators'; +import { SKIP_CONTRACT } from '@shared/decorators'; export const ZOD_RESPONSE_TOKEN = 'ZOD_RESPONSE_TOKEN'; @@ -24,7 +24,7 @@ export class ZodValidationInterceptor implements NestInterceptor(SKIP_CONTRACT_HANDLE, handler); + const skipValidation = this.reflector.get(SKIP_CONTRACT, handler); if (skipValidation) { return next.handle(); diff --git a/src/shared/media/decorators/extract-media-req.decorator.ts b/src/shared/media/decorators/extract-media-req.decorator.ts index f347c320..fa935ad6 100644 --- a/src/shared/media/decorators/extract-media-req.decorator.ts +++ b/src/shared/media/decorators/extract-media-req.decorator.ts @@ -66,7 +66,11 @@ export const ExtractMediaReq = createParamDecorator( ...fields, }; } catch (e) { - if (e?.code === 'FST_REQ_FILE_TOO_LARGE') { + const hasCode = (err: unknown): err is { code: string } => { + return err !== null && typeof err === 'object' && 'code' in err; + }; + + if (hasCode(e) && e?.code === 'FST_REQ_FILE_TOO_LARGE') { throw new BaseException( { code: 'FILE_TOO_LARGE', diff --git a/src/shared/media/media.service.ts b/src/shared/media/media.service.ts index c2f011b5..349fe0f0 100644 --- a/src/shared/media/media.service.ts +++ b/src/shared/media/media.service.ts @@ -4,9 +4,9 @@ import type { UploadMediaDto } from './dtos'; import { BaseException } from '@shared/error'; import { FlowProducer } from 'bullmq'; import { InjectFlowProducer } from '@nestjs/bullmq'; -import { MEDIA_STRATEGIES } from './strategies'; +import { MEDIA_STRATEGIES, MediaStrategyKey } from './strategies'; import { MEDIA_FLOW, MEDIA_JOBS, MEDIA_QUEUES } from './media.constant'; -import { MediaDispatchStrategy } from './strategies/media.strategy'; +import type { MediaDispatchStrategy } from './strategies/media.strategy'; import { extname } from 'path'; @Injectable() @@ -85,14 +85,17 @@ export class MediaService { } private getStrategy(context: string) { - const strategy = MEDIA_STRATEGIES[context]; - if (!strategy) { + if (!this.isValidStrategyKey(context)) { throw new BaseException( { code: 'STRATEGY_NOT_FOUND', message: `No strategy for ${context}` }, HttpStatus.BAD_REQUEST, ); } - return strategy; + return MEDIA_STRATEGIES[context]; + } + + private isValidStrategyKey(key: string): key is MediaStrategyKey { + return key in MEDIA_STRATEGIES; } private handleError(error: unknown): never { diff --git a/src/shared/media/strategies/index.ts b/src/shared/media/strategies/index.ts index 4ee05c91..5a832fe3 100644 --- a/src/shared/media/strategies/index.ts +++ b/src/shared/media/strategies/index.ts @@ -6,3 +6,5 @@ export const MEDIA_STRATEGIES = { 'team.avatar': new TeamMediaStrategy(), 'team.banner': new TeamMediaStrategy(), } as const; + +export type MediaStrategyKey = keyof typeof MEDIA_STRATEGIES; diff --git a/src/shared/media/workers/media.worker.ts b/src/shared/media/workers/media.worker.ts index b298f1bd..144f046b 100644 --- a/src/shared/media/workers/media.worker.ts +++ b/src/shared/media/workers/media.worker.ts @@ -29,7 +29,10 @@ export class MediaProcessor extends WorkerHost { const progressStep = Math.floor(90 / resizeSpecs.length); for (let i = 0; i < resizeSpecs.length; i++) { - const { name, ...dimensions } = resizeSpecs[i]; + const spec = resizeSpecs[i]; + if (!spec) continue; + + const { name, ...dimensions } = spec; const targetFileName = `${name}.webp`; const processedImage = await this.imagor.get(`/${originalFilePath}`, dimensions); diff --git a/src/shared/utils/index.ts b/src/shared/utils/index.ts index a946ec66..24b54a6a 100644 --- a/src/shared/utils/index.ts +++ b/src/shared/utils/index.ts @@ -1 +1,2 @@ export { ImageHelper } from './image-builder.util'; +export * from './remove-undefined.util'; diff --git a/src/shared/utils/remove-undefined.util.ts b/src/shared/utils/remove-undefined.util.ts new file mode 100644 index 00000000..5f3e954f --- /dev/null +++ b/src/shared/utils/remove-undefined.util.ts @@ -0,0 +1,9 @@ +export function removeUndefined>(obj: T): Partial { + const result: Partial = {}; + for (const key in obj) { + if (obj[key] !== undefined) { + result[key] = obj[key]; + } + } + return result; +} diff --git a/src/teams/application/dtos/index.ts b/src/teams/application/dtos/index.ts index 33428781..cd1a98a3 100644 --- a/src/teams/application/dtos/index.ts +++ b/src/teams/application/dtos/index.ts @@ -1,14 +1,3 @@ -export { - InviteMemberDto, - UpdateMemberDto, - TeamMemberResponse, - UserInviteResponse, - TeamMembersResponse, - UserInvitesResponse, -} from './member.dto'; -export { - UpdateInvitationDto, - TeamInvitationResponse, - TeamInvitationsResponse, -} from './invitation.dto'; -export { CreateTeamDto, UpdateTeamDto, UserTeamsResponse, TeamResponse } from './team.dto'; +export * from './member.dto'; +export * from './invitation.dto'; +export * from './team.dto'; diff --git a/src/teams/application/team.facade.ts b/src/teams/application/team.facade.ts index cdf7b541..e07445ef 100644 --- a/src/teams/application/team.facade.ts +++ b/src/teams/application/team.facade.ts @@ -53,7 +53,7 @@ export class TeamsFacade { public removeMember = (teamId: string, curr: string, target: string) => this.removeMemberUc.execute(teamId, curr, target); - public getInvitations = (teamId: string, userId?: string) => + public getInvitations = (teamId: string, userId: string) => this.getInvitationsQ.execute(teamId, userId); public invite = (teamId: string, inviterId: string, dto: InviteMemberDto) => diff --git a/src/teams/application/use-cases/invitions/accept-invitation.use-case.ts b/src/teams/application/use-cases/invitions/accept-invitation.use-case.ts index afffe42c..328dafc9 100644 --- a/src/teams/application/use-cases/invitions/accept-invitation.use-case.ts +++ b/src/teams/application/use-cases/invitions/accept-invitation.use-case.ts @@ -74,15 +74,6 @@ export class AcceptInvitationUseCase { return { success: true, message: 'Вы успешно присоединились к команде' }; } - private checkMemberStatus(member: any) { - if (member?.status === 'banned') { - // throw new BaseException({ code: 'MEMBER_BANNED' }, 403); - } - if (member?.status === 'active') { - // throw new BaseException({ code: 'ALREADY_MEMBER' }, 400); - } - } - private async cleanupInvite(code: string, teamId: string, email: string) { await this.cacheService .transaction() diff --git a/src/teams/application/use-cases/invitions/get-invitations.query.ts b/src/teams/application/use-cases/invitions/get-invitations.query.ts index ed1f839b..fd60d132 100644 --- a/src/teams/application/use-cases/invitions/get-invitations.query.ts +++ b/src/teams/application/use-cases/invitions/get-invitations.query.ts @@ -37,11 +37,14 @@ export class GetInvitationsQuery { const results = await this.cacheService.getMany(codes.map(this.INVITES_KEY)); const { active, expired } = results.reduce( - (acc, raw, i) => { + (acc: { active: any[]; expired: string[] }, raw, i) => { + const code = codes[i]; + if (!code) return acc; + if (raw) { - acc.active.push({ code: codes[i], ...JSON.parse(raw) }); + acc.active.push({ code, ...JSON.parse(raw) }); } else { - acc.expired.push(codes[i]); + acc.expired.push(code); } return acc; }, diff --git a/src/teams/application/use-cases/invitions/get-my-invites.use-case.ts b/src/teams/application/use-cases/invitions/get-my-invites.use-case.ts index 4c4ca428..5555831c 100644 --- a/src/teams/application/use-cases/invitions/get-my-invites.use-case.ts +++ b/src/teams/application/use-cases/invitions/get-my-invites.use-case.ts @@ -30,32 +30,34 @@ export class GetMyInvitesUseCase { const inviteKeys = codes.map((c) => `inv:code:${c}`); const results = await this.cacheService.getMany(inviteKeys); - const { activeInvites, expiredCodes } = results.reduce( - (acc, raw, i) => { + const { active, expired } = results.reduce( + (acc: { active: any[]; expired: string[] }, raw, i) => { + const code = codes[i]; + if (!code) return acc; + if (raw) { - acc.activeInvites.push(TeamMemberMapper.toPublicInvite(raw, codes[i])); + acc.active.push(TeamMemberMapper.toPublicInvite(raw, code)); } else { - acc.expiredCodes.push(codes[i]); + acc.expired.push(code); } return acc; }, - { activeInvites: [], expiredCodes: [] }, + { active: [], expired: [] }, ); - if (expiredCodes.length > 0) { - this.cacheService.removeManyFromCollection(userKey, expiredCodes).catch((err) => { + if (expired.length > 0) { + this.cacheService.removeManyFromCollection(userKey, expired).catch((err) => { console.error('Failed to cleanup expired invites:', err); }); } return { - // TODO: реализовать полноценную пагинацию для инвайтов пользователя. - items: activeInvites, + items: active, meta: { - total: activeInvites.length, - totalPages: activeInvites.length ? 1 : 0, + total: active.length, + totalPages: active.length ? 1 : 0, page: 1, - limit: activeInvites.length, + limit: active.length, hasPrevPage: false, hasNextPage: false, }, diff --git a/src/teams/application/use-cases/invitions/send-invitation.use-case.ts b/src/teams/application/use-cases/invitions/send-invitation.use-case.ts index 6aabe8ab..07588df9 100644 --- a/src/teams/application/use-cases/invitions/send-invitation.use-case.ts +++ b/src/teams/application/use-cases/invitions/send-invitation.use-case.ts @@ -4,10 +4,9 @@ import { InjectQueue } from '@nestjs/bullmq'; import { HttpStatus, Inject, Injectable } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { Queue } from 'bullmq'; -import { InviteMemberDto } from '../../dtos'; +import { InviteMemberDto, type TeamInvite } from '../../dtos'; import { BaseException } from '@shared/error'; import { generateSecret } from 'otplib'; -import type { TeamInvite } from '../../dtos/invitation.dto'; import { TeamInvitationEvent } from '@core/teams/domain/events'; import { TeamMemberPolicy } from '@core/teams/domain/policy'; import type { TeamRole } from '@shared/entities'; @@ -108,12 +107,12 @@ export class SendInvitationUseCase { const expiresAt = new Date(Date.now() + this.INVITE_TTL * 1000); const cdn = this.getCdnBaseUrl(); - const { small } = ImageHelper.buildResponsiveUrls(cdn, team.avatarUrl); + const images = ImageHelper.buildResponsiveUrls(cdn, team.avatarUrl); return { teamId: team.id, teamName: team.name, - teamAvatar: small, + teamAvatar: images?.small ?? null, email: dto.email.toLowerCase(), role: (dto.role || 'member') as TeamRole, inviterId: inviter.userId, diff --git a/src/teams/domain/enums/mail-jobs.enum.ts b/src/teams/domain/enums/mail-jobs.enum.ts index a46d334d..da9a52a8 100644 --- a/src/teams/domain/enums/mail-jobs.enum.ts +++ b/src/teams/domain/enums/mail-jobs.enum.ts @@ -1,7 +1,7 @@ -export enum TeamQueues { +export const enum TeamQueues { TEAM_MAIL = 'TEAM_MAIL_QUEUE', } -export enum TeamMailJobs { +export const enum TeamMailJobs { SEND_TEAM_INVITATION = 'TEAM_SEND_TEAM_INVITATION', } diff --git a/src/teams/domain/policy/team-member.policy.ts b/src/teams/domain/policy/team-member.policy.ts index 3979e533..c849508b 100644 --- a/src/teams/domain/policy/team-member.policy.ts +++ b/src/teams/domain/policy/team-member.policy.ts @@ -15,7 +15,7 @@ export class TeamMemberPolicy { */ public canManage(issuerRole: TeamRole, targetRole: TeamRole): boolean { // Минимальный порог для управления — администратор - if (this.getPriority(issuerRole) < ROLE_PRIORITY.admin) return false; + if (this.getPriority(issuerRole) < (ROLE_PRIORITY['admin'] ?? 3)) return false; // Нельзя редактировать того, кто равен или выше по рангу return this.getPriority(issuerRole) > this.getPriority(targetRole); @@ -65,7 +65,7 @@ export class TeamMemberPolicy { const issuerPrio = this.getPriority(issuerRole); const targetPrio = this.getPriority(targetRole); - return issuerPrio >= ROLE_PRIORITY.admin && issuerPrio > targetPrio; + return issuerPrio >= (ROLE_PRIORITY['admin'] ?? 3) && issuerPrio > targetPrio; } /** @@ -76,7 +76,7 @@ export class TeamMemberPolicy { const newRolePrio = this.getPriority(newMemberRole); // Только админы и выше могут приглашать - if (issuerPrio < ROLE_PRIORITY.admin) return false; + if (issuerPrio < (ROLE_PRIORITY['admin'] ?? 3)) return false; // Нельзя пригласить кого-то на роль выше или равную своей (кроме owner) if (issuerRole !== 'owner' && newRolePrio >= issuerPrio) return false; @@ -100,6 +100,6 @@ export class TeamMemberPolicy { * const canUpdate = policy.canUpdateMedia('admin'); // true */ public canUpdateMedia(issuerRole: TeamRole): boolean { - return this.getPriority(issuerRole) >= ROLE_PRIORITY.moderator; + return this.getPriority(issuerRole) >= (ROLE_PRIORITY['moderator'] ?? 2); } } diff --git a/src/teams/domain/repository/teams.repository.interface.ts b/src/teams/domain/repository/teams.repository.interface.ts index f7886b42..c4bf29bc 100644 --- a/src/teams/domain/repository/teams.repository.interface.ts +++ b/src/teams/domain/repository/teams.repository.interface.ts @@ -20,7 +20,7 @@ export type RawMemberTeams = { description: string | null; avatarUrl: string | null; role: string; - joinedAt: string; + joinedAt: string | null; }; export interface ITeamsRepository { diff --git a/src/teams/infrastructure/persistence/repositories/teams.repository.ts b/src/teams/infrastructure/persistence/repositories/teams.repository.ts index 8b51c7bf..a2582b50 100644 --- a/src/teams/infrastructure/persistence/repositories/teams.repository.ts +++ b/src/teams/infrastructure/persistence/repositories/teams.repository.ts @@ -25,13 +25,17 @@ export class TeamsRepository implements ITeamsRepository { public create = async (ownerId: string, dto: NewTeam) => { return this.db.transaction(async (tx) => { - const [{ teamId }] = await tx + const [team] = await tx .insert(schema.teams) .values({ ...dto, ownerId }) .returning({ teamId: schema.teams.id }); + if (!team?.teamId) { + throw new Error('Failed to create team: no team returned'); + } + await tx.insert(schema.teamMembers).values({ - teamId, + teamId: team.teamId, userId: ownerId, role: 'owner', status: 'active', @@ -40,22 +44,26 @@ export class TeamsRepository implements ITeamsRepository { return { success: true, - teamId, + teamId: team.teamId, }; }); }; public update = async (id: string, dto: Partial) => { return this.db.transaction(async (tx) => { - const [{ teamId }] = await tx + const [team] = await tx .update(schema.teams) .set(dto) .where(eq(schema.teams.id, id)) .returning({ teamId: schema.teams.id }); + if (!team?.teamId) { + throw new Error('Failed to create team: no team returned'); + } + return { success: true, - teamId, + teamId: team.teamId, }; }); }; diff --git a/src/user/application/use-cases/register-user.use-case.ts b/src/user/application/use-cases/register-user.use-case.ts index 88167ee1..8b5e6461 100644 --- a/src/user/application/use-cases/register-user.use-case.ts +++ b/src/user/application/use-cases/register-user.use-case.ts @@ -11,7 +11,7 @@ export class RegisterUserUseCase { private readonly repository: IUserRepository, ) {} - async execute(dto: NewUser & { password: string }) { + async execute(dto: NewUser & { password: string | null }) { const existingUser = await this.repository.findByEmail(dto.email); if (existingUser?.user) { @@ -28,14 +28,16 @@ export class RegisterUserUseCase { try { const user = await this.repository.create(dto); - await Promise.all([ - this.repository.logActivity({ - eventType: 'registered', - userId: user.id, - id: createId(), - }), - this.repository.updatePasswordHash(user.id, dto.password), - ]); + if (dto.password) { + await Promise.all([ + this.repository.logActivity({ + eventType: 'registered', + userId: user.id, + id: createId(), + }), + this.repository.updatePasswordHash(user.id, dto.password), + ]); + } return user; } catch (error) { diff --git a/src/user/application/use-cases/update-notifications.use-case.ts b/src/user/application/use-cases/update-notifications.use-case.ts index 022e251d..2c9c55c6 100644 --- a/src/user/application/use-cases/update-notifications.use-case.ts +++ b/src/user/application/use-cases/update-notifications.use-case.ts @@ -3,6 +3,7 @@ import { HttpStatus, Inject, Injectable } from '@nestjs/common'; import { BaseException } from '@shared/error'; import { UpdateNotificationsDto } from '../dtos'; import { createId } from '@paralleldrive/cuid2'; +import { removeUndefined } from '@shared/utils'; @Injectable() export class UpdateNotificationsUseCase { @@ -22,11 +23,13 @@ export class UpdateNotificationsUseCase { } try { - const isUpdated = await this.userRepo.updateNotifications(id, { - email: dto.email, - push: dto.push, - }); - + const isUpdated = await this.userRepo.updateNotifications( + id, + removeUndefined({ + email: dto.email, + push: dto.push, + }), + ); if (!isUpdated) { throw new BaseException( { diff --git a/src/user/application/use-cases/update-profile.use-case.ts b/src/user/application/use-cases/update-profile.use-case.ts index 537fc928..5d7e22f0 100644 --- a/src/user/application/use-cases/update-profile.use-case.ts +++ b/src/user/application/use-cases/update-profile.use-case.ts @@ -3,6 +3,7 @@ import { Injectable, Inject, HttpStatus } from '@nestjs/common'; import { UpdateProfileDto } from '../dtos'; import { BaseException } from '@shared/error'; import { createId } from '@paralleldrive/cuid2'; +import { removeUndefined } from '@shared/utils'; @Injectable() export class UpdateProfileUseCase { @@ -14,7 +15,7 @@ export class UpdateProfileUseCase { async execute(id: string, dto: UpdateProfileDto) { const entity = await this.userRepo.findById(id); - if (!entity.user) { + if (!entity?.user) { throw new BaseException( { code: 'USER_NOT_FOUND', message: 'Пользователь не найден' }, HttpStatus.NOT_FOUND, @@ -31,7 +32,11 @@ export class UpdateProfileUseCase { theme, }; - const isUpdated = await this.userRepo.updateProfile(entity.user.id, profile, preferences); + const isUpdated = await this.userRepo.updateProfile( + entity.user.id, + removeUndefined(profile), + preferences, + ); if (!isUpdated) { throw new BaseException( diff --git a/src/user/domain/entities/user.domain.ts b/src/user/domain/entities/user.domain.ts index beff7975..ad6c9e18 100644 --- a/src/user/domain/entities/user.domain.ts +++ b/src/user/domain/entities/user.domain.ts @@ -1,4 +1,4 @@ -import { InferSelectModel, InferInsertModel } from 'drizzle-orm'; +import type { InferSelectModel, InferInsertModel } from 'drizzle-orm'; import { users, userSecurity, @@ -17,19 +17,24 @@ export type UserSecurity = InferSelectModel; export type NewUserSecurity = InferInsertModel; export type UserNotifications = InferSelectModel; -export type NotificationSettings = Pick; +export type NotificationSettings = NonNullable; export type UserActivity = InferSelectModel; export type NewUserActivity = InferInsertModel; export type UserProfile = { user: User; - security: Pick; - notifications: NotificationSettings['settings']; - preferences: UserPreferences; + security: { + lastPasswordChange: string | null; + is2faEnabled: boolean; + }; + preferences: UserPreferences | null; + notifications: NotificationSettings; }; export type UserWithSecurity = { user: User; - security: Pick; + security: { + passwordHash: string | null; + }; }; diff --git a/src/user/domain/repository/user.repository.interface.ts b/src/user/domain/repository/user.repository.interface.ts index b1d4a07e..333f16b8 100644 --- a/src/user/domain/repository/user.repository.interface.ts +++ b/src/user/domain/repository/user.repository.interface.ts @@ -7,7 +7,11 @@ import type { UserPreferences, UserProfile, UserWithSecurity, -} from '../entities/user.domain'; +} from '../entities'; + +type DeepPartial = { + [P in keyof T]?: T[P] extends object ? DeepPartial : T[P]; +}; export interface IUserRepository { create(data: NewUser): Promise; @@ -29,6 +33,9 @@ export interface IUserRepository { preferences?: Partial, ): Promise; updatePasswordHash(id: string, hash: string): Promise; - updateNotifications(id: string, settings: UserNotifications['settings']): Promise; + updateNotifications( + id: string, + settings: DeepPartial, + ): Promise; logActivity(data: NewUserActivity): Promise; } diff --git a/src/user/infrastructure/listeners/update-avatar.listener.ts b/src/user/infrastructure/listeners/update-avatar.listener.ts index 7563c220..631a916b 100644 --- a/src/user/infrastructure/listeners/update-avatar.listener.ts +++ b/src/user/infrastructure/listeners/update-avatar.listener.ts @@ -43,6 +43,7 @@ export class UpdateAvatarListener extends WorkerHost { await job.updateProgress(100); await job.log(`Successfully updated avatar for user ${userAccount.user.id}`); + return; } catch (error) { const errorMessage = error instanceof Error ? error.message : 'Unknown error'; await job.log(`Critical failure: ${errorMessage}`); diff --git a/src/user/infrastructure/persistence/repositories/user.repository.ts b/src/user/infrastructure/persistence/repositories/user.repository.ts index 8fc683f7..3f5a1966 100644 --- a/src/user/infrastructure/persistence/repositories/user.repository.ts +++ b/src/user/infrastructure/persistence/repositories/user.repository.ts @@ -27,34 +27,47 @@ export class UserRepository implements IUserRepository { .leftJoin(sc.userNotifications, eq(sc.users.id, sc.userNotifications.userId)); } - async findProfile(id: string) { + public findProfile = async (id: string) => { const [rows] = await this.fullUserQuery .leftJoin(sc.userPreferences, eq(sc.users.id, sc.userPreferences.userId)) .where(eq(sc.users.id, id)); - if (!rows || !rows.users) { - return null; + if (!rows || !rows.users || !rows.user_security) { + throw new Error(`User with id ${id} not found`); } const { lastPasswordChange, is2faEnabled } = rows.user_security; - const { settings } = rows.user_notifications; - const preferences = rows.user_preferences; + + const defaultNotifications = { + email: { + task_assigned: true, + mentions: true, + daily_summary: false, + }, + push: { + task_assigned: true, + reminders: true, + }, + }; return { user: rows.users, - security: { lastPasswordChange, is2faEnabled }, - preferences, - notifications: settings, + security: { + lastPasswordChange: lastPasswordChange ?? null, + is2faEnabled: is2faEnabled ?? false, + }, + preferences: rows.user_preferences ?? null, + notifications: rows.user_notifications?.settings ?? defaultNotifications, }; - } + }; - async findByIds(ids: string[]) { + public findByIds = async (ids: string[]) => { if (ids.length === 0) return []; return this.db.select().from(sc.users).where(inArray(sc.users.id, ids)); - } + }; - async findById(id: string) { + public findById = async (id: string) => { const [row] = await this.fullUserQuery.where(eq(sc.users.id, id)); if (!row || !row.user_security) return null; return { @@ -63,9 +76,9 @@ export class UserRepository implements IUserRepository { passwordHash: row.user_security.passwordHash, }, }; - } + }; - async findByEmail(email: string) { + public findByEmail = async (email: string) => { const [row] = await this.fullUserQuery.where(eq(sc.users.email, email.toLowerCase())); if (!row || !row.user_security) return null; return { @@ -74,50 +87,60 @@ export class UserRepository implements IUserRepository { passwordHash: row.user_security.passwordHash, }, }; - } + }; - async findSecurityByUserId(userId: string) { + public findSecurityByUserId = async (userId: string) => { const [result] = await this.db .select() .from(sc.userSecurity) .where(eq(sc.userSecurity.userId, userId)); return result || null; - } + }; - async create(data: NewUser) { - return await this.db.transaction(async (tx) => { + public create = async (data: NewUser) => { + return this.db.transaction(async (tx) => { const [newUser] = await tx.insert(sc.users).values(data).returning(); + if (!newUser) { + throw new Error('Failed to create user'); + } + await tx.insert(sc.userNotifications).values({ userId: newUser.id, }); return newUser; }); - } - - async updateProfile(id: string, user: Partial, preferences?: Partial) { - const [userRes, preferencesRes] = await Promise.all([ + }; + + public updateProfile = async ( + id: string, + user: Partial, + preferences?: Partial, + ) => { + const results = await Promise.all([ this.updateUser(id, user), this.upsertPreferences(id, preferences), ]); - return userRes || preferencesRes; - } + return results.some((result) => result === true); + }; private async updateUser(id: string, data: Partial) { if (Object.keys(data).length === 0) return null; - const [result] = await this.db + const result = await this.db .update(sc.users) .set({ ...data, updatedAt: new Date().toISOString() }) .where(eq(sc.users.id, id)); - return result; + return (result?.count ?? 0) > 0; } - private async upsertPreferences(userId: string, data: Partial) { - if (Object.keys(data).length === 0) return null; + private async upsertPreferences(userId: string, data?: Partial) { + if (!data || Object.keys(data).length === 0) { + return false; + } const existing = await this.db .select({ id: sc.userPreferences.userId }) @@ -131,14 +154,14 @@ export class UserRepository implements IUserRepository { ...data, }); - return result.count ?? 0 > 0; + return (result.count ?? 0) > 0; } else { const result = await this.db .update(sc.userPreferences) .set(data) .where(eq(sc.userPreferences.userId, userId)); - return result.count ?? 0 > 0; + return (result.count ?? 0) > 0; } } diff --git a/tsconfig.json b/tsconfig.json index 21038d87..9fa039b4 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -13,12 +13,24 @@ "outDir": "./dist", "incremental": true, "skipLibCheck": true, - "strictNullChecks": false, - "noImplicitAny": false, - "strictBindCallApply": false, - "forceConsistentCasingInFileNames": false, - "noFallthroughCasesInSwitch": false, "types": ["node", "vitest/globals"], + "strict": true, + "strictNullChecks": true, + "noImplicitAny": true, + "strictBindCallApply": true, + "strictFunctionTypes": true, + "strictPropertyInitialization": false, + "noImplicitThis": true, + "alwaysStrict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedIndexedAccess": true, + "noPropertyAccessFromIndexSignature": true, + "noImplicitOverride": true, + "exactOptionalPropertyTypes": false, + "forceConsistentCasingInFileNames": true, "paths": { "@libs/bootstrap": ["./libs/bootstrap/src"], "@libs/bootstrap/*": ["./libs/bootstrap/src/*"],