refactor: organize backend by business responsibility

This commit is contained in:
2026-09-13 13:52:57 +03:00
parent 7fde3ece6e
commit 8ba005346d
145 changed files with 3027 additions and 2346 deletions
@@ -0,0 +1,55 @@
import {
Body,
Controller,
Delete,
Get,
HttpCode,
Put,
Req,
UseGuards,
} from '@nestjs/common';
import {
ApiBearerAuth,
ApiNoContentResponse,
ApiOkResponse,
ApiTags,
} from '@nestjs/swagger';
import type { Request } from 'express';
import { ApiProblemResponses } from '../../../common/decorators/api-safe-error-response.decorator';
import { AccessPayload, JwtAuthGuard } from '../../auth/auth.api';
import {
PutTbankConnectionDto,
TbankConnectionStatusDto,
} from './tbank-connection.dto';
import { TbankConnectionService } from './tbank-connection.service';
@ApiTags('tbank-connection')
@ApiProblemResponses()
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
@Controller('tbank-connection')
export class TbankConnectionController {
constructor(private readonly connection: TbankConnectionService) {}
@Get()
@ApiOkResponse({ type: TbankConnectionStatusDto })
status(@Req() request: Request & { user: AccessPayload }) {
return this.connection.status(request.user.sub);
}
@Put()
@ApiOkResponse({ type: TbankConnectionStatusDto })
put(
@Req() request: Request & { user: AccessPayload },
@Body() body: PutTbankConnectionDto,
) {
return this.connection.put(request.user.sub, body.token);
}
@Delete()
@HttpCode(204)
@ApiNoContentResponse()
delete(@Req() request: Request & { user: AccessPayload }): Promise<void> {
return this.connection.delete(request.user.sub);
}
}
@@ -0,0 +1,18 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsNotEmpty, IsString, MaxLength } from 'class-validator';
export class PutTbankConnectionDto {
@ApiProperty({ writeOnly: true })
@IsString()
@IsNotEmpty()
@MaxLength(4096)
declare token: string;
}
export class TbankConnectionStatusDto {
@ApiProperty()
declare connected: boolean;
@ApiProperty({ type: String, nullable: true, format: 'date-time' })
declare connectedAt: string | null;
}
@@ -0,0 +1,13 @@
import { Module } from '@nestjs/common';
import { TbankModule } from '../../../integrations/tbank/tbank.module';
import { AuthModule } from '../../auth/auth.module';
import { PortfolioModule } from '../portfolio/portfolio.module';
import { TbankConnectionController } from './tbank-connection.controller';
import { TbankConnectionService } from './tbank-connection.service';
@Module({
imports: [AuthModule, TbankModule, PortfolioModule],
controllers: [TbankConnectionController],
providers: [TbankConnectionService],
})
export class TbankConnectionModule {}
@@ -0,0 +1,122 @@
import { BadRequestException } from '@nestjs/common';
jest.mock('../../../prisma/prisma.service', () => ({
PrismaService: class PrismaService {},
}));
import type { TbankClient } from '../../../integrations/tbank/tbank.client';
import { TbankConnectionService } from './tbank-connection.service';
describe('TbankConnectionService', () => {
const prisma = {
tbankConnection: {
findUnique: jest.fn(),
upsert: jest.fn(),
deleteMany: jest.fn(),
},
};
const tokens = {
encrypt: jest.fn((value: string) => `encrypted:${value}`),
translateInvalidToken: jest.fn(
async (operation: () => Promise<unknown>) => {
try {
return await operation();
} catch (error) {
if (error instanceof Error && error.message === 'INVALID_TBANK_TOKEN')
throw new BadRequestException({ code: 'INVALID_TBANK_TOKEN' });
throw error;
}
},
),
};
const tbank: jest.Mocked<Pick<TbankClient, 'validateToken'>> = {
validateToken: jest.fn(),
};
const portfolio = { invalidate: jest.fn() };
const service = new TbankConnectionService(
prisma as never,
tokens as never,
tbank,
portfolio as never,
);
beforeEach(() => {
jest.clearAllMocks();
tbank.validateToken.mockResolvedValue(undefined);
});
it('does not persist an invalid token', async () => {
tbank.validateToken.mockRejectedValueOnce(new Error('INVALID_TBANK_TOKEN'));
await expect(service.put('user-1', ' token ')).rejects.toEqual(
expect.any(BadRequestException),
);
expect(prisma.tbankConnection.upsert).not.toHaveBeenCalled();
});
it('validates and encrypts a candidate token before persisting it', async () => {
const connectedAt = new Date('2026-08-25T09:00:00.000Z');
prisma.tbankConnection.upsert.mockResolvedValue({ connectedAt });
await expect(service.put('user-1', ' token ')).resolves.toEqual({
connected: true,
connectedAt: '2026-08-25T09:00:00.000Z',
});
expect(tokens.translateInvalidToken).toHaveBeenCalledWith(
expect.any(Function),
);
expect(tbank.validateToken).toHaveBeenCalledWith('token');
expect(tokens.encrypt).toHaveBeenCalledWith('token');
expect(prisma.tbankConnection.upsert).toHaveBeenCalledWith({
where: { userId: 'user-1' },
create: {
userId: 'user-1',
encryptedToken: 'encrypted:token',
connectedAt: expect.any(Date) as Date,
},
update: {
encryptedToken: 'encrypted:token',
connectedAt: expect.any(Date) as Date,
},
});
expect(portfolio.invalidate).toHaveBeenCalledWith('user-1');
});
it('re-encrypts the same plaintext token for every successful replacement', async () => {
const connectedAt = new Date('2026-08-25T09:00:00.000Z');
prisma.tbankConnection.upsert.mockResolvedValue({ connectedAt });
tokens.encrypt
.mockReturnValueOnce('encrypted:first-nonce')
.mockReturnValueOnce('encrypted:second-nonce');
await service.put('user-1', 'same-token');
await service.put('user-1', 'same-token');
expect(prisma.tbankConnection.upsert).toHaveBeenNthCalledWith(
1,
expect.objectContaining({
update: expect.objectContaining({
encryptedToken: 'encrypted:first-nonce',
}) as object,
}),
);
expect(prisma.tbankConnection.upsert).toHaveBeenNthCalledWith(
2,
expect.objectContaining({
update: expect.objectContaining({
encryptedToken: 'encrypted:second-nonce',
}) as object,
}),
);
});
it('deletes a connection idempotently', async () => {
await service.delete('user-1');
expect(prisma.tbankConnection.deleteMany).toHaveBeenCalledWith({
where: { userId: 'user-1' },
});
expect(portfolio.invalidate).toHaveBeenCalledWith('user-1');
});
});
@@ -0,0 +1,60 @@
import { BadRequestException, Inject, Injectable } from '@nestjs/common';
import {
TbankClient,
TbankTokenService,
} from '../../../integrations/tbank/tbank.api';
import { PrismaService } from '../../../prisma/prisma.service';
import { PortfolioService } from '../portfolio/portfolio.api';
type TbankTokenValidator = Pick<TbankClient, 'validateToken'>;
@Injectable()
export class TbankConnectionService {
constructor(
private readonly prisma: PrismaService,
private readonly tokens: TbankTokenService,
@Inject(TbankClient) private readonly tbank: TbankTokenValidator,
private readonly portfolio: PortfolioService,
) {}
async status(userId: string) {
const connection = await this.prisma.tbankConnection.findUnique({
where: { userId },
});
return {
connected: Boolean(connection),
connectedAt: connection?.connectedAt.toISOString() ?? null,
};
}
async put(userId: string, value: string) {
const token = value.trim();
if (!token) throw new BadRequestException({ code: 'VALIDATION_ERROR' });
await this.tokens.translateInvalidToken(() =>
this.tbank.validateToken(token),
);
const encryptedToken = this.tokens.encrypt(token);
const connection = await this.prisma.tbankConnection.upsert({
where: { userId },
create: {
userId,
encryptedToken,
connectedAt: new Date(),
},
update: {
encryptedToken,
connectedAt: new Date(),
},
});
this.portfolio.invalidate(userId);
return {
connected: true,
connectedAt: connection.connectedAt.toISOString(),
};
}
async delete(userId: string): Promise<void> {
await this.prisma.tbankConnection.deleteMany({ where: { userId } });
this.portfolio.invalidate(userId);
}
}