1124 lines
33 KiB
Markdown
1124 lines
33 KiB
Markdown
# Backend Architecture Refactor Implementation Plan
|
|
|
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
|
|
|
**Goal:** Improve backend architecture and safety without adding user-facing features or changing successful API response shapes.
|
|
|
|
**Architecture:** Keep the refactor vertical and minimal: each task hardens one boundary, adds regression tests first, then makes the smallest production change. Configuration/security logic stays near bootstrap/config, DTO validation stays at request boundaries, T-Bank dynamic gRPC casts are centralized in `TBankClientService`, and docs are synchronized after runtime contracts are stable.
|
|
|
|
**Tech Stack:** NestJS 10, TypeScript, Vitest, class-validator/class-transformer, Prisma, `@grpc/grpc-js`, cache-manager, Docusaurus docs.
|
|
|
|
---
|
|
|
|
## Scope
|
|
|
|
This plan implements `docs/features/backend-architecture-refactor/spec.md` only.
|
|
|
|
In scope:
|
|
|
|
- Error masking for unhandled `500` responses.
|
|
- Production JWT secret validation and credentialed CORS allowlist.
|
|
- Portfolio position DTO validation for quantity/date inputs.
|
|
- Typed T-Bank gRPC service-client boundary.
|
|
- Cache metadata consistency on cache hits.
|
|
- Published backend docs sync.
|
|
|
|
Out of scope:
|
|
|
|
- T-Bank multi-tenancy and user-owned broker connections.
|
|
- Local T-Bank operations read-path.
|
|
- Device sessions, refresh-token rotation, reuse detection.
|
|
- Ledger/financial storage migration.
|
|
- Rate limiting, CSRF, security headers.
|
|
- Large service/module decomposition.
|
|
|
|
---
|
|
|
|
## File Structure
|
|
|
|
### Runtime Files
|
|
|
|
- `apps/backend/src/common/filters/http-exception.filter.ts`
|
|
- Owns public error response formatting and internal logging for unhandled exceptions.
|
|
- `apps/backend/src/config/configuration.ts`
|
|
- Owns typed application config values, including auth secrets and CORS origins.
|
|
- `apps/backend/src/main.ts`
|
|
- Owns Nest bootstrap wiring, including CORS options and production config assertions.
|
|
- `apps/backend/src/modules/portfolio/dto/add-position.dto.ts`
|
|
- Request-boundary validation for position creation.
|
|
- `apps/backend/src/modules/portfolio/dto/update-position.dto.ts`
|
|
- Request-boundary validation for position updates.
|
|
- `apps/backend/src/modules/tbank/services/tbank-client.service.ts`
|
|
- Single unsafe boundary for dynamic proto clients and typed service facade methods.
|
|
- `apps/backend/src/modules/tbank/services/broker-accounts.service.ts`
|
|
- Consumer of typed `UsersService` facade.
|
|
- `apps/backend/src/modules/tbank/services/broker-operations.service.ts`
|
|
- Consumer of typed `OperationsService` facade.
|
|
- `apps/backend/src/modules/tbank/services/broker-portfolio.service.ts`
|
|
- Consumer of typed `OperationsService` facade.
|
|
- `apps/backend/src/modules/tbank/services/broker-instruments.service.ts`
|
|
- Consumer of typed `InstrumentsService` facade.
|
|
- `apps/backend/src/modules/cache/cache.service.ts`
|
|
- Owns cache helper payload format and `cachedAt` metadata.
|
|
|
|
### Test Files
|
|
|
|
- Create `apps/backend/src/common/filters/http-exception.filter.spec.ts`.
|
|
- Create `apps/backend/src/config/backend-runtime-config.spec.ts`.
|
|
- Create `apps/backend/src/modules/portfolio/dto/position.dto.spec.ts`.
|
|
- Modify `apps/backend/src/modules/tbank/services/tbank-client.service.spec.ts`.
|
|
- Create `apps/backend/src/modules/cache/cache.service.spec.ts`.
|
|
- Modify affected broker service specs only if the typed facade requires mock updates.
|
|
|
|
### Documentation Files
|
|
|
|
- Modify `apps/docs/docs/backend/moex-client.md`.
|
|
- Modify `apps/docs/docs/backend/modules.md`.
|
|
- Modify `apps/docs/docs/backend/api.md`.
|
|
- Modify `apps/docs/docs/backend/tbank-invest.md` only if endpoint naming or sync wording needs final alignment.
|
|
- Modify `docs/features/backend-architecture-refactor/tasks.md` as each task is completed.
|
|
|
|
---
|
|
|
|
## Verification Commands
|
|
|
|
Use these commands from the repository root unless a task says otherwise:
|
|
|
|
- Backend tests: `npm run test -w apps/backend`
|
|
- Backend build: `npm run build -w apps/backend`
|
|
- Backend lint: `npm run lint -w apps/backend`
|
|
- Docs build after docs sync: `npm run build -w apps/docs`
|
|
|
|
---
|
|
|
|
### Task 0: Baseline Verification
|
|
|
|
**Files:** none
|
|
|
|
- [ ] **Step 1: Confirm branch and working tree**
|
|
|
|
Run:
|
|
|
|
```bash
|
|
git branch --show-current
|
|
rtk git status --short
|
|
```
|
|
|
|
Expected:
|
|
|
|
```text
|
|
codex/backend-architecture-refactor
|
|
```
|
|
|
|
`rtk git status --short` may show the SDD docs created before implementation. It must not show unrelated runtime changes.
|
|
|
|
- [ ] **Step 2: Run backend tests before implementation**
|
|
|
|
Run:
|
|
|
|
```bash
|
|
npm run test -w apps/backend
|
|
```
|
|
|
|
Expected: PASS. If this fails before code changes, stop and report the baseline failure.
|
|
|
|
- [ ] **Step 3: Run backend build before implementation**
|
|
|
|
Run:
|
|
|
|
```bash
|
|
npm run build -w apps/backend
|
|
```
|
|
|
|
Expected: PASS. If this fails before code changes, stop and report the baseline failure.
|
|
|
|
---
|
|
|
|
### Task 1: Mask Unhandled 500 Errors
|
|
|
|
**Files:**
|
|
|
|
- Create: `apps/backend/src/common/filters/http-exception.filter.spec.ts`
|
|
- Modify: `apps/backend/src/common/filters/http-exception.filter.ts`
|
|
|
|
- [ ] **Step 1: Write failing tests for public error masking**
|
|
|
|
Create `apps/backend/src/common/filters/http-exception.filter.spec.ts`:
|
|
|
|
```ts
|
|
import { ArgumentsHost, BadRequestException, HttpStatus } from '@nestjs/common';
|
|
import { HttpExceptionFilter } from './http-exception.filter';
|
|
|
|
describe('HttpExceptionFilter', () => {
|
|
const createHost = () => {
|
|
const json = vi.fn();
|
|
const status = vi.fn(() => ({ json }));
|
|
const host = {
|
|
switchToHttp: () => ({
|
|
getResponse: () => ({ status }),
|
|
getRequest: () => ({ url: '/api/v1/test' }),
|
|
}),
|
|
} as unknown as ArgumentsHost;
|
|
|
|
return { host, status, json };
|
|
};
|
|
|
|
it('does not expose internal Error.message for unhandled exceptions', () => {
|
|
const filter = new HttpExceptionFilter();
|
|
const { host, status, json } = createHost();
|
|
|
|
filter.catch(new Error('Prisma failed at file:///secret/path'), host);
|
|
|
|
expect(status).toHaveBeenCalledWith(HttpStatus.INTERNAL_SERVER_ERROR);
|
|
expect(json).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
statusCode: HttpStatus.INTERNAL_SERVER_ERROR,
|
|
message: 'Internal server error',
|
|
error: 'Internal Server Error',
|
|
path: '/api/v1/test',
|
|
}),
|
|
);
|
|
expect(json.mock.calls[0][0].message).not.toContain('Prisma failed');
|
|
});
|
|
|
|
it('keeps HttpException response messages intact', () => {
|
|
const filter = new HttpExceptionFilter();
|
|
const { host, status, json } = createHost();
|
|
|
|
filter.catch(new BadRequestException('Invalid request'), host);
|
|
|
|
expect(status).toHaveBeenCalledWith(HttpStatus.BAD_REQUEST);
|
|
expect(json).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
statusCode: HttpStatus.BAD_REQUEST,
|
|
message: 'Invalid request',
|
|
error: 'Bad Request',
|
|
}),
|
|
);
|
|
});
|
|
});
|
|
```
|
|
|
|
- [ ] **Step 2: Run the new test and verify it fails**
|
|
|
|
Run:
|
|
|
|
```bash
|
|
npm run test -w apps/backend -- src/common/filters/http-exception.filter.spec.ts
|
|
```
|
|
|
|
Expected: FAIL because the current filter returns `exception.message` for non-`HttpException` errors.
|
|
|
|
- [ ] **Step 3: Implement minimal masking change**
|
|
|
|
In `apps/backend/src/common/filters/http-exception.filter.ts`, keep the initial safe defaults and remove the public assignment of `exception.message` in the non-HTTP branch:
|
|
|
|
```ts
|
|
} else if (exception instanceof Error) {
|
|
this.logger.error(`Unhandled exception: ${exception.message}`, exception.stack);
|
|
} else {
|
|
this.logger.error(`Unhandled non-error exception: ${String(exception)}`);
|
|
}
|
|
```
|
|
|
|
Do not change the `HttpException` branch.
|
|
|
|
- [ ] **Step 4: Verify the filter test passes**
|
|
|
|
Run:
|
|
|
|
```bash
|
|
npm run test -w apps/backend -- src/common/filters/http-exception.filter.spec.ts
|
|
```
|
|
|
|
Expected: PASS.
|
|
|
|
- [ ] **Step 5: Update task checklist**
|
|
|
|
In `docs/features/backend-architecture-refactor/tasks.md`, mark Task 1 complete after verification passes.
|
|
|
|
---
|
|
|
|
### Task 2: Harden Production Runtime Config
|
|
|
|
**Files:**
|
|
|
|
- Create: `apps/backend/src/config/backend-runtime-config.spec.ts`
|
|
- Modify: `apps/backend/src/config/configuration.ts`
|
|
- Modify: `apps/backend/src/main.ts`
|
|
|
|
- [ ] **Step 1: Write failing config tests**
|
|
|
|
Create `apps/backend/src/config/backend-runtime-config.spec.ts`:
|
|
|
|
```ts
|
|
describe('backend runtime configuration', () => {
|
|
const OLD_ENV = process.env;
|
|
|
|
beforeEach(() => {
|
|
vi.resetModules();
|
|
process.env = { ...OLD_ENV };
|
|
delete process.env.NODE_ENV;
|
|
delete process.env.JWT_SECRET;
|
|
delete process.env.JWT_REFRESH_SECRET;
|
|
delete process.env.BACKEND_CORS_ORIGINS;
|
|
});
|
|
|
|
afterEach(() => {
|
|
process.env = OLD_ENV;
|
|
});
|
|
|
|
it('keeps dev auth defaults outside production', async () => {
|
|
const configuration = (await import('./configuration')).default;
|
|
|
|
expect(configuration().auth).toMatchObject({
|
|
jwtSecret: 'dev-jwt-secret-change-in-production',
|
|
jwtRefreshSecret: 'dev-refresh-secret-change-in-production',
|
|
});
|
|
});
|
|
|
|
it('parses backend CORS origins from comma-separated env', async () => {
|
|
process.env.BACKEND_CORS_ORIGINS = 'https://app.example.com, http://localhost:5173 ';
|
|
const configuration = (await import('./configuration')).default;
|
|
|
|
expect(configuration().cors.origins).toEqual([
|
|
'https://app.example.com',
|
|
'http://localhost:5173',
|
|
]);
|
|
});
|
|
|
|
it('rejects production defaults for JWT secrets', async () => {
|
|
const { assertSafeProductionConfig } = await import('../main');
|
|
|
|
expect(() =>
|
|
assertSafeProductionConfig({
|
|
nodeEnv: 'production',
|
|
jwtSecret: 'dev-jwt-secret-change-in-production',
|
|
jwtRefreshSecret: 'custom-refresh-secret',
|
|
corsOrigins: ['https://app.example.com'],
|
|
}),
|
|
).toThrow('JWT_SECRET must be set to a non-default value in production');
|
|
});
|
|
|
|
it('rejects production credentialed CORS without explicit origins', async () => {
|
|
const { assertSafeProductionConfig } = await import('../main');
|
|
|
|
expect(() =>
|
|
assertSafeProductionConfig({
|
|
nodeEnv: 'production',
|
|
jwtSecret: 'custom-access-secret',
|
|
jwtRefreshSecret: 'custom-refresh-secret',
|
|
corsOrigins: [],
|
|
}),
|
|
).toThrow('BACKEND_CORS_ORIGINS must contain at least one origin in production');
|
|
});
|
|
|
|
it('allows development with reflected CORS', async () => {
|
|
const { buildCorsOrigin } = await import('../main');
|
|
|
|
expect(buildCorsOrigin('development', [])).toBe(true);
|
|
});
|
|
|
|
it('uses explicit production CORS origins', async () => {
|
|
const { buildCorsOrigin } = await import('../main');
|
|
|
|
expect(buildCorsOrigin('production', ['https://app.example.com'])).toEqual([
|
|
'https://app.example.com',
|
|
]);
|
|
});
|
|
});
|
|
```
|
|
|
|
- [ ] **Step 2: Run config tests and verify they fail**
|
|
|
|
Run:
|
|
|
|
```bash
|
|
npm run test -w apps/backend -- src/config/backend-runtime-config.spec.ts
|
|
```
|
|
|
|
Expected: FAIL because `configuration().cors` and exported bootstrap helpers do not exist yet.
|
|
|
|
- [ ] **Step 3: Add CORS origins to configuration**
|
|
|
|
In `apps/backend/src/config/configuration.ts`, add a small parser and `cors` config:
|
|
|
|
```ts
|
|
const parseCsv = (value: string | undefined): string[] =>
|
|
(value ?? '')
|
|
.split(',')
|
|
.map((item) => item.trim())
|
|
.filter(Boolean);
|
|
|
|
export default registerAs('app', () => ({
|
|
port: parseInt(process.env.PORT || '3000', 10),
|
|
// existing sections stay unchanged
|
|
cors: {
|
|
origins: parseCsv(process.env.BACKEND_CORS_ORIGINS),
|
|
},
|
|
auth: {
|
|
jwtSecret: process.env.JWT_SECRET || 'dev-jwt-secret-change-in-production',
|
|
jwtRefreshSecret: process.env.JWT_REFRESH_SECRET || 'dev-refresh-secret-change-in-production',
|
|
jwtAccessExpires: process.env.JWT_ACCESS_EXPIRES || '15m',
|
|
jwtRefreshExpires: process.env.JWT_REFRESH_EXPIRES || '7d',
|
|
},
|
|
}));
|
|
```
|
|
|
|
Keep the existing `database`, `moex`, `tbank`, and `cache` sections exactly as they are.
|
|
|
|
- [ ] **Step 4: Export bootstrap helpers from main**
|
|
|
|
In `apps/backend/src/main.ts`, add imports and helpers above `bootstrap()`:
|
|
|
|
```ts
|
|
import { ConfigService } from '@nestjs/config';
|
|
|
|
const DEV_JWT_SECRET = 'dev-jwt-secret-change-in-production';
|
|
const DEV_JWT_REFRESH_SECRET = 'dev-refresh-secret-change-in-production';
|
|
|
|
export type BackendRuntimeConfig = {
|
|
nodeEnv: string;
|
|
jwtSecret: string;
|
|
jwtRefreshSecret: string;
|
|
corsOrigins: string[];
|
|
};
|
|
|
|
export function assertSafeProductionConfig(config: BackendRuntimeConfig): void {
|
|
if (config.nodeEnv !== 'production') return;
|
|
|
|
if (!config.jwtSecret || config.jwtSecret === DEV_JWT_SECRET) {
|
|
throw new Error('JWT_SECRET must be set to a non-default value in production');
|
|
}
|
|
|
|
if (!config.jwtRefreshSecret || config.jwtRefreshSecret === DEV_JWT_REFRESH_SECRET) {
|
|
throw new Error('JWT_REFRESH_SECRET must be set to a non-default value in production');
|
|
}
|
|
|
|
if (config.corsOrigins.length === 0) {
|
|
throw new Error('BACKEND_CORS_ORIGINS must contain at least one origin in production');
|
|
}
|
|
}
|
|
|
|
export function buildCorsOrigin(nodeEnv: string, corsOrigins: string[]): boolean | string[] {
|
|
return nodeEnv === 'production' ? corsOrigins : true;
|
|
}
|
|
```
|
|
|
|
Then update `bootstrap()` after `app.use(cookieParser())`:
|
|
|
|
```ts
|
|
const configService = app.get(ConfigService);
|
|
const runtimeConfig: BackendRuntimeConfig = {
|
|
nodeEnv: process.env.NODE_ENV || 'development',
|
|
jwtSecret: configService.get<string>('app.auth.jwtSecret', ''),
|
|
jwtRefreshSecret: configService.get<string>('app.auth.jwtRefreshSecret', ''),
|
|
corsOrigins: configService.get<string[]>('app.cors.origins', []),
|
|
};
|
|
|
|
assertSafeProductionConfig(runtimeConfig);
|
|
|
|
app.enableCors({
|
|
origin: buildCorsOrigin(runtimeConfig.nodeEnv, runtimeConfig.corsOrigins),
|
|
credentials: true,
|
|
});
|
|
```
|
|
|
|
Remove the previous line:
|
|
|
|
```ts
|
|
app.enableCors({ origin: true, credentials: true });
|
|
```
|
|
|
|
- [ ] **Step 5: Prevent bootstrap from running during import tests**
|
|
|
|
At the bottom of `apps/backend/src/main.ts`, replace unconditional bootstrap with:
|
|
|
|
```ts
|
|
if (process.env.NODE_ENV !== 'test') {
|
|
void bootstrap();
|
|
}
|
|
```
|
|
|
|
This keeps helper imports from starting a Nest server in Vitest.
|
|
|
|
- [ ] **Step 6: Verify config tests pass**
|
|
|
|
Run:
|
|
|
|
```bash
|
|
npm run test -w apps/backend -- src/config/backend-runtime-config.spec.ts
|
|
```
|
|
|
|
Expected: PASS.
|
|
|
|
- [ ] **Step 7: Update task checklist**
|
|
|
|
In `docs/features/backend-architecture-refactor/tasks.md`, mark Task 2 complete after verification passes.
|
|
|
|
---
|
|
|
|
### Task 3: Harden Portfolio Position DTO Validation
|
|
|
|
**Files:**
|
|
|
|
- Create: `apps/backend/src/modules/portfolio/dto/position.dto.spec.ts`
|
|
- Modify: `apps/backend/src/modules/portfolio/dto/add-position.dto.ts`
|
|
- Modify: `apps/backend/src/modules/portfolio/dto/update-position.dto.ts`
|
|
|
|
- [ ] **Step 1: Write failing DTO validation tests**
|
|
|
|
Create `apps/backend/src/modules/portfolio/dto/position.dto.spec.ts`:
|
|
|
|
```ts
|
|
import 'reflect-metadata';
|
|
import { plainToInstance } from 'class-transformer';
|
|
import { validate } from 'class-validator';
|
|
import { AddPositionDto } from './add-position.dto';
|
|
import { UpdatePositionDto } from './update-position.dto';
|
|
|
|
describe('position DTO validation', () => {
|
|
const validateDto = async <T extends object>(cls: new () => T, payload: Record<string, unknown>) =>
|
|
validate(plainToInstance(cls, payload));
|
|
|
|
it('rejects zero quantity when adding a position', async () => {
|
|
const errors = await validateDto(AddPositionDto, { secid: 'SBER', quantity: 0 });
|
|
|
|
expect(errors.some((error) => error.property === 'quantity')).toBe(true);
|
|
});
|
|
|
|
it('rejects zero quantity when updating a position', async () => {
|
|
const errors = await validateDto(UpdatePositionDto, { quantity: 0 });
|
|
|
|
expect(errors.some((error) => error.property === 'quantity')).toBe(true);
|
|
});
|
|
|
|
it('rejects invalid buyDate values', async () => {
|
|
const addErrors = await validateDto(AddPositionDto, {
|
|
secid: 'SBER',
|
|
quantity: 1,
|
|
buyDate: 'not-a-date',
|
|
});
|
|
const updateErrors = await validateDto(UpdatePositionDto, { buyDate: 'not-a-date' });
|
|
|
|
expect(addErrors.some((error) => error.property === 'buyDate')).toBe(true);
|
|
expect(updateErrors.some((error) => error.property === 'buyDate')).toBe(true);
|
|
});
|
|
|
|
it('accepts valid position payloads', async () => {
|
|
await expect(
|
|
validateDto(AddPositionDto, { secid: 'SBER', quantity: 1, buyDate: '2026-06-01' }),
|
|
).resolves.toHaveLength(0);
|
|
await expect(
|
|
validateDto(UpdatePositionDto, { quantity: 2, buyDate: '2026-06-15' }),
|
|
).resolves.toHaveLength(0);
|
|
});
|
|
});
|
|
```
|
|
|
|
- [ ] **Step 2: Run DTO tests and verify they fail**
|
|
|
|
Run:
|
|
|
|
```bash
|
|
npm run test -w apps/backend -- src/modules/portfolio/dto/position.dto.spec.ts
|
|
```
|
|
|
|
Expected: FAIL because `quantity: 0` and arbitrary date strings are currently accepted by DTO validation.
|
|
|
|
- [ ] **Step 3: Update add-position validation**
|
|
|
|
In `apps/backend/src/modules/portfolio/dto/add-position.dto.ts`:
|
|
|
|
1. Add `IsDateString` to the import list from `class-validator`.
|
|
2. Change `@Min(0)` on `quantity` to `@Min(1)`.
|
|
3. Change `buyDate` validation from `@IsString()` to `@IsDateString()`.
|
|
|
|
The relevant fields should become:
|
|
|
|
```ts
|
|
@ApiProperty({ example: 10 })
|
|
@IsInt()
|
|
@Min(1)
|
|
quantity!: number;
|
|
|
|
@ApiPropertyOptional({ example: '2026-06-01' })
|
|
@IsDateString()
|
|
@IsOptional()
|
|
buyDate?: string;
|
|
```
|
|
|
|
- [ ] **Step 4: Update update-position validation**
|
|
|
|
In `apps/backend/src/modules/portfolio/dto/update-position.dto.ts`:
|
|
|
|
1. Add `IsDateString` to the import list from `class-validator`.
|
|
2. Change `@Min(0)` on `quantity` to `@Min(1)`.
|
|
3. Change `buyDate` validation from `@IsString()` to `@IsDateString()`.
|
|
|
|
The relevant fields should become:
|
|
|
|
```ts
|
|
@ApiPropertyOptional({ example: 15 })
|
|
@IsInt()
|
|
@Min(1)
|
|
@IsOptional()
|
|
quantity?: number;
|
|
|
|
@ApiPropertyOptional({ example: '2026-06-15' })
|
|
@IsDateString()
|
|
@IsOptional()
|
|
buyDate?: string;
|
|
```
|
|
|
|
- [ ] **Step 5: Verify DTO tests pass**
|
|
|
|
Run:
|
|
|
|
```bash
|
|
npm run test -w apps/backend -- src/modules/portfolio/dto/position.dto.spec.ts
|
|
```
|
|
|
|
Expected: PASS.
|
|
|
|
- [ ] **Step 6: Decide whether service-level zero guard stays**
|
|
|
|
Keep the existing service guard in `PortfolioService.addPosition()` for defense in depth:
|
|
|
|
```ts
|
|
if (dto.quantity === 0) throw new BadRequestException('Quantity must be greater than 0');
|
|
```
|
|
|
|
Do not add new behavior to `updatePosition()` beyond DTO validation.
|
|
|
|
- [ ] **Step 7: Update task checklist**
|
|
|
|
In `docs/features/backend-architecture-refactor/tasks.md`, mark Task 3 complete after verification passes.
|
|
|
|
---
|
|
|
|
### Task 4: Localize T-Bank gRPC `any` Casts Behind Typed Facade
|
|
|
|
**Files:**
|
|
|
|
- Modify: `apps/backend/src/modules/tbank/services/tbank-client.service.ts`
|
|
- Modify: `apps/backend/src/modules/tbank/services/tbank-client.service.spec.ts`
|
|
- Modify: `apps/backend/src/modules/tbank/services/broker-accounts.service.ts`
|
|
- Modify: `apps/backend/src/modules/tbank/services/broker-operations.service.ts`
|
|
- Modify: `apps/backend/src/modules/tbank/services/broker-portfolio.service.ts`
|
|
- Modify: `apps/backend/src/modules/tbank/services/broker-instruments.service.ts`
|
|
|
|
- [ ] **Step 1: Write failing facade tests**
|
|
|
|
In `apps/backend/src/modules/tbank/services/tbank-client.service.spec.ts`, add this test after `creates service clients from vendored proto contracts`:
|
|
|
|
```ts
|
|
it('exposes typed service-client facades for broker services', () => {
|
|
const service = new TBankClientService(config);
|
|
|
|
expect(service.getUsersClient()).toHaveProperty('getAccounts');
|
|
expect(service.getOperationsClient()).toHaveProperty('getPortfolio');
|
|
expect(service.getOperationsClient()).toHaveProperty('getPositions');
|
|
expect(service.getOperationsClient()).toHaveProperty('getOperationsByCursor');
|
|
expect(service.getInstrumentsClient()).toHaveProperty('getInstrumentBy');
|
|
});
|
|
```
|
|
|
|
- [ ] **Step 2: Run facade test and verify it fails**
|
|
|
|
Run:
|
|
|
|
```bash
|
|
npm run test -w apps/backend -- src/modules/tbank/services/tbank-client.service.spec.ts
|
|
```
|
|
|
|
Expected: FAIL because the facade methods do not exist.
|
|
|
|
- [ ] **Step 3: Add service-client types and facade methods**
|
|
|
|
In `apps/backend/src/modules/tbank/services/tbank-client.service.ts`, import T-Bank proto response types:
|
|
|
|
```ts
|
|
import type {
|
|
TBankAccountsResponse,
|
|
TBankInstrumentResponse,
|
|
TBankOperationsByCursorResponse,
|
|
TBankPortfolioResponse,
|
|
TBankPositionsResponse,
|
|
} from '../types/tbank-proto.types';
|
|
```
|
|
|
|
Add request and client types below `GrpcServiceConstructor`:
|
|
|
|
```ts
|
|
type TBankAccountsRequest = { status: string };
|
|
type TBankPortfolioRequest = { accountId: string; currency: string };
|
|
type TBankPositionsRequest = { accountId: string };
|
|
type TBankInstrumentRequest = { idType: string; id: string };
|
|
|
|
export type TBankUsersClient = Client & {
|
|
getAccounts: GrpcUnary<TBankAccountsRequest, TBankAccountsResponse>;
|
|
};
|
|
|
|
export type TBankOperationsClient = Client & {
|
|
getPortfolio: GrpcUnary<TBankPortfolioRequest, TBankPortfolioResponse>;
|
|
getPositions: GrpcUnary<TBankPositionsRequest, TBankPositionsResponse>;
|
|
getOperationsByCursor: GrpcUnary<Record<string, unknown>, TBankOperationsByCursorResponse>;
|
|
};
|
|
|
|
export type TBankInstrumentsClient = Client & {
|
|
getInstrumentBy: GrpcUnary<TBankInstrumentRequest, TBankInstrumentResponse>;
|
|
};
|
|
```
|
|
|
|
Add facade methods inside `TBankClientService` after `getServiceClient()`:
|
|
|
|
```ts
|
|
getUsersClient(): TBankUsersClient {
|
|
return this.getServiceClient('UsersService') as TBankUsersClient;
|
|
}
|
|
|
|
getOperationsClient(): TBankOperationsClient {
|
|
return this.getServiceClient('OperationsService') as TBankOperationsClient;
|
|
}
|
|
|
|
getInstrumentsClient(): TBankInstrumentsClient {
|
|
return this.getServiceClient('InstrumentsService') as TBankInstrumentsClient;
|
|
}
|
|
```
|
|
|
|
The only remaining dynamic casts for service clients should be these facade methods.
|
|
|
|
- [ ] **Step 4: Replace broker service direct casts**
|
|
|
|
Update consumers:
|
|
|
|
`apps/backend/src/modules/tbank/services/broker-accounts.service.ts`:
|
|
|
|
```ts
|
|
const usersClient = this.tbankClient.getUsersClient();
|
|
```
|
|
|
|
`apps/backend/src/modules/tbank/services/broker-operations.service.ts`:
|
|
|
|
```ts
|
|
const operationsClient = this.tbankClient.getOperationsClient();
|
|
```
|
|
|
|
`apps/backend/src/modules/tbank/services/broker-portfolio.service.ts` in both methods:
|
|
|
|
```ts
|
|
const operationsClient = this.tbankClient.getOperationsClient();
|
|
```
|
|
|
|
```ts
|
|
const operationsClient = this.tbankClient.getOperationsClient();
|
|
```
|
|
|
|
`apps/backend/src/modules/tbank/services/broker-instruments.service.ts`:
|
|
|
|
```ts
|
|
const instrumentsClient = this.tbankClient.getInstrumentsClient();
|
|
```
|
|
|
|
- [ ] **Step 5: Verify no direct service-client casts remain in broker services**
|
|
|
|
Run:
|
|
|
|
```bash
|
|
rg "getServiceClient\('.*Service'\) as any|getServiceClient\(\".*Service\"\) as any" apps/backend/src/modules/tbank/services
|
|
```
|
|
|
|
Expected: no matches.
|
|
|
|
- [ ] **Step 6: Verify T-Bank service tests pass**
|
|
|
|
Run:
|
|
|
|
```bash
|
|
npm run test -w apps/backend -- src/modules/tbank/services/tbank-client.service.spec.ts src/modules/tbank/services/broker-accounts.service.spec.ts src/modules/tbank/services/broker-operations.service.spec.ts src/modules/tbank/services/broker-portfolio.service.spec.ts
|
|
```
|
|
|
|
Expected: PASS. If mocks fail because specs stub `getServiceClient`, update those specs to stub the new facade method used by each service.
|
|
|
|
- [ ] **Step 7: Update task checklist**
|
|
|
|
In `docs/features/backend-architecture-refactor/tasks.md`, mark Task 4 complete after verification passes.
|
|
|
|
---
|
|
|
|
### Task 5: Preserve Cache `cachedAt` Metadata on Hits
|
|
|
|
**Files:**
|
|
|
|
- Create: `apps/backend/src/modules/cache/cache.service.spec.ts`
|
|
- Modify: `apps/backend/src/modules/cache/cache.service.ts`
|
|
|
|
- [ ] **Step 1: Write failing cache metadata tests**
|
|
|
|
Create `apps/backend/src/modules/cache/cache.service.spec.ts`:
|
|
|
|
```ts
|
|
import { ConfigService } from '@nestjs/config';
|
|
import { CacheService } from './cache.service';
|
|
|
|
describe('CacheService', () => {
|
|
const configService = {
|
|
get: vi.fn((_key: string, fallback?: unknown) => fallback),
|
|
} as unknown as ConfigService;
|
|
|
|
const createCache = () => ({
|
|
get: vi.fn(),
|
|
set: vi.fn(),
|
|
});
|
|
|
|
it('stores data with cachedAt metadata on cache miss', async () => {
|
|
vi.useFakeTimers();
|
|
vi.setSystemTime(new Date('2026-06-25T10:00:00.000Z'));
|
|
const cache = createCache();
|
|
cache.get.mockResolvedValue(undefined);
|
|
const service = new CacheService(cache as never, configService);
|
|
|
|
try {
|
|
const result = await service.getOrFetch('prefix', ['a'], async () => ({ value: 1 }), 'ttlKey');
|
|
|
|
expect(result).toEqual({
|
|
data: { value: 1 },
|
|
fromCache: false,
|
|
cachedAt: '2026-06-25T10:00:00.000Z',
|
|
});
|
|
expect(cache.set).toHaveBeenCalledWith(
|
|
'prefix:a',
|
|
{ data: { value: 1 }, cachedAt: '2026-06-25T10:00:00.000Z' },
|
|
900,
|
|
);
|
|
} finally {
|
|
vi.useRealTimers();
|
|
}
|
|
});
|
|
|
|
it('returns cachedAt metadata on cache hit', async () => {
|
|
const cache = createCache();
|
|
cache.get.mockResolvedValue({
|
|
data: { value: 1 },
|
|
cachedAt: '2026-06-25T10:00:00.000Z',
|
|
});
|
|
const service = new CacheService(cache as never, configService);
|
|
|
|
const result = await service.getOrFetch('prefix', ['a'], async () => ({ value: 2 }), 'ttlKey');
|
|
|
|
expect(result).toEqual({
|
|
data: { value: 1 },
|
|
fromCache: true,
|
|
cachedAt: '2026-06-25T10:00:00.000Z',
|
|
});
|
|
});
|
|
|
|
it('supports legacy raw cache values during rollout', async () => {
|
|
const cache = createCache();
|
|
cache.get.mockResolvedValue({ value: 1 });
|
|
const service = new CacheService(cache as never, configService);
|
|
|
|
const result = await service.getOrFetch('prefix', ['a'], async () => ({ value: 2 }), 'ttlKey');
|
|
|
|
expect(result).toEqual({ data: { value: 1 }, fromCache: true, cachedAt: null });
|
|
});
|
|
});
|
|
```
|
|
|
|
- [ ] **Step 2: Run cache tests and verify they fail**
|
|
|
|
Run:
|
|
|
|
```bash
|
|
npm run test -w apps/backend -- src/modules/cache/cache.service.spec.ts
|
|
```
|
|
|
|
Expected: FAIL because cache hits currently return `cachedAt: null` and misses store raw values.
|
|
|
|
- [ ] **Step 3: Implement cache entry wrapper**
|
|
|
|
In `apps/backend/src/modules/cache/cache.service.ts`, add a private type near imports:
|
|
|
|
```ts
|
|
type CacheEntry<T> = {
|
|
data: T;
|
|
cachedAt: string;
|
|
};
|
|
```
|
|
|
|
Add a type guard inside `CacheService`:
|
|
|
|
```ts
|
|
private isCacheEntry<T>(value: unknown): value is CacheEntry<T> {
|
|
return (
|
|
typeof value === 'object' &&
|
|
value !== null &&
|
|
'data' in value &&
|
|
'cachedAt' in value &&
|
|
typeof (value as { cachedAt?: unknown }).cachedAt === 'string'
|
|
);
|
|
}
|
|
```
|
|
|
|
Replace `getOrFetch()` implementation with:
|
|
|
|
```ts
|
|
async getOrFetch<T>(
|
|
keyPrefix: string,
|
|
keyParts: string[],
|
|
fetchFn: () => Promise<T>,
|
|
ttlConfigKey: string,
|
|
): Promise<{ data: T; fromCache: boolean; cachedAt: string | null }> {
|
|
const key = this.buildKey(keyPrefix, ...keyParts);
|
|
const ttl = this.configService.get<number>(`app.cache.${ttlConfigKey}`, 900);
|
|
|
|
const cached = await this.get<CacheEntry<T> | T>(key);
|
|
if (cached !== undefined) {
|
|
if (this.isCacheEntry<T>(cached)) {
|
|
return { data: cached.data, fromCache: true, cachedAt: cached.cachedAt };
|
|
}
|
|
|
|
return { data: cached as T, fromCache: true, cachedAt: null };
|
|
}
|
|
|
|
const data = await fetchFn();
|
|
const cachedAt = new Date().toISOString();
|
|
await this.set(key, { data, cachedAt }, ttl);
|
|
|
|
return { data, fromCache: false, cachedAt };
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 4: Verify cache tests pass**
|
|
|
|
Run:
|
|
|
|
```bash
|
|
npm run test -w apps/backend -- src/modules/cache/cache.service.spec.ts
|
|
```
|
|
|
|
Expected: PASS.
|
|
|
|
- [ ] **Step 5: Run service tests that use cache wrapper**
|
|
|
|
Run:
|
|
|
|
```bash
|
|
npm run test -w apps/backend -- src/modules/shares/shares.service.spec.ts src/modules/bonds/bonds.service.spec.ts src/modules/candles/candles.service.spec.ts src/modules/securities/screener.service.spec.ts src/modules/tbank/services/broker-accounts.service.spec.ts src/modules/tbank/services/broker-portfolio.service.spec.ts src/modules/tbank/services/broker-analytics.service.spec.ts
|
|
```
|
|
|
|
Expected: PASS.
|
|
|
|
- [ ] **Step 6: Update task checklist**
|
|
|
|
In `docs/features/backend-architecture-refactor/tasks.md`, mark Task 5 complete after verification passes.
|
|
|
|
---
|
|
|
|
### Task 6: Synchronize Published Backend Docs
|
|
|
|
**Files:**
|
|
|
|
- Modify: `apps/docs/docs/backend/moex-client.md`
|
|
- Modify: `apps/docs/docs/backend/modules.md`
|
|
- Modify: `apps/docs/docs/backend/api.md`
|
|
- Modify: `apps/docs/docs/backend/tbank-invest.md`
|
|
|
|
- [ ] **Step 1: Confirm stale docs signals before editing**
|
|
|
|
Run:
|
|
|
|
```bash
|
|
rg -n "MoexClientService|operations/refresh|\"status\": \"ok\"" apps/docs/docs/backend
|
|
```
|
|
|
|
Expected: matches in `moex-client.md`, `modules.md`, and `api.md`.
|
|
|
|
- [ ] **Step 2: Update MOEX client docs**
|
|
|
|
In `apps/docs/docs/backend/moex-client.md`, replace the overview and method table so the primary abstraction is:
|
|
|
|
```md
|
|
MOEX integration is split into a shared HTTP infrastructure client and focused domain clients under
|
|
`apps/backend/src/modules/moex-client/`:
|
|
|
|
- `MoexHttpClient` — request queue, rate limiting, circuit breaker, ISS JSON parsing.
|
|
- `MoexSecuritiesClient` — security search and descriptions.
|
|
- `MoexMarketDataClient` — share/bond market data and batch position enrichment.
|
|
- `MoexCandlesClient` — candle history.
|
|
- `MoexHistoryClient` — share and bond history.
|
|
- `MoexDividendsClient` — dividend calendar.
|
|
```
|
|
|
|
Keep the rate limiting, circuit breaker, response parsing, and ISS table format sections, but make them describe `MoexHttpClient` instead of removed `MoexClientService`.
|
|
|
|
- [ ] **Step 3: Update backend module docs**
|
|
|
|
In `apps/docs/docs/backend/modules.md`:
|
|
|
|
1. Replace diagram labels/references to `MoexClientService` with split clients.
|
|
2. Remove wording that says `MoexClientModule` is global if present.
|
|
3. Update health summary from raw `{ status, timestamp, uptime }` to envelope response with `checks`.
|
|
|
|
Use this health endpoint wording:
|
|
|
|
```md
|
|
- `GET /api/v1/health` → `{ data: { status, timestamp, uptime, checks }, meta }`
|
|
```
|
|
|
|
- [ ] **Step 4: Update API reference**
|
|
|
|
In `apps/docs/docs/backend/api.md`:
|
|
|
|
1. Replace the raw health response example with:
|
|
|
|
```json
|
|
{
|
|
"data": {
|
|
"status": "ok",
|
|
"timestamp": "2026-06-25T12:00:00.000Z",
|
|
"uptime": 1234.56,
|
|
"checks": [
|
|
{ "name": "database", "status": "ok" },
|
|
{ "name": "moex", "status": "ok" },
|
|
{ "name": "tbank", "status": "ok" }
|
|
]
|
|
},
|
|
"meta": { "fromCache": false, "cachedAt": null }
|
|
}
|
|
```
|
|
|
|
2. Replace `/api/v1/broker/accounts/:accountId/operations/refresh` with `/api/v1/broker/accounts/:accountId/operations/sync`.
|
|
|
|
- [ ] **Step 5: Align T-Bank docs if needed**
|
|
|
|
In `apps/docs/docs/backend/tbank-invest.md`, ensure it consistently names the sync endpoint as:
|
|
|
|
```md
|
|
POST /api/v1/broker/accounts/:accountId/operations/sync
|
|
```
|
|
|
|
Do not introduce local read-path claims; this feature does not implement local-first reads.
|
|
|
|
- [ ] **Step 6: Verify stale docs signals are gone**
|
|
|
|
Run:
|
|
|
|
```bash
|
|
rg -n "MoexClientService|operations/refresh" apps/docs/docs/backend
|
|
```
|
|
|
|
Expected: no matches.
|
|
|
|
- [ ] **Step 7: Build docs**
|
|
|
|
Run:
|
|
|
|
```bash
|
|
npm run build -w apps/docs
|
|
```
|
|
|
|
Expected: PASS.
|
|
|
|
- [ ] **Step 8: Update task checklist**
|
|
|
|
In `docs/features/backend-architecture-refactor/tasks.md`, mark Task 6 complete after verification passes.
|
|
|
|
---
|
|
|
|
### Task 7: Final Quality Gate
|
|
|
|
**Files:**
|
|
|
|
- Modify: `docs/features/backend-architecture-refactor/tasks.md`
|
|
- Modify: `docs/features/backend-architecture-refactor/spec.md` only if implementation reveals a spec ambiguity.
|
|
|
|
- [ ] **Step 1: Run backend lint**
|
|
|
|
Run:
|
|
|
|
```bash
|
|
npm run lint -w apps/backend
|
|
```
|
|
|
|
Expected: PASS.
|
|
|
|
- [ ] **Step 2: Run full backend tests**
|
|
|
|
Run:
|
|
|
|
```bash
|
|
npm run test -w apps/backend
|
|
```
|
|
|
|
Expected: PASS.
|
|
|
|
- [ ] **Step 3: Run backend build**
|
|
|
|
Run:
|
|
|
|
```bash
|
|
npm run build -w apps/backend
|
|
```
|
|
|
|
Expected: PASS.
|
|
|
|
- [ ] **Step 4: Run docs build**
|
|
|
|
Run:
|
|
|
|
```bash
|
|
npm run build -w apps/docs
|
|
```
|
|
|
|
Expected: PASS.
|
|
|
|
- [ ] **Step 5: Confirm no direct T-Bank service-client `as any` remains in production broker services**
|
|
|
|
Run:
|
|
|
|
```bash
|
|
rg -n "getServiceClient\('.*Service'\) as any|getServiceClient\(\".*Service\"\) as any" apps/backend/src/modules/tbank/services --glob '!*.spec.ts'
|
|
```
|
|
|
|
Expected: no matches.
|
|
|
|
- [ ] **Step 6: Confirm docs no longer reference stale backend contracts**
|
|
|
|
Run:
|
|
|
|
```bash
|
|
rg -n "MoexClientService|operations/refresh" apps/docs/docs/backend
|
|
```
|
|
|
|
Expected: no matches.
|
|
|
|
- [ ] **Step 7: Mark final checklist complete**
|
|
|
|
In `docs/features/backend-architecture-refactor/tasks.md`, mark Task 7 complete and add the final verification commands with PASS results.
|
|
|
|
- [ ] **Step 8: Inspect final diff**
|
|
|
|
Run:
|
|
|
|
```bash
|
|
rtk git status --short
|
|
rtk git diff
|
|
```
|
|
|
|
Expected: only files related to this SDD feature and implementation are changed.
|
|
|
|
---
|
|
|
|
## Plan Self-Review
|
|
|
|
- Spec requirement `Error masking`: covered by Task 1.
|
|
- Spec requirement `Production configuration hardening`: covered by Task 2.
|
|
- Spec requirement `DTO validation hardening`: covered by Task 3.
|
|
- Spec requirement `T-Bank gRPC typed boundary`: covered by Task 4.
|
|
- Spec requirement `Cache metadata consistency`: covered by Task 5.
|
|
- Spec requirement `Backend documentation sync`: covered by Task 6.
|
|
- Final quality gates and acceptance checks: covered by Task 7.
|
|
- Out-of-scope items are not implemented by any task.
|