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)
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
@host = http://localhost:4005
@host = https://simple-storage.vigdorov.ru
@user = test_user
@auth = 7b5da8a1-b64c-43ea-90f3-cdd3da507504
@storage_id = 67c420ab38fafe445411e76a
@auth = 6d4c2f3e-e9ae-4a57-8a10-91656e4902eb
@storage_id = 6817ac44687546864fb1f5ac
### Auth
POST {{host}}/auth HTTP/1.1
@ -28,10 +28,9 @@ Authorization: {{auth}}
{
"data": {
"users": ["ivan", "maria"],
"count": 2
"tasks": []
},
"storageName": "users"
"storageName": "tasks"
}
### Get storage
@ -46,8 +45,23 @@ Authorization: {{auth}}
{
"data": {
"users": ["ivan", "maria", "fedor"],
"count": 3
"tasks": [
{
"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 @@
{
"singleQuote": true,
"trailingComma": "all"
}
"@vigdorov/prettier-config"

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 eslint from '@eslint/js';
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'
},
},
);
import {node} from '@vigdorov/eslint-config';
export default node();

3051
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -1,6 +1,6 @@
{
"name": "simple-storage",
"version": "0.1.0",
"version": "0.1.1",
"description": "",
"author": "",
"private": true,
@ -24,11 +24,14 @@
"dependencies": {
"@nestjs/common": "^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/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",
"typeorm": "^0.3.20",
"rxjs": "^7.8.1",
"uuid": "^11.1.0"
},
@ -36,6 +39,9 @@
"@eslint/eslintrc": "^3.2.0",
"@eslint/js": "^9.18.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/testing": "^11.0.1",
"@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,
GET_STORAGES_LIST_SUCCESS,
} from './api.responses';
import { AuthRequest, StorageCreateRequest } from './schemas';
import {
AuthRequest,
StorageCreateRequest,
StorageUpdateRequest,
} from './schemas';
import { Storage, StorageCreate, StorageList, StorageUpdate } from './types';
@Controller()
@ -107,6 +111,10 @@ export class AppController {
name: 'id',
description: 'id storage',
})
@ApiBody({
type: StorageUpdateRequest,
description: 'Объект обновления storage',
})
@Header(...ALLOW_ORIGIN_ALL)
@ApiResponse(MANIPULATE_STORAGE_SUCCESS)
@ApiResponse(AUTH_ERROR)

View File

@ -1,31 +1,34 @@
import { Module } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { TypeOrmModule } from '@nestjs/typeorm';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { MongooseModule } from '@nestjs/mongoose';
import { DB_STORAGES, DB_USERS, MONGO_URL } from './consts';
import {
StorageDocument,
StorageScheme,
UserDocument,
UserScheme,
} from './schemas';
import { UserEntity } from './entities/user.entity';
import { StorageEntity } from './entities/storage.entity';
import { HealthModule } from './health/health.module';
@Module({
imports: [
MongooseModule.forRoot(`${MONGO_URL}/${DB_USERS}`, {
connectionName: DB_USERS,
ConfigModule.forRoot({
isGlobal: true,
envFilePath: ['.env.local', '.env'],
}),
MongooseModule.forRoot(`${MONGO_URL}/${DB_STORAGES}`, {
connectionName: DB_STORAGES,
TypeOrmModule.forRootAsync({
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(
[{ name: UserDocument.name, schema: UserScheme }],
DB_USERS,
),
MongooseModule.forFeature(
[{ name: StorageDocument.name, schema: StorageScheme }],
DB_STORAGES,
),
}),
TypeOrmModule.forFeature([UserEntity, StorageEntity]),
HealthModule,
],
controllers: [AppController],
providers: [AppService],

View File

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

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 { Document } from 'mongoose';
import { Storage, StorageCreate } from './types';
export class AuthRequest {
@ -8,45 +6,6 @@ export class AuthRequest {
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 {
@ApiProperty()
data: object;
@ -68,3 +27,8 @@ export class StorageCreateRequest implements StorageCreate {
@ApiProperty()
storageName: string;
}
export class StorageUpdateRequest {
@ApiProperty()
data: object;
}

View File

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

View File

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