Compare commits

...

9 Commits

Author SHA1 Message Date
2d1d625dd4 chore: migrate to @vigdorov/* shared configs (eslint, prettier, typescript)
All checks were successful
continuous-integration/drone/push Build is passing
Replace project-local ESLint, Prettier, and TypeScript configs
with shared packages from dev-configs monorepo.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 00:57:46 +03:00
3f67c79cca rm
All checks were successful
continuous-integration/drone/push Build is passing
2026-02-08 21:19:30 +03:00
c489417874 fix
Some checks failed
continuous-integration/drone/push Build is failing
2026-02-08 21:14:30 +03:00
cee79e205c fix branch name
Some checks failed
continuous-integration/drone/push Build is failing
2026-02-08 20:27:11 +03:00
43005b7d15 bim bam 2026-02-08 20:12:04 +03:00
f839d215f3 migrate to ci and postgress 2026-02-08 20:06:17 +03:00
88fcc33e87 add claude file 2026-02-05 00:01:08 +03:00
e0befd6375 0.1.1 2025-05-05 16:24:52 +03:00
1526e52d60 patch: fix schemas 2025-05-05 16:23:49 +03:00
23 changed files with 3146 additions and 400 deletions

View File

@ -1,6 +0,0 @@
node_modules
npm-debug.log
dist
.env
.git
.gitignore

41
.drone.yml Normal file
View File

@ -0,0 +1,41 @@
kind: pipeline
type: kubernetes
name: ci
steps:
- name: prepare
image: alpine:3.19
environment:
GITEA_TOKEN:
from_secret: GITEA_TOKEN
commands:
- apk add --no-cache git bash yq
- git clone --depth 1 https://token:$GITEA_TOKEN@git.vigdorov.ru/vigdorov/ci-templates.git .ci
- chmod +x .ci/scripts/*.sh
- bash .ci/scripts/prepare.sh
- name: build
image: gcr.io/kaniko-project/executor:v1.23.2-debug
depends_on: [prepare]
environment:
HARBOR_USER:
from_secret: HARBOR_USER
HARBOR_PASSWORD:
from_secret: HARBOR_PASSWORD
commands:
- /busybox/sh .ci/scripts/build.sh
- name: deploy
image: alpine:3.19
depends_on: [build]
environment:
KUBE_CONFIG:
from_secret: KUBE_CONFIG
commands:
- apk add --no-cache bash yq kubectl helm
- bash .ci/scripts/deploy.sh
trigger:
branch: [main, master, develop]
event: [push, custom]

2
.gitignore vendored
View File

@ -54,3 +54,5 @@ pids
# Diagnostic reports (https://nodejs.org/api/report.html) # Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
.serena
.claude

30
.http
View File

@ -1,8 +1,8 @@
### use REST Client plugin for VSCode https://marketplace.visualstudio.com/items?itemName=humao.rest-client ### use REST Client plugin for VSCode https://marketplace.visualstudio.com/items?itemName=humao.rest-client
@host = http://localhost:4005 @host = https://simple-storage.vigdorov.ru
@user = test_user @user = test_user
@auth = 7b5da8a1-b64c-43ea-90f3-cdd3da507504 @auth = 6d4c2f3e-e9ae-4a57-8a10-91656e4902eb
@storage_id = 67c420ab38fafe445411e76a @storage_id = 6817ac44687546864fb1f5ac
### Auth ### Auth
POST {{host}}/auth HTTP/1.1 POST {{host}}/auth HTTP/1.1
@ -28,10 +28,9 @@ Authorization: {{auth}}
{ {
"data": { "data": {
"users": ["ivan", "maria"], "tasks": []
"count": 2
}, },
"storageName": "users" "storageName": "tasks"
} }
### Get storage ### Get storage
@ -46,8 +45,23 @@ Authorization: {{auth}}
{ {
"data": { "data": {
"users": ["ivan", "maria", "fedor"], "tasks": [
"count": 3 {
"title": "task #1"
},
{
"title": "task #1"
},
{
"title": "task #1"
},
{
"title": "task #1"
},
{
"title": "task #1"
}
]
} }
} }

1
.npmrc Normal file
View File

@ -0,0 +1 @@
@vigdorov:registry=https://git.vigdorov.ru/api/packages/vigdorov/npm/

View File

@ -1,4 +1 @@
{ "@vigdorov/prettier-config"
"singleQuote": true,
"trailingComma": "all"
}

58
CLAUDE.md Normal file
View File

@ -0,0 +1,58 @@
# Simple Storage
Учебный backend-проект для обучения студентов основам NestJS.
## Назначение
Простой REST API сервис для хранения данных. Используется как демонстрационный проект в рамках обучения backend-разработке.
## Стек
- **Framework:** NestJS 11
- **Language:** TypeScript
- **Runtime:** Node.js
- **Database:** PostgreSQL (shared-db) + TypeORM
- **CI/CD:** Drone CI + ci-templates
## Структура
```
src/
├── main.ts # Точка входа
├── app.module.ts # Корневой модуль (ConfigModule + TypeORM)
├── app.controller.ts # REST контроллер
├── app.service.ts # Бизнес-логика (TypeORM Repository)
├── entities/
│ ├── user.entity.ts # Entity: users
│ └── storage.entity.ts # Entity: storages
├── health/
│ ├── health.module.ts # Health check модуль
│ └── health.controller.ts # GET /health
├── schemas.ts # Swagger DTO
├── types.ts # TypeScript типы
├── consts.ts # Константы
└── api.responses.ts # Форматы ответов API
```
## Команды
```bash
npm install # Установка зависимостей
npm run start:dev # Запуск в dev режиме
npm run build # Сборка
npm run start:prod # Production запуск
```
## Деплой
- **CI/CD:** Drone CI через ci-templates (service.yaml + .drone.yml)
- **Namespace:** backend-for-learning
- **URL:** https://simple-storage.vigdorov.ru
- **Health:** GET /health
## Локальная разработка
Переменные окружения в `.env.local`:
- `DATABASE_HOST` — хост PostgreSQL (localhost для dev через NodePort :30432)
- `DATABASE_PORT`, `DATABASE_USER`, `DATABASE_PASSWORD`, `DATABASE_NAME`

View File

@ -1,23 +0,0 @@
# Этап сборки
FROM node:18-alpine AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
# Этап продакшна
FROM node:18-alpine
WORKDIR /app
COPY --from=build /app/package*.json ./
COPY --from=build /app/dist ./dist
RUN npm ci --only=production
# Переменные окружения
ENV NODE_ENV production
# Пользователь без привилегий для безопасности
USER node
EXPOSE 3000
CMD ["node", "dist/main"]

View File

@ -1,35 +1,2 @@
// @ts-check import {node} from '@vigdorov/eslint-config';
import eslint from '@eslint/js'; export default node();
import eslintPluginPrettierRecommended from 'eslint-plugin-prettier/recommended';
import globals from 'globals';
import tseslint from 'typescript-eslint';
export default tseslint.config(
{
ignores: ['eslint.config.mjs'],
},
eslint.configs.recommended,
...tseslint.configs.recommendedTypeChecked,
eslintPluginPrettierRecommended,
{
languageOptions: {
globals: {
...globals.node,
...globals.jest,
},
ecmaVersion: 5,
sourceType: 'module',
parserOptions: {
projectService: true,
tsconfigRootDir: import.meta.dirname,
},
},
},
{
rules: {
'@typescript-eslint/no-explicit-any': 'off',
'@typescript-eslint/no-floating-promises': 'warn',
'@typescript-eslint/no-unsafe-argument': 'warn'
},
},
);

3051
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -1,6 +1,6 @@
{ {
"name": "simple-storage", "name": "simple-storage",
"version": "0.1.0", "version": "0.1.1",
"description": "", "description": "",
"author": "", "author": "",
"private": true, "private": true,
@ -24,11 +24,14 @@
"dependencies": { "dependencies": {
"@nestjs/common": "^11.0.1", "@nestjs/common": "^11.0.1",
"@nestjs/core": "^11.0.1", "@nestjs/core": "^11.0.1",
"@nestjs/mongoose": "^11.0.1", "@nestjs/config": "^4.0.0",
"@nestjs/platform-express": "^11.0.1", "@nestjs/platform-express": "^11.0.1",
"@nestjs/swagger": "^11.0.6", "@nestjs/swagger": "^11.0.6",
"mongoose": "^8.11.0", "@nestjs/terminus": "^11.0.0",
"@nestjs/typeorm": "^11.0.0",
"pg": "^8.13.1",
"reflect-metadata": "^0.2.2", "reflect-metadata": "^0.2.2",
"typeorm": "^0.3.20",
"rxjs": "^7.8.1", "rxjs": "^7.8.1",
"uuid": "^11.1.0" "uuid": "^11.1.0"
}, },
@ -36,6 +39,9 @@
"@eslint/eslintrc": "^3.2.0", "@eslint/eslintrc": "^3.2.0",
"@eslint/js": "^9.18.0", "@eslint/js": "^9.18.0",
"@nestjs/cli": "^11.0.0", "@nestjs/cli": "^11.0.0",
"@vigdorov/eslint-config": "^1.0.1",
"@vigdorov/prettier-config": "^1.0.0",
"@vigdorov/typescript-config": "^1.1.0",
"@nestjs/schematics": "^11.0.0", "@nestjs/schematics": "^11.0.0",
"@nestjs/testing": "^11.0.1", "@nestjs/testing": "^11.0.1",
"@swc/cli": "^0.6.0", "@swc/cli": "^0.6.0",

24
service.yaml Normal file
View File

@ -0,0 +1,24 @@
service:
name: simple-storage
type: api
deploy:
namespace: backend-for-learning
domain: simple-storage.vigdorov.ru
infrastructure:
postgres: true
env:
- name: DATABASE_HOST
value: "simple-storage-postgres"
- name: DATABASE_PORT
value: "5432"
- name: DATABASE_USER
value: "simple_storage_user"
- name: DATABASE_PASSWORD
value: "SimpleStorage_DB_2025"
- name: DATABASE_NAME
value: "simple_storage_db"
- name: CORS_ORIGIN
value: "*"

View File

@ -32,7 +32,11 @@ import {
MANIPULATE_STORAGE_SUCCESS, MANIPULATE_STORAGE_SUCCESS,
GET_STORAGES_LIST_SUCCESS, GET_STORAGES_LIST_SUCCESS,
} from './api.responses'; } from './api.responses';
import { AuthRequest, StorageCreateRequest } from './schemas'; import {
AuthRequest,
StorageCreateRequest,
StorageUpdateRequest,
} from './schemas';
import { Storage, StorageCreate, StorageList, StorageUpdate } from './types'; import { Storage, StorageCreate, StorageList, StorageUpdate } from './types';
@Controller() @Controller()
@ -107,6 +111,10 @@ export class AppController {
name: 'id', name: 'id',
description: 'id storage', description: 'id storage',
}) })
@ApiBody({
type: StorageUpdateRequest,
description: 'Объект обновления storage',
})
@Header(...ALLOW_ORIGIN_ALL) @Header(...ALLOW_ORIGIN_ALL)
@ApiResponse(MANIPULATE_STORAGE_SUCCESS) @ApiResponse(MANIPULATE_STORAGE_SUCCESS)
@ApiResponse(AUTH_ERROR) @ApiResponse(AUTH_ERROR)

View File

@ -1,31 +1,34 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { TypeOrmModule } from '@nestjs/typeorm';
import { AppController } from './app.controller'; import { AppController } from './app.controller';
import { AppService } from './app.service'; import { AppService } from './app.service';
import { MongooseModule } from '@nestjs/mongoose'; import { UserEntity } from './entities/user.entity';
import { DB_STORAGES, DB_USERS, MONGO_URL } from './consts'; import { StorageEntity } from './entities/storage.entity';
import { import { HealthModule } from './health/health.module';
StorageDocument,
StorageScheme,
UserDocument,
UserScheme,
} from './schemas';
@Module({ @Module({
imports: [ imports: [
MongooseModule.forRoot(`${MONGO_URL}/${DB_USERS}`, { ConfigModule.forRoot({
connectionName: DB_USERS, isGlobal: true,
envFilePath: ['.env.local', '.env'],
}), }),
MongooseModule.forRoot(`${MONGO_URL}/${DB_STORAGES}`, { TypeOrmModule.forRootAsync({
connectionName: DB_STORAGES, imports: [ConfigModule],
inject: [ConfigService],
useFactory: (configService: ConfigService) => ({
type: 'postgres',
host: configService.get('DATABASE_HOST', 'localhost'),
port: configService.get('DATABASE_PORT', 5432),
username: configService.get('DATABASE_USER', 'postgres'),
password: configService.get('DATABASE_PASSWORD', 'postgres'),
database: configService.get('DATABASE_NAME', 'simple_storage_db'),
entities: [UserEntity, StorageEntity],
synchronize: true,
}),
}), }),
MongooseModule.forFeature( TypeOrmModule.forFeature([UserEntity, StorageEntity]),
[{ name: UserDocument.name, schema: UserScheme }], HealthModule,
DB_USERS,
),
MongooseModule.forFeature(
[{ name: StorageDocument.name, schema: StorageScheme }],
DB_STORAGES,
),
], ],
controllers: [AppController], controllers: [AppController],
providers: [AppService], providers: [AppService],

View File

@ -3,9 +3,11 @@ import {
Injectable, Injectable,
NotAcceptableException, NotAcceptableException,
} from '@nestjs/common'; } from '@nestjs/common';
import { InjectModel } from '@nestjs/mongoose'; import { InjectRepository } from '@nestjs/typeorm';
import { StorageDocument, UserDocument } from './schemas'; import { Repository } from 'typeorm';
import { Model } from 'mongoose'; import { v4 } from 'uuid';
import { UserEntity } from './entities/user.entity';
import { StorageEntity } from './entities/storage.entity';
import { import {
User, User,
Storage, Storage,
@ -13,21 +15,18 @@ import {
StorageUpdate, StorageUpdate,
StorageList, StorageList,
} from './types'; } from './types';
import { v4 } from 'uuid';
import { DB_STORAGES, DB_USERS } from './consts';
@Injectable() @Injectable()
export class AppService { export class AppService {
constructor( constructor(
@InjectModel(UserDocument.name, DB_USERS) @InjectRepository(UserEntity)
private userModel: Model<UserDocument>, private userRepository: Repository<UserEntity>,
@InjectModel(StorageDocument.name, DB_STORAGES) @InjectRepository(StorageEntity)
private storageModel: Model<StorageDocument>, private storageRepository: Repository<StorageEntity>,
) {} ) {}
async checkRequest(token?: string): Promise<User> { async checkRequest(token?: string): Promise<User> {
const userList = await this.userModel.find().exec(); const searchUser = await this.userRepository.findOne({ where: { token } });
const searchUser = userList.find((user) => user.token === token);
if (searchUser) { if (searchUser) {
return { return {
login: searchUser.login, login: searchUser.login,
@ -38,37 +37,36 @@ export class AppService {
} }
async authUser(login: string): Promise<string> { async authUser(login: string): Promise<string> {
const userList = await this.userModel.find().exec(); const searchUser = await this.userRepository.findOne({ where: { login } });
const searchUser = userList.find((user) => user.login === login);
if (searchUser) { if (searchUser) {
return searchUser.token; return searchUser.token;
} }
const Model = this.userModel; const newUser = await this.userRepository.save({
const userModel = new Model({
login, login,
token: v4(), token: v4(),
}); });
const newUser = await userModel.save();
return newUser.token; return newUser.token;
} }
async getStorageList(login: string): Promise<StorageList> { async getStorageList(login: string): Promise<StorageList> {
const storageList = await this.storageModel.find().exec(); const storageList = await this.storageRepository.find({
const preparedList = storageList.map(({ _id, user, storageName }) => ({ where: { user: login },
id: _id as string, });
return storageList.map(({ id, user, storageName }) => ({
id,
user, user,
storageName, storageName,
})); }));
return preparedList.filter(({ user }) => user === login);
} }
async getStorageById(login: string, id: string): Promise<Storage> { async getStorageById(login: string, id: string): Promise<Storage> {
const searchStorage = await this.storageModel.findById(id); const searchStorage = await this.storageRepository.findOne({
where: { id },
});
if (searchStorage && searchStorage.user === login) { if (searchStorage && searchStorage.user === login) {
return { return {
data: searchStorage.data, data: searchStorage.data,
id: searchStorage._id as string, id: searchStorage.id,
storageName: searchStorage.storageName, storageName: searchStorage.storageName,
user: searchStorage.user, user: searchStorage.user,
}; };
@ -77,25 +75,19 @@ export class AppService {
} }
async addStorage(login: string, storage: StorageCreate): Promise<Storage> { async addStorage(login: string, storage: StorageCreate): Promise<Storage> {
const Model = this.storageModel; if (!storage.data || !storage.storageName) {
const storageModel = new Model({ throw new BadRequestException('data и storageName обязательны');
}
const newStorage = await this.storageRepository.save({
data: storage.data, data: storage.data,
storageName: storage.storageName, storageName: storage.storageName,
user: login, user: login,
}); });
try {
await storageModel.validate();
} catch (e) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
throw new BadRequestException(e.message);
}
const newStorage = await storageModel.save();
return { return {
data: newStorage.data, data: newStorage.data,
user: newStorage.user, user: newStorage.user,
storageName: newStorage.storageName, storageName: newStorage.storageName,
id: newStorage._id as string, id: newStorage.id,
}; };
} }
@ -104,7 +96,9 @@ export class AppService {
id: string, id: string,
storage: StorageUpdate, storage: StorageUpdate,
): Promise<Storage> { ): Promise<Storage> {
const searchStorage = await this.storageModel.findById(id); const searchStorage = await this.storageRepository.findOne({
where: { id },
});
if (!searchStorage || searchStorage.user !== login) { if (!searchStorage || searchStorage.user !== login) {
throw new BadRequestException(`Storage с id - "${id}" не найден`); throw new BadRequestException(`Storage с id - "${id}" не найден`);
@ -112,7 +106,7 @@ export class AppService {
const updatedStorageName = storage.storageName ?? searchStorage.storageName; const updatedStorageName = storage.storageName ?? searchStorage.storageName;
await searchStorage.updateOne({ await this.storageRepository.update(id, {
data: storage.data, data: storage.data,
storageName: updatedStorageName, storageName: updatedStorageName,
}); });
@ -121,24 +115,25 @@ export class AppService {
data: storage.data, data: storage.data,
user: searchStorage.user, user: searchStorage.user,
storageName: updatedStorageName, storageName: updatedStorageName,
id: searchStorage._id as string, id: searchStorage.id,
}; };
} }
async deleteStorageById(login: string, id: string): Promise<Storage> { async deleteStorageById(login: string, id: string): Promise<Storage> {
const searchStorage = await this.storageModel.findById(id); const searchStorage = await this.storageRepository.findOne({
where: { id },
});
if (!searchStorage || searchStorage.user !== login) { if (!searchStorage || searchStorage.user !== login) {
throw new BadRequestException(`Storage с id - "${id}" не найден`); throw new BadRequestException(`Storage с id - "${id}" не найден`);
} }
const Model = this.storageModel;
await Model.findByIdAndDelete(id); await this.storageRepository.delete(id);
return { return {
data: searchStorage.data, data: searchStorage.data,
user: searchStorage.user, user: searchStorage.user,
storageName: searchStorage.storageName, storageName: searchStorage.storageName,
id: searchStorage._id as string, id: searchStorage.id,
}; };
} }
} }

View File

@ -1,10 +1,3 @@
// Подключение к MongoDB
export const MONGO_URL = process.env.MONGODB_URI || 'mongodb://localhost:27017';
// Имя базы данных для пользователей и хранилищ
export const DB_USERS = process.env.DB_USERS || 'storage-back-users';
export const DB_STORAGES = process.env.DB_STORAGES || 'storage-back-storages';
// Порт приложения // Порт приложения
export const APP_PORT = parseInt(process.env.APP_PORT || '3000', 10); export const APP_PORT = parseInt(process.env.APP_PORT || '3000', 10);

View File

@ -0,0 +1,16 @@
import { Entity, PrimaryGeneratedColumn, Column } from 'typeorm';
@Entity('storages')
export class StorageEntity {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ type: 'jsonb' })
data: object;
@Column({ type: 'varchar' })
storageName: string;
@Column({ type: 'varchar' })
user: string;
}

View File

@ -0,0 +1,13 @@
import { Entity, PrimaryGeneratedColumn, Column } from 'typeorm';
@Entity('users')
export class UserEntity {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ type: 'varchar', unique: true })
login: string;
@Column({ type: 'varchar' })
token: string;
}

View File

@ -0,0 +1,20 @@
import { Controller, Get } from '@nestjs/common';
import {
HealthCheckService,
HealthCheck,
TypeOrmHealthIndicator,
} from '@nestjs/terminus';
@Controller('health')
export class HealthController {
constructor(
private health: HealthCheckService,
private db: TypeOrmHealthIndicator,
) {}
@Get()
@HealthCheck()
check() {
return this.health.check([() => this.db.pingCheck('database')]);
}
}

View File

@ -0,0 +1,9 @@
import { Module } from '@nestjs/common';
import { TerminusModule } from '@nestjs/terminus';
import { HealthController } from './health.controller';
@Module({
imports: [TerminusModule],
controllers: [HealthController],
})
export class HealthModule {}

View File

@ -1,6 +1,4 @@
import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose';
import { ApiProperty } from '@nestjs/swagger'; import { ApiProperty } from '@nestjs/swagger';
import { Document } from 'mongoose';
import { Storage, StorageCreate } from './types'; import { Storage, StorageCreate } from './types';
export class AuthRequest { export class AuthRequest {
@ -8,45 +6,6 @@ export class AuthRequest {
login: string; login: string;
} }
@Schema()
export class UserDocument extends Document {
@Prop({
type: String,
required: true,
})
login: string;
@Prop({
type: String,
required: true,
})
token: string;
}
@Schema()
export class StorageDocument extends Document {
@Prop({
type: Object,
required: true,
})
data: object;
@Prop({
type: String,
required: true,
})
storageName: string;
@Prop({
type: String,
required: true,
})
user: string;
}
export const StorageScheme = SchemaFactory.createForClass(StorageDocument);
export const UserScheme = SchemaFactory.createForClass(UserDocument);
export class StorageResponse implements Storage { export class StorageResponse implements Storage {
@ApiProperty() @ApiProperty()
data: object; data: object;
@ -68,3 +27,8 @@ export class StorageCreateRequest implements StorageCreate {
@ApiProperty() @ApiProperty()
storageName: string; storageName: string;
} }
export class StorageUpdateRequest {
@ApiProperty()
data: object;
}

View File

@ -23,5 +23,5 @@ export type StorageCreate = {
export type StorageUpdate = { export type StorageUpdate = {
data: object; data: object;
storageName?: string; storageName: string;
}; };

View File

@ -1,19 +1,10 @@
{ {
"extends": "@vigdorov/typescript-config/node",
"compilerOptions": { "compilerOptions": {
"module": "commonjs",
"declaration": true,
"removeComments": true,
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"allowSyntheticDefaultImports": true,
"target": "ES2023",
"sourceMap": true,
"outDir": "./dist", "outDir": "./dist",
"baseUrl": "./", "baseUrl": "./",
"incremental": true, "strict": false,
"skipLibCheck": true,
"strictNullChecks": true, "strictNullChecks": true,
"forceConsistentCasingInFileNames": true,
"noImplicitAny": false, "noImplicitAny": false,
"strictBindCallApply": false, "strictBindCallApply": false,
"noFallthroughCasesInSwitch": false "noFallthroughCasesInSwitch": false