diff --git a/.ai/architecture.md b/.ai/architecture.md index a0440a2..78a0c26 100644 --- a/.ai/architecture.md +++ b/.ai/architecture.md @@ -29,9 +29,9 @@ market — котировками, income — выплатами/личными domain из-за extraction в функцию. Boundary mappers преобразуют внешние records; чистые расчёты portfolio/banking остаются у domain owner. -PrismaService владеет DB lifecycle. Самостоятельные use cases получают данные -через concrete repositories; существующие связные CRUD/transaction services -сохраняют прямые queries по [data convention](conventions/data.md). +PrismaService владеет DB lifecycle. Application services/use cases получают +данные через concrete repositories; запросы и CAS живут в infrastructure +по [data convention](conventions/data.md). Money — currency + decimal string; расчёты используют точные общие helpers. Auth управляет identity/session; TbankAccessService проверяет доступ к счёту и @@ -201,9 +201,9 @@ Circular dependencies устраняй изменением ownership/compositio конкретные infrastructure providers, зарегистрированные module. Infrastructure может использовать application types/input и domain rules; новые interfaces ради инверсии зависимостей не обязательны. Domain не импортирует presentation или -infrastructure. Существующие связные CRUD/transaction services могут сохранять прямые queries -по [Data](conventions/data.md); use case получает данные через repository и сам -Prisma queries не выполняет. Entity/interface на каждую таблицу не требуется. +infrastructure. Application не строит Prisma queries: use case и связный CRUD +service обращаются к репозиторию по [Data](conventions/data.md). Entity/interface +на каждую таблицу не требуется. DTO остаются транспортными runtime classes; существующее использование DTO на application boundary допустимо по [HTTP API](conventions/http-api.md). diff --git a/.ai/conventions/data.md b/.ai/conventions/data.md index b79cc76..ea74b2f 100644 --- a/.ai/conventions/data.md +++ b/.ai/conventions/data.md @@ -34,19 +34,19 @@ candidate, не повод дублировать их при новом use cas вызывает repository, затем чистый `bankSummary` и формирует ответ. Prisma records не становятся domain entities: расчёт получает только нужные ему значения. -Не создавай обёртку на каждый вызов Prisma. Существующие связные CRUD/transaction -services (auth, lifecycle подключения, изменения карт/операций/категорий) -сохраняют queries рядом с CAS и проверками инварианта, если их перенос требует -лишь набора pass-through методов. Новая самостоятельная read/write capability -получает repository method; самостоятельный use case не выполняет Prisma queries. -`BankCardsRepository.write` сохраняет общую retry/transaction boundary для -существующих banking services. Такой callback с Prisma transaction client — -локальная техническая граница этих services, не API для domain или use cases. +Application-код не импортирует PrismaService и не строит Prisma queries. +Репозиторий владеет запросом и CAS-проверкой результата, а application — +правилами и формированием ответа. Типы Prisma допустимы только в type-only +импортах для существующей транзакционной границы. Не создавай обёртку +на каждый вызов Prisma без текущего потребителя. `BankCardsRepository.write` +сохраняет retry/transaction boundary для банковских записей; callback с +transaction client — локальная техническая граница, не API для domain. Выбирай только нужные данные, сохраняй user/account ownership в запросах и application checks. Предварительный lookup не заменяет unique constraint при race. -Пример: AuthService обрабатывает P2002 и использует conditional `updateMany` -для refresh rotation. Сначала пойми invariant; не копируй его flow в другой use case. +Пример: AuthRepository преобразует P2002 в результат регистрации и выполняет +conditional `updateMany` для refresh rotation; AuthService сохраняет HTTP +error contract. Сначала пойми invariant; не копируй его flow в другой use case. Transaction не охватывает сетевой T-Bank вызов как будто это одна atomic DB операция. Планируй порядок и compensation по текущей задаче; не удерживай DB diff --git a/eslint.config.mjs b/eslint.config.mjs index d5a9e23..b44f066 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -158,13 +158,14 @@ export default defineConfig( }, }, { - files: ['src/**/*.use-case.ts'], + files: ['src/**/application/**/*.ts'], ignores: tests, rules: { 'no-restricted-imports': ['error', { patterns: [{ group: prismaImports, - message: 'Use cases obtain data through concrete repositories.', + allowTypeImports: true, + message: 'Application code obtains data through concrete repositories; type-only imports are allowed.', }], }], }, diff --git a/src/modules/auth/application/auth.service.spec.ts b/src/modules/auth/application/auth.service.spec.ts index 3e04a6d..b9e4930 100644 --- a/src/modules/auth/application/auth.service.spec.ts +++ b/src/modules/auth/application/auth.service.spec.ts @@ -2,6 +2,8 @@ import { ConflictException, UnauthorizedException } from '@nestjs/common'; import { JwtService } from '@nestjs/jwt'; import { createHash } from 'crypto'; +import { AuthRepository } from '../infrastructure/auth.repository'; + jest.mock('bcrypt', () => ({ hash: jest.fn(() => Promise.resolve('password-hash')), compare: jest.fn(() => Promise.resolve(true)), @@ -51,7 +53,7 @@ describe('AuthService', () => { delete: jest.fn(), }, }; - const service = new AuthService(prisma as never, jwt); + const service = new AuthService(new AuthRepository(prisma as never), jwt); beforeEach(() => { jest.clearAllMocks(); diff --git a/src/modules/auth/application/auth.service.ts b/src/modules/auth/application/auth.service.ts index f19cbc2..2462d24 100644 --- a/src/modules/auth/application/auth.service.ts +++ b/src/modules/auth/application/auth.service.ts @@ -7,15 +7,13 @@ import { JwtService } from '@nestjs/jwt'; import * as bcrypt from 'bcrypt'; import { createHash, randomUUID } from 'crypto'; -import { Session, User } from '../../../../generated/prisma/client'; +import type { Session, User } from '../../../../generated/prisma/client'; + import { HttpErrorCode } from '../../../common/error-code'; -import { isPrismaErrorCode, PrismaErrorCode } from '../../../prisma/prisma-error-code'; -import { PrismaService } from '../../../prisma/prisma.service'; +import { AuthRepository } from '../infrastructure/auth.repository'; import { AuthErrorCode } from './auth-error-code'; import { AuthenticationResult, AuthUser, RefreshPayload } from './auth.types'; -const EXPECTED_ROTATED_SESSIONS = 1; - const PASSWORD_HASH_ROUNDS = 12; export const REFRESH_TTL_MS = 604_800_000; @@ -28,7 +26,7 @@ const DUMMY_PASSWORD_HASH = @Injectable() export class AuthService { constructor( - private readonly prisma: PrismaService, + private readonly repository: AuthRepository, private readonly jwt: JwtService, ) {} @@ -36,27 +34,18 @@ export class AuthService { email: string, password: string, ): Promise { - const existing = await this.prisma.user.findUnique({ - where: { email }, - }); + const existing = await this.repository.findUserByEmail(email); if (existing) { throw new ConflictException({ code: AuthErrorCode.EmailAlreadyRegistered }); } - let user: User; - try { - user = await this.prisma.user.create({ - data: { - id: randomUUID(), - email, - passwordHash: await bcrypt.hash(password, PASSWORD_HASH_ROUNDS), - }, - }); - } catch (error) { - if (isUniqueConstraintError(error)) { - throw new ConflictException({ code: AuthErrorCode.EmailAlreadyRegistered }); - } - throw error; + const user = await this.repository.createUser({ + id: randomUUID(), + email, + passwordHash: await bcrypt.hash(password, PASSWORD_HASH_ROUNDS), + }); + if (!user) { + throw new ConflictException({ code: AuthErrorCode.EmailAlreadyRegistered }); } return this.createSession(user); } @@ -65,9 +54,7 @@ export class AuthService { email: string, password: string, ): Promise { - const user = await this.prisma.user.findUnique({ - where: { email }, - }); + const user = await this.repository.findUserByEmail(email); const valid = await bcrypt.compare( password, user?.passwordHash ?? DUMMY_PASSWORD_HASH, @@ -92,10 +79,7 @@ export class AuthService { if (payload.type !== 'refresh') { throw new UnauthorizedException({ code: HttpErrorCode.Unauthorized }); } - const session = await this.prisma.session.findUnique({ - where: { id: payload.sid }, - include: { user: true }, - }); + const session = await this.repository.findSessionWithUser(payload.sid); if ( !session || session.userId !== payload.sub || @@ -103,7 +87,7 @@ export class AuthService { session.expiresAt <= new Date() ) { if (session && !session.revokedAt) { - await this.prisma.session.delete({ where: { id: session.id } }); + await this.repository.deleteSession(session.id); } throw new UnauthorizedException({ code: HttpErrorCode.Unauthorized }); } @@ -124,18 +108,13 @@ export class AuthService { return; } if (payload.type !== 'refresh') return; - const session = await this.prisma.session.findUnique({ - where: { id: payload.sid }, - }); + const session = await this.repository.findSession(payload.sid); if ( session && !session.revokedAt && session.refreshTokenHash === this.hash(token) ) { - await this.prisma.session.update({ - where: { id: session.id }, - data: { revokedAt: new Date() }, - }); + await this.repository.revokeSession(session.id); } } @@ -144,7 +123,7 @@ export class AuthService { } async userById(id: string): Promise { - const user = await this.prisma.user.findUnique({ where: { id } }); + const user = await this.repository.findUserById(id); if (!user) throw new UnauthorizedException({ code: HttpErrorCode.Unauthorized }); return user; } @@ -152,14 +131,7 @@ export class AuthService { private async createSession( user: User, ): Promise { - const session = await this.prisma.session.create({ - data: { - id: randomUUID(), - userId: user.id, - refreshTokenHash: '', - expiresAt: new Date(), - }, - }); + const session = await this.repository.createSession(user.id, randomUUID()); return this.rotateSession({ ...session, user }); } @@ -180,16 +152,8 @@ export class AuthService { refreshTokenHash: this.hash(refreshToken), expiresAt: new Date(Date.now() + REFRESH_TTL_MS), }; - if (expectedRefreshTokenHash) { - const updated = await this.prisma.session.updateMany({ - where: { id: session.id, refreshTokenHash: expectedRefreshTokenHash }, - data, - }); - if (updated.count !== EXPECTED_ROTATED_SESSIONS) { - throw new UnauthorizedException({ code: HttpErrorCode.Unauthorized }); - } - } else { - await this.prisma.session.update({ where: { id: session.id }, data }); + if (!(await this.repository.rotateSession(session.id, data, expectedRefreshTokenHash))) { + throw new UnauthorizedException({ code: HttpErrorCode.Unauthorized }); } return { user: this.toUser(session.user), @@ -205,12 +169,3 @@ export class AuthService { return createHash('sha256').update(token).digest('hex'); } } - -function isUniqueConstraintError(error: unknown): boolean { - return ( - typeof error === 'object' && - error !== null && - 'code' in error && - isPrismaErrorCode(error.code, PrismaErrorCode.UniqueConstraint) - ); -} diff --git a/src/modules/auth/auth.module.ts b/src/modules/auth/auth.module.ts index 1d93f41..ccef8e5 100644 --- a/src/modules/auth/auth.module.ts +++ b/src/modules/auth/auth.module.ts @@ -5,6 +5,7 @@ import { PassportModule } from '@nestjs/passport'; import { Environment } from '../../config/env.validation'; import { AuthService } from './application/auth.service'; +import { AuthRepository } from './infrastructure/auth.repository'; import { JwtStrategy } from './infrastructure/jwt.strategy'; import { AuthController } from './presentation/auth.controller'; import { JwtAuthGuard } from './presentation/jwt-auth.guard'; @@ -22,6 +23,6 @@ import { JwtAuthGuard } from './presentation/jwt-auth.guard'; ], exports: [JwtAuthGuard], controllers: [AuthController], - providers: [AuthService, JwtStrategy, JwtAuthGuard], + providers: [AuthService, AuthRepository, JwtStrategy, JwtAuthGuard], }) export class AuthModule {} diff --git a/src/modules/auth/infrastructure/auth.repository.ts b/src/modules/auth/infrastructure/auth.repository.ts new file mode 100644 index 0000000..afc3515 --- /dev/null +++ b/src/modules/auth/infrastructure/auth.repository.ts @@ -0,0 +1,81 @@ +import { Injectable } from '@nestjs/common'; + +import { isPrismaErrorCode, PrismaErrorCode } from '../../../prisma/prisma-error-code'; +import { PrismaService } from '../../../prisma/prisma.service'; + +const EXPECTED_ROTATED_SESSIONS = 1; + +@Injectable() +export class AuthRepository { + constructor(private readonly prisma: PrismaService) {} + + findUserByEmail(email: string) { + return this.prisma.user.findUnique({ where: { email } }); + } + + async createUser(data: { email: string; id: string; passwordHash: string }) { + try { + return await this.prisma.user.create({ data }); + } catch (error) { + if (isUniqueConstraintError(error)) return null; + throw error; + } + } + + findUserById(id: string) { + return this.prisma.user.findUnique({ where: { id } }); + } + + findSessionWithUser(id: string) { + return this.prisma.session.findUnique({ + where: { id }, + include: { user: true }, + }); + } + + findSession(id: string) { + return this.prisma.session.findUnique({ where: { id } }); + } + + deleteSession(id: string) { + return this.prisma.session.delete({ where: { id } }); + } + + revokeSession(id: string) { + return this.prisma.session.update({ + where: { id }, + data: { revokedAt: new Date() }, + }); + } + + createSession(userId: string, id: string) { + return this.prisma.session.create({ + data: { id, userId, refreshTokenHash: '', expiresAt: new Date() }, + }); + } + + async rotateSession( + id: string, + data: { expiresAt: Date; refreshTokenHash: string }, + expectedRefreshTokenHash?: string, + ): Promise { + if (expectedRefreshTokenHash) { + const updated = await this.prisma.session.updateMany({ + where: { id, refreshTokenHash: expectedRefreshTokenHash }, + data, + }); + return updated.count === EXPECTED_ROTATED_SESSIONS; + } + await this.prisma.session.update({ where: { id }, data }); + return true; + } +} + +function isUniqueConstraintError(error: unknown): boolean { + return ( + typeof error === 'object' && + error !== null && + 'code' in error && + isPrismaErrorCode(error.code, PrismaErrorCode.UniqueConstraint) + ); +} diff --git a/src/modules/banking/bank-cards/application/bank-cards.service.ts b/src/modules/banking/bank-cards/application/bank-cards.service.ts index 96685b0..e8db044 100644 --- a/src/modules/banking/bank-cards/application/bank-cards.service.ts +++ b/src/modules/banking/bank-cards/application/bank-cards.service.ts @@ -1,6 +1,7 @@ import { Injectable } from '@nestjs/common'; -import { BankCard, Prisma } from '../../../../../generated/prisma/client'; +import type { BankCard, Prisma } from '../../../../../generated/prisma/client'; + import { bankBalance } from '../domain/bank-card.calculations'; import { rubMoney } from '../domain/rub-money'; import { BankCardsRepository } from '../infrastructure/bank-cards.repository'; diff --git a/src/modules/banking/bank-cards/application/bank-operations.service.ts b/src/modules/banking/bank-cards/application/bank-operations.service.ts index 5788899..72e8682 100644 --- a/src/modules/banking/bank-cards/application/bank-operations.service.ts +++ b/src/modules/banking/bank-cards/application/bank-operations.service.ts @@ -1,9 +1,10 @@ import { GoneException, Injectable } from '@nestjs/common'; -import { +import type { BankCardOperation, Prisma, } from '../../../../../generated/prisma/client'; + import { FIRST_PAGE } from '../domain/bank-card-limits'; import { BankOperationKind } from '../domain/bank-operation-kind'; import { rubMoney, ZERO_KOPECKS } from '../domain/rub-money'; diff --git a/test/eslint-config.cjs b/test/eslint-config.cjs index 4a8b9d1..69fb37b 100644 --- a/test/eslint-config.cjs +++ b/test/eslint-config.cjs @@ -102,6 +102,7 @@ test('architecture imports enforce direct boundaries without banning DTOs or pur [controller, 'import { PrismaService } from "../../../prisma/prisma.service";'], [controller, 'import { TbankClient } from "../../../integrations/tbank/tbank.api";'], [useCase, 'import { PrismaService } from "../../../../prisma/prisma.service";'], + ['src/modules/banking/bank-cards/application/bank-cards.service.ts', 'import { PrismaService } from "../../../../prisma/prisma.service";'], ]) { const [result] = await eslint.lintText(code, { filePath }); assert.equal(result.fatalErrorCount, 0); @@ -113,7 +114,7 @@ test('architecture imports enforce direct boundaries without banning DTOs or pur [controller, 'import { AuthService } from "../application/auth.service";'], [useCase, 'import { BankCardsRepository } from "../infrastructure/bank-cards.repository";'], [useCase, 'import { BankSummaryQueryDto } from "../presentation/bank-card.dto";'], - ['src/modules/banking/bank-cards/application/bank-cards.service.ts', 'import { PrismaService } from "../../../../prisma/prisma.service";'], + ['src/modules/banking/bank-cards/application/bank-cards.service.ts', 'import type { Prisma } from "../../../../../generated/prisma/client";'], ]) { const [result] = await eslint.lintText(code, { filePath }); assert.equal(result.fatalErrorCount, 0);