feat: establish backend foundation

This commit is contained in:
2026-08-12 23:04:21 +03:00
parent f8dcfb71c1
commit ab21426478
24 changed files with 2313 additions and 382 deletions
+3
View File
@@ -0,0 +1,3 @@
DATABASE_URL="file:./prisma/dev.db"
FRONTEND_ORIGIN="http://localhost:5173"
PORT=3000
+7 -1
View File
@@ -3,6 +3,9 @@
/node_modules
/build
# generated by `npx prisma generate`
/generated
# Logs
logs
*.log
@@ -20,7 +23,7 @@ lerna-debug.log*
/.nyc_output
# IDEs and editors
/.idea
.idea
.project
.classpath
.c9/
@@ -54,3 +57,6 @@ pids
# Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
# SQLite data
prisma/*.db
+19
View File
@@ -0,0 +1,19 @@
# Backend
## Назначение
NestJS API WealthLedger. Все функциональные изменения планируются в `../specs/openspec` до реализации.
## Проверка
```bash
npm run lint
npm test
npm run build
```
## Границы
- Публичный HTTP API версионируется как `/api/v1`; OpenAPI из backend - источник контракта для frontend.
- Backend отвечает за доменную логику, данные, конфигурацию и внешние интеграции. Frontend не вызывает T-Bank напрямую; будущая интеграция остается за backend boundary.
- Не размещать frontend-код, сгенерированный клиент или постоянную документацию в этом репозитории.
+1 -1
View File
@@ -6,7 +6,7 @@ import tseslint from 'typescript-eslint';
export default tseslint.config(
{
ignores: ['eslint.config.mjs'],
ignores: ['eslint.config.mjs', 'dist/', 'generated/'],
},
eslint.configs.recommended,
...tseslint.configs.recommendedTypeChecked,
+1947 -373
View File
File diff suppressed because it is too large Load Diff
+17 -4
View File
@@ -6,23 +6,30 @@
"private": true,
"license": "UNLICENSED",
"scripts": {
"build": "nest build",
"build": "prisma generate && nest build",
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
"start": "nest start",
"start:dev": "nest start --watch",
"start:dev": "NODE_ENV=development nest start --watch",
"start:debug": "nest start --debug --watch",
"start:prod": "node dist/main",
"start:prod": "node dist/src/main.js",
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
"test": "jest",
"test:watch": "jest --watch",
"test:cov": "jest --coverage",
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
"test:e2e": "jest --config ./test/jest-e2e.json"
"test:e2e": "NODE_OPTIONS=--experimental-vm-modules jest --config ./test/jest-e2e.json"
},
"dependencies": {
"@nestjs/common": "^11.0.1",
"@nestjs/config": "^4.0.4",
"@nestjs/core": "^11.0.1",
"@nestjs/platform-express": "^11.0.1",
"@nestjs/swagger": "11.4.5",
"@prisma/adapter-better-sqlite3": "^7.9.1",
"@prisma/client": "^7.9.1",
"better-sqlite3": "^12.6.0",
"class-transformer": "^0.5.1",
"class-validator": "^0.15.1",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1"
},
@@ -42,6 +49,7 @@
"globals": "^16.0.0",
"jest": "^30.0.0",
"prettier": "^3.4.2",
"prisma": "^7.9.1",
"source-map-support": "^0.5.21",
"supertest": "^7.0.0",
"ts-jest": "^29.2.5",
@@ -51,6 +59,11 @@
"typescript": "^5.7.3",
"typescript-eslint": "^8.20.0"
},
"overrides": {
"@nestjs/swagger@11.4.5": {
"js-yaml": "5.2.3"
}
},
"jest": {
"moduleFileExtensions": [
"js",
+9
View File
@@ -0,0 +1,9 @@
import 'dotenv/config';
import { defineConfig } from 'prisma/config';
export default defineConfig({
schema: 'prisma/schema.prisma',
datasource: {
url: process.env['DATABASE_URL'],
},
});
+9
View File
@@ -0,0 +1,9 @@
generator client {
provider = "prisma-client"
output = "../generated/prisma"
moduleFormat = "cjs"
}
datasource db {
provider = "sqlite"
}
+37
View File
@@ -1,4 +1,6 @@
import { Test, TestingModule } from '@nestjs/testing';
import { INestApplication } from '@nestjs/common';
import { DocumentBuilder, OpenAPIObject, SwaggerModule } from '@nestjs/swagger';
import { AppController } from './app.controller';
import { AppService } from './app.service';
@@ -14,6 +16,41 @@ describe('AppController', () => {
appController = app.get<AppController>(AppController);
});
it('documents the safe internal error response', async () => {
const module: TestingModule = await Test.createTestingModule({
controllers: [AppController],
providers: [AppService],
}).compile();
const app: INestApplication = module.createNestApplication();
app.setGlobalPrefix('api/v1');
const document: OpenAPIObject = SwaggerModule.createDocument(
app,
new DocumentBuilder().build(),
);
expect(document.paths['/api/v1']?.get?.responses['500']).toEqual({
description: 'Internal server error',
content: {
'application/json': {
schema: { $ref: '#/components/schemas/SafeErrorResponseDto' },
},
},
});
expect(document.paths['/api/v1']?.get?.responses['200']).toEqual(
expect.objectContaining({
content: {
'application/json': {
schema: { type: 'string', example: 'Hello World!' },
},
},
}),
);
await app.close();
});
describe('root', () => {
it('should return "Hello World!"', () => {
expect(appController.getHello()).toBe('Hello World!');
+6
View File
@@ -1,4 +1,6 @@
import { Controller, Get } from '@nestjs/common';
import { ApiOkResponse } from '@nestjs/swagger';
import { ApiSafeErrorResponse } from './common/decorators/api-safe-error-response.decorator';
import { AppService } from './app.service';
@Controller()
@@ -6,6 +8,10 @@ export class AppController {
constructor(private readonly appService: AppService) {}
@Get()
@ApiOkResponse({
schema: { type: 'string', example: 'Hello World!' },
})
@ApiSafeErrorResponse()
getHello(): string {
return this.appService.getHello();
}
+10 -1
View File
@@ -1,9 +1,18 @@
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { validateEnv } from './config/env.validation';
import { PrismaModule } from './prisma/prisma.module';
@Module({
imports: [],
imports: [
ConfigModule.forRoot({
isGlobal: true,
validate: validateEnv,
}),
PrismaModule,
],
controllers: [AppController],
providers: [AppService],
})
@@ -0,0 +1,19 @@
import { ApiInternalServerErrorResponse, ApiProperty } from '@nestjs/swagger';
export class SafeErrorResponseDto {
@ApiProperty({ example: 500 })
statusCode: number;
@ApiProperty({ example: 'Internal server error' })
message: string;
@ApiProperty({ example: '/api/v1' })
path: string;
}
export function ApiSafeErrorResponse(): MethodDecorator & ClassDecorator {
return ApiInternalServerErrorResponse({
description: 'Internal server error',
type: SafeErrorResponseDto,
});
}
@@ -0,0 +1,28 @@
import { ArgumentsHost } from '@nestjs/common';
import { HttpExceptionFilter } from './http-exception.filter';
describe('HttpExceptionFilter', () => {
it('does not expose internal error details', () => {
const status = jest.fn().mockReturnThis();
const json = jest.fn();
const filter = new HttpExceptionFilter();
const host = {
switchToHttp: () => ({
getRequest: () => ({
path: '/api/v1/health',
url: '/api/v1/health?access_token=secret',
}),
getResponse: () => ({ status, json }),
}),
} as unknown as ArgumentsHost;
filter.catch(new Error('database password: secret'), host);
expect(status).toHaveBeenCalledWith(500);
expect(json).toHaveBeenCalledWith({
statusCode: 500,
message: 'Internal server error',
path: '/api/v1/health',
});
});
});
@@ -0,0 +1,27 @@
import {
ArgumentsHost,
Catch,
ExceptionFilter,
HttpException,
HttpStatus,
} from '@nestjs/common';
import { Request, Response } from 'express';
@Catch()
export class HttpExceptionFilter implements ExceptionFilter {
catch(exception: unknown, host: ArgumentsHost): void {
const context = host.switchToHttp();
const response = context.getResponse<Response>();
const request = context.getRequest<Request>();
const status =
exception instanceof HttpException
? exception.getStatus()
: HttpStatus.INTERNAL_SERVER_ERROR;
response.status(status).json({
statusCode: status,
message: status === 500 ? 'Internal server error' : 'Request failed',
path: request.path,
});
}
}
+39
View File
@@ -0,0 +1,39 @@
import { validateEnv } from './env.validation';
describe('validateEnv', () => {
it('normalizes FRONTEND_ORIGIN to its canonical origin', () => {
expect(
validateEnv({
DATABASE_URL: 'file:./data.db',
FRONTEND_ORIGIN: 'http://localhost:5173/',
}),
).toEqual({
DATABASE_URL: 'file:./data.db',
FRONTEND_ORIGIN: 'http://localhost:5173',
PORT: 3000,
});
});
it('fails fast when required configuration is missing', () => {
expect(() =>
validateEnv({ FRONTEND_ORIGIN: 'http://localhost:5173' }),
).toThrow('DATABASE_URL must be a SQLite file URL');
});
it.each([
'https://frontend.example/app',
'https://frontend.example?token=secret',
'https://frontend.example#fragment',
'ftp://frontend.example',
])(
'rejects a frontend origin that is not a canonical HTTP origin: %s',
(origin) => {
expect(() =>
validateEnv({
DATABASE_URL: 'file:./data.db',
FRONTEND_ORIGIN: origin,
}),
).toThrow('FRONTEND_ORIGIN must be a valid HTTP origin');
},
);
});
+46
View File
@@ -0,0 +1,46 @@
export interface Environment {
DATABASE_URL: string;
FRONTEND_ORIGIN: string;
PORT: number;
}
export function validateEnv(config: Record<string, unknown>): Environment {
const databaseUrl = config['DATABASE_URL'];
const frontendOrigin = config['FRONTEND_ORIGIN'];
const port = config['PORT'] ?? 3000;
if (typeof databaseUrl !== 'string' || !databaseUrl.startsWith('file:')) {
throw new Error('DATABASE_URL must be a SQLite file URL');
}
if (typeof frontendOrigin !== 'string') {
throw new Error('FRONTEND_ORIGIN must be a valid HTTP origin');
}
let parsedOrigin: URL;
try {
parsedOrigin = new URL(frontendOrigin);
} catch {
throw new Error('FRONTEND_ORIGIN must be a valid HTTP origin');
}
if (
(parsedOrigin.protocol !== 'http:' && parsedOrigin.protocol !== 'https:') ||
parsedOrigin.pathname !== '/' ||
parsedOrigin.search ||
parsedOrigin.hash
) {
throw new Error('FRONTEND_ORIGIN must be a valid HTTP origin');
}
const parsedPort = Number(port);
if (!Number.isInteger(parsedPort) || parsedPort < 1 || parsedPort > 65535) {
throw new Error('PORT must be a valid port number');
}
return {
DATABASE_URL: databaseUrl,
FRONTEND_ORIGIN: parsedOrigin.origin,
PORT: parsedPort,
};
}
+3
View File
@@ -0,0 +1,3 @@
export function shouldExposeSwagger(nodeEnv: string | undefined): boolean {
return nodeEnv === 'development';
}
+7
View File
@@ -0,0 +1,7 @@
import { shouldExposeSwagger } from './config/swagger.config';
describe('shouldExposeSwagger', () => {
it('does not expose Swagger outside development', () => {
expect(shouldExposeSwagger('test')).toBe(false);
});
});
+25 -2
View File
@@ -1,8 +1,31 @@
import { NestFactory } from '@nestjs/core';
import { ConfigService } from '@nestjs/config';
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
import { AppModule } from './app.module';
import { HttpExceptionFilter } from './common/filters/http-exception.filter';
import { Environment } from './config/env.validation';
import { shouldExposeSwagger } from './config/swagger.config';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
await app.listen(process.env.PORT ?? 3000);
const config = app.get<ConfigService<Environment, true>>(ConfigService);
const frontendOrigin = config.getOrThrow<string>('FRONTEND_ORIGIN');
app.enableCors({ origin: frontendOrigin });
app.setGlobalPrefix('api/v1');
app.useGlobalFilters(new HttpExceptionFilter());
if (shouldExposeSwagger(process.env['NODE_ENV'])) {
const document = SwaggerModule.createDocument(
app,
new DocumentBuilder()
.setTitle('WealthLedger API')
.setVersion('v1')
.build(),
);
SwaggerModule.setup('api/docs', app, document);
}
await app.listen(config.getOrThrow('PORT'));
}
bootstrap();
void bootstrap();
+9
View File
@@ -0,0 +1,9 @@
import { Global, Module } from '@nestjs/common';
import { PrismaService } from './prisma.service';
@Global()
@Module({
providers: [PrismaService],
exports: [PrismaService],
})
export class PrismaModule {}
+31
View File
@@ -0,0 +1,31 @@
import { Injectable, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { PrismaBetterSqlite3 } from '@prisma/adapter-better-sqlite3';
import { PrismaClient } from '../../generated/prisma/client';
import { Environment } from '../config/env.validation';
function getDatabaseUrl(config: ConfigService<Environment, true>): string {
return config.getOrThrow<string>('DATABASE_URL');
}
@Injectable()
export class PrismaService
extends PrismaClient
implements OnModuleInit, OnModuleDestroy
{
constructor(config: ConfigService<Environment, true>) {
super({
adapter: new PrismaBetterSqlite3({
url: getDatabaseUrl(config),
}),
});
}
onModuleInit(): Promise<void> {
return this.$connect();
}
onModuleDestroy(): Promise<void> {
return this.$disconnect();
}
}
+2
View File
@@ -0,0 +1,2 @@
process.env.DATABASE_URL = 'file:/tmp/wealth-ledger-e2e.db';
process.env.FRONTEND_ORIGIN = 'http://localhost:5173';
+4
View File
@@ -2,8 +2,12 @@
"moduleFileExtensions": ["js", "json", "ts"],
"rootDir": ".",
"testEnvironment": "node",
"setupFiles": ["<rootDir>/e2e-env.cjs"],
"testRegex": ".e2e-spec.ts$",
"transform": {
"^.+\\.(t|j)s$": "ts-jest"
},
"moduleNameMapper": {
"^(\\.{1,2}/.*)\\.js$": "$1"
}
}
+8
View File
@@ -0,0 +1,8 @@
const assert = require('node:assert/strict');
const { existsSync, readFileSync } = require('node:fs');
const packageJson = JSON.parse(readFileSync('package.json', 'utf8'));
assert.equal(packageJson.scripts['start:prod'], 'node dist/src/main.js');
assert.ok(existsSync('dist/src/main.js'));
assert.doesNotThrow(() => require('../dist/generated/prisma/client.js'));