2 Commits
Author SHA1 Message Date
ksv741 a7e98c0ec3 refactor: enforce repository boundary in application (WealthLedger/backend#171)
Backend verify / commits (pull_request) Successful in 1m41s
Backend verify / security (pull_request) Successful in 1m13s
Backend verify / verify (pull_request) Successful in 6m50s
2026-11-04 16:53:41 +03:00
ksv741 4e3e58e288 refactor: move auth persistence into repository (WealthLedger/backend#171) 2026-11-04 16:46:49 +03:00
10 changed files with 132 additions and 89 deletions
+6 -6
View File
@@ -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).
+10 -10
View File
@@ -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
+3 -2
View File
@@ -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.',
}],
}],
},
@@ -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();
+21 -66
View File
@@ -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<AuthenticationResult> {
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<AuthenticationResult> {
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<User> {
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<AuthenticationResult> {
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)
);
}
+2 -1
View File
@@ -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 {}
@@ -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<boolean> {
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)
);
}
@@ -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';
@@ -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';
+2 -1
View File
@@ -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);