migrate ci and postgress
Some checks failed
continuous-integration/drone/push Build is failing
continuous-integration/drone Build is failing

This commit is contained in:
2026-02-08 20:09:53 +03:00
parent 448b63a59f
commit 9d2d30e991
22 changed files with 11409 additions and 8243 deletions

View File

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

56
.drone.yml Normal file
View File

@ -0,0 +1,56 @@
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
- name: notify
image: appleboy/drone-telegram
depends_on: [deploy]
settings:
token:
from_secret: TELEGRAM_TOKEN
to:
from_secret: TELEGRAM_CHAT_ID
format: markdown
message: >
{{#success build.status}}✅{{else}}❌{{/success}} **{{repo.name}}**
Branch: `{{commit.branch}}`
{{commit.message}}
when:
status: [success, failure]
trigger:
branch: [master, develop]
event: [push, custom]

View File

@ -1,24 +0,0 @@
module.exports = {
parser: '@typescript-eslint/parser',
parserOptions: {
project: 'tsconfig.json',
sourceType: 'module',
},
plugins: ['@typescript-eslint/eslint-plugin'],
extends: [
'plugin:@typescript-eslint/eslint-recommended',
'plugin:@typescript-eslint/recommended',
'prettier',
'prettier/@typescript-eslint',
],
root: true,
env: {
node: true,
jest: true,
},
rules: {
'@typescript-eslint/interface-name-prefix': 'off',
'@typescript-eslint/explicit-function-return-type': 'off',
'@typescript-eslint/no-explicit-any': 'off',
},
};

26
.gitignore vendored
View File

@ -1,11 +1,13 @@
# compiled output # compiled output
/dist /dist
/node_modules /node_modules
/build
# Logs # Logs
logs logs
*.log *.log
npm-debug.log* npm-debug.log*
pnpm-debug.log*
yarn-debug.log* yarn-debug.log*
yarn-error.log* yarn-error.log*
lerna-debug.log* lerna-debug.log*
@ -32,3 +34,27 @@ lerna-debug.log*
!.vscode/tasks.json !.vscode/tasks.json
!.vscode/launch.json !.vscode/launch.json
!.vscode/extensions.json !.vscode/extensions.json
# dotenv environment variable files
.env
.env.development.local
.env.test.local
.env.production.local
.env.local
# temp directory
.temp
.tmp
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Diagnostic reports
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
# Tools
.serena
.claude

View File

@ -8,19 +8,27 @@ REST API для работы с изображениями. Использует
## Стек ## Стек
- **Framework:** NestJS - **Framework:** NestJS 11
- **Language:** TypeScript - **Language:** TypeScript
- **Runtime:** Node.js - **Runtime:** Node.js
- **Database:** PostgreSQL (shared-db) + TypeORM
- **CI/CD:** Drone CI + ci-templates
## Структура ## Структура
``` ```
src/ src/
├── main.ts # Точка входа ├── main.ts # Точка входа
├── app.module.ts # Корневой модуль ├── app.module.ts # Корневой модуль (ConfigModule + TypeORM)
├── app.controller.ts # REST контроллер ├── app.controller.ts # REST контроллер
├── app.service.ts # Бизнес-логика ├── app.service.ts # Бизнес-логика (TypeORM Repository)
├── schemas.ts # Валидация данных ├── entities/
│ ├── author.entity.ts # Entity: authors
│ └── image.entity.ts # Entity: images
├── health/
│ ├── health.module.ts # Health check модуль
│ └── health.controller.ts # GET /health
├── schemas.ts # Swagger DTO
├── types.ts # TypeScript типы ├── types.ts # TypeScript типы
├── consts.ts # Константы ├── consts.ts # Константы
└── api.responses.ts # Форматы ответов API └── api.responses.ts # Форматы ответов API
@ -33,12 +41,17 @@ npm install # Установка зависимостей
npm run start:dev # Запуск в dev режиме npm run start:dev # Запуск в dev режиме
npm run build # Сборка npm run build # Сборка
npm run start:prod # Production запуск npm run start:prod # Production запуск
npm run test # Тесты
npm run lint # Линтер
``` ```
## Деплой ## Деплой
- **Dockerfile:** есть - **CI/CD:** Drone CI через ci-templates (service.yaml + .drone.yml)
- **Namespace:** backend-for-learning - **Namespace:** backend-for-learning
- **URL:** https://image-list.vigdorov.ru - **URL:** https://image-list.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"]

35
eslint.config.mjs Normal file
View File

@ -0,0 +1,35 @@
// @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',
},
},
);

18749
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -1,21 +1,18 @@
{ {
"name": "image-back", "name": "image-back",
"version": "0.1.0", "version": "0.2.0",
"description": "", "description": "",
"author": "", "author": "",
"private": true, "private": true,
"license": "UNLICENSED", "license": "UNLICENSED",
"scripts": { "scripts": {
"prebuild": "rimraf dist",
"build": "nest build", "build": "nest build",
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"", "format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
"start": "nest start", "start": "nest start",
"dev": "nest start --watch", "start:dev": "nest start --watch",
"start:debug": "nest start --debug --watch", "start:debug": "nest start --debug --watch",
"start:prod": "node dist/main", "start:prod": "node dist/main",
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix", "lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
"_example_imageBuild": "docker buildx build --platform linux/amd64 -t vigdorov/image-back:0.0.1-amd64 .",
"_example_imagePush": "docker push vigdorov/image-back:0.0.1-amd64",
"test": "jest", "test": "jest",
"test:watch": "jest --watch", "test:watch": "jest --watch",
"test:cov": "jest --coverage", "test:cov": "jest --coverage",
@ -23,41 +20,45 @@
"test:e2e": "jest --config ./test/jest-e2e.json" "test:e2e": "jest --config ./test/jest-e2e.json"
}, },
"dependencies": { "dependencies": {
"@nestjs/common": "^7.0.0", "@nestjs/common": "^11.0.1",
"@nestjs/core": "^7.0.0", "@nestjs/config": "^4.0.0",
"@nestjs/mongoose": "^7.1.2", "@nestjs/core": "^11.0.1",
"@nestjs/platform-express": "^7.0.0", "@nestjs/platform-express": "^11.0.1",
"@nestjs/swagger": "^4.7.5", "@nestjs/swagger": "^11.0.6",
"mongoose": "^5.11.4", "@nestjs/terminus": "^11.0.0",
"reflect-metadata": "^0.1.13", "@nestjs/typeorm": "^11.0.0",
"rimraf": "^3.0.2", "pg": "^8.13.1",
"rxjs": "^6.5.4", "reflect-metadata": "^0.2.2",
"swagger-ui-express": "^4.1.5", "rxjs": "^7.8.1",
"uuid": "^8.3.1" "typeorm": "^0.3.20",
"uuid": "^11.1.0"
}, },
"devDependencies": { "devDependencies": {
"@nestjs/cli": "^7.0.0", "@eslint/eslintrc": "^3.2.0",
"@nestjs/schematics": "^7.0.0", "@eslint/js": "^9.18.0",
"@nestjs/testing": "^7.0.0", "@nestjs/cli": "^11.0.0",
"@types/express": "^4.17.3", "@nestjs/schematics": "^11.0.0",
"@types/jest": "25.2.3", "@nestjs/testing": "^11.0.1",
"@types/mongoose": "^5.10.2", "@swc/cli": "^0.6.0",
"@types/node": "^13.9.1", "@swc/core": "^1.10.7",
"@types/supertest": "^2.0.8", "@types/express": "^5.0.0",
"@types/uuid": "^8.3.0", "@types/jest": "^29.5.14",
"@typescript-eslint/eslint-plugin": "3.0.2", "@types/node": "^22.10.7",
"@typescript-eslint/parser": "3.0.2", "@types/supertest": "^6.0.2",
"eslint": "7.1.0", "eslint": "^9.18.0",
"eslint-config-prettier": "^6.10.0", "eslint-config-prettier": "^10.0.1",
"eslint-plugin-import": "^2.20.1", "eslint-plugin-prettier": "^5.2.2",
"jest": "26.0.1", "globals": "^15.14.0",
"prettier": "^1.19.1", "jest": "^29.7.0",
"supertest": "^4.0.2", "prettier": "^3.4.2",
"ts-jest": "26.1.0", "source-map-support": "^0.5.21",
"ts-loader": "^6.2.1", "supertest": "^7.0.0",
"ts-node": "^8.6.2", "ts-jest": "^29.2.5",
"tsconfig-paths": "^3.9.0", "ts-loader": "^9.5.2",
"typescript": "^3.7.4" "ts-node": "^10.9.2",
"tsconfig-paths": "^4.2.0",
"typescript": "^5.7.3",
"typescript-eslint": "^8.20.0"
}, },
"jest": { "jest": {
"moduleFileExtensions": [ "moduleFileExtensions": [
@ -66,10 +67,13 @@
"ts" "ts"
], ],
"rootDir": "src", "rootDir": "src",
"testRegex": ".spec.ts$", "testRegex": ".*\\.spec\\.ts$",
"transform": { "transform": {
"^.+\\.(t|j)s$": "ts-jest" "^.+\\.(t|j)s$": "ts-jest"
}, },
"collectCoverageFrom": [
"**/*.(t|j)s"
],
"coverageDirectory": "../coverage", "coverageDirectory": "../coverage",
"testEnvironment": "node" "testEnvironment": "node"
} }

24
service.yaml Normal file
View File

@ -0,0 +1,24 @@
service:
name: image-list
type: api
deploy:
namespace: backend-for-learning
domain: image-list.vigdorov.ru
infrastructure:
postgres: true
env:
- name: DATABASE_HOST
value: "image-list-postgres"
- name: DATABASE_PORT
value: "5432"
- name: DATABASE_USER
value: "image_list_user"
- name: DATABASE_PASSWORD
value: "Im@g3L1st_DB_P@ss_2025!"
- name: DATABASE_NAME
value: "image_list_db"
- name: CORS_ORIGIN
value: "*"

View File

@ -1,7 +1,7 @@
import {ApiProperty, ApiResponseOptions} from '@nestjs/swagger'; import { ApiProperty, ApiResponseOptions } from '@nestjs/swagger';
import {ImageResponse} from './schemas'; import { ImageResponse } from './schemas';
class Error { class ErrorResponse {
@ApiProperty() @ApiProperty()
statusCode: number; statusCode: number;
@ -14,22 +14,23 @@ class Error {
export const AUTH_ERROR: ApiResponseOptions = { export const AUTH_ERROR: ApiResponseOptions = {
status: 406, status: 406,
description: 'Ошибка, при попытке получить доступ к данным без токена или с не корректным токеном', description:
type: Error, 'Ошибка, при попытке получить доступ к данным без токена или с не корректным токеном',
type: ErrorResponse,
}; };
export const GET_IMAGE_LIST_SUCCESS: ApiResponseOptions = { export const GET_IMAGE_LIST_SUCCESS: ApiResponseOptions = {
status: 200, status: 200,
description: 'Список всех картинок', description: 'Список всех картинок',
type: ImageResponse, type: ImageResponse,
isArray: true isArray: true,
}; };
export const GET_USER_LIST_SUCCESS: ApiResponseOptions = { export const GET_USER_LIST_SUCCESS: ApiResponseOptions = {
status: 200, status: 200,
description: 'Список всех пользователей', description: 'Список всех пользователей',
type: String, type: String,
isArray: true isArray: true,
}; };
export const MANIPULATE_IMAGE_SUCCESS: ApiResponseOptions = { export const MANIPULATE_IMAGE_SUCCESS: ApiResponseOptions = {

View File

@ -1,16 +1,46 @@
import {Controller, Delete, Get, Header, HttpCode, Options, Post, Put, Req} from '@nestjs/common'; import {
import {AppService} from './app.service'; Controller,
import {AuthRequest, ImageCreateRequest} from './schemas'; Delete,
import {Request} from 'express'; Get,
import {ApiBody, ApiParam, ApiQuery, ApiResponse, ApiSecurity, ApiTags} from '@nestjs/swagger'; Header,
import {Image, ImageCreate} from './types'; HttpCode,
import {ALLOW_CREDENTIALS, ALLOW_HEADERS, ALLOW_METHOD, ALLOW_ORIGIN_ALL, CONTENT_LENGTH} from './consts'; Options,
import {AUTH_ERROR, AUTH_SUCCESS, GET_IMAGE_LIST_SUCCESS, GET_USER_LIST_SUCCESS, MANIPULATE_IMAGE_SUCCESS} from './api.responses'; Post,
Put,
Req,
} from '@nestjs/common';
import { Request } from 'express';
import { AppService } from './app.service';
import {
ApiBody,
ApiExcludeEndpoint,
ApiParam,
ApiQuery,
ApiResponse,
ApiSecurity,
ApiTags,
} from '@nestjs/swagger';
import { Image, ImageCreate } from './types';
import {
ALLOW_CREDENTIALS,
ALLOW_HEADERS,
ALLOW_METHOD,
ALLOW_ORIGIN_ALL,
CONTENT_LENGTH,
} from './consts';
import {
AUTH_ERROR,
AUTH_SUCCESS,
GET_IMAGE_LIST_SUCCESS,
GET_USER_LIST_SUCCESS,
MANIPULATE_IMAGE_SUCCESS,
} from './api.responses';
import { AuthRequest, ImageCreateRequest } from './schemas';
@Controller() @Controller()
@ApiTags('image-app') @ApiTags('image-app')
export class AppController { export class AppController {
constructor(private readonly appService: AppService) { } constructor(private readonly appService: AppService) {}
@Post('/auth') @Post('/auth')
@ApiBody({ @ApiBody({
@ -20,7 +50,7 @@ export class AppController {
@Header(...ALLOW_ORIGIN_ALL) @Header(...ALLOW_ORIGIN_ALL)
@ApiResponse(AUTH_SUCCESS) @ApiResponse(AUTH_SUCCESS)
authUser( authUser(
@Req() request: Request<null, {login: string}> @Req() request: Request<null, null, { login: string }>,
): Promise<string> { ): Promise<string> {
return this.appService.authUser(request.body.login); return this.appService.authUser(request.body.login);
} }
@ -30,9 +60,7 @@ export class AppController {
@Header(...ALLOW_ORIGIN_ALL) @Header(...ALLOW_ORIGIN_ALL)
@ApiResponse(GET_IMAGE_LIST_SUCCESS) @ApiResponse(GET_IMAGE_LIST_SUCCESS)
@ApiResponse(AUTH_ERROR) @ApiResponse(AUTH_ERROR)
async getImageList( async getImageList(@Req() request: Request): Promise<Image[]> {
@Req() request: Request
): Promise<Image[]> {
await this.appService.checkRequest(request.headers.authorization); await this.appService.checkRequest(request.headers.authorization);
return this.appService.getImageList(); return this.appService.getImageList();
} }
@ -48,7 +76,7 @@ export class AppController {
@ApiResponse(GET_USER_LIST_SUCCESS) @ApiResponse(GET_USER_LIST_SUCCESS)
@ApiResponse(AUTH_ERROR) @ApiResponse(AUTH_ERROR)
async getUserList( async getUserList(
@Req() request: Request<null, null, null, {login?: string}> @Req() request: Request<null, null, null, { login?: string }>,
): Promise<string[]> { ): Promise<string[]> {
await this.appService.checkRequest(request.headers.authorization); await this.appService.checkRequest(request.headers.authorization);
return this.appService.getUserList(request.query.login ?? ''); return this.appService.getUserList(request.query.login ?? '');
@ -64,7 +92,7 @@ export class AppController {
@ApiResponse(MANIPULATE_IMAGE_SUCCESS) @ApiResponse(MANIPULATE_IMAGE_SUCCESS)
@ApiResponse(AUTH_ERROR) @ApiResponse(AUTH_ERROR)
async getImageById( async getImageById(
@Req() request: Request<{id: string}> @Req() request: Request<{ id: string }>,
): Promise<Image> { ): Promise<Image> {
await this.appService.checkRequest(request.headers.authorization); await this.appService.checkRequest(request.headers.authorization);
return this.appService.getImageById(request.params.id); return this.appService.getImageById(request.params.id);
@ -80,10 +108,12 @@ export class AppController {
@ApiResponse(MANIPULATE_IMAGE_SUCCESS) @ApiResponse(MANIPULATE_IMAGE_SUCCESS)
@ApiResponse(AUTH_ERROR) @ApiResponse(AUTH_ERROR)
async createImage( async createImage(
@Req() request: Request<null, ImageCreate> @Req() request: Request<null, null, ImageCreate>,
): Promise<Image> { ): Promise<Image> {
const {login} = await this.appService.checkRequest(request.headers.authorization); const { login } = await this.appService.checkRequest(
return this.appService.addImage(login, request.body) request.headers.authorization,
);
return this.appService.addImage(login, request.body);
} }
@Put('/list/:id') @Put('/list/:id')
@ -96,9 +126,11 @@ export class AppController {
@ApiResponse(MANIPULATE_IMAGE_SUCCESS) @ApiResponse(MANIPULATE_IMAGE_SUCCESS)
@ApiResponse(AUTH_ERROR) @ApiResponse(AUTH_ERROR)
async toggleLike( async toggleLike(
@Req() request: Request<{id: string}> @Req() request: Request<{ id: string }>,
): Promise<Image> { ): Promise<Image> {
const {login} = await this.appService.checkRequest(request.headers.authorization); const { login } = await this.appService.checkRequest(
request.headers.authorization,
);
return this.appService.toggleLike(login, request.params.id); return this.appService.toggleLike(login, request.params.id);
} }
@ -112,15 +144,16 @@ export class AppController {
@ApiResponse(MANIPULATE_IMAGE_SUCCESS) @ApiResponse(MANIPULATE_IMAGE_SUCCESS)
@ApiResponse(AUTH_ERROR) @ApiResponse(AUTH_ERROR)
async deleteImage( async deleteImage(
@Req() request: Request<{id: string}> @Req() request: Request<{ id: string }>,
): Promise<Image> { ): Promise<Image> {
const {login} = await this.appService.checkRequest(request.headers.authorization); const { login } = await this.appService.checkRequest(
request.headers.authorization,
);
return this.appService.deleteImageById(login, request.params.id); return this.appService.deleteImageById(login, request.params.id);
} }
@Options([ @ApiExcludeEndpoint()
'', '/auth', '/list', '/list/:id' @Options(['', '/auth', '/list', '/list/:id'])
])
@Header(...ALLOW_ORIGIN_ALL) @Header(...ALLOW_ORIGIN_ALL)
@Header(...ALLOW_METHOD) @Header(...ALLOW_METHOD)
@Header(...ALLOW_CREDENTIALS) @Header(...ALLOW_CREDENTIALS)
@ -128,6 +161,6 @@ export class AppController {
@Header(...ALLOW_HEADERS) @Header(...ALLOW_HEADERS)
@HttpCode(204) @HttpCode(204)
async options(): Promise<string> { async options(): Promise<string> {
return ''; return await Promise.resolve('');
} }
} }

View File

@ -1,26 +1,36 @@
import {Module} from '@nestjs/common'; import { Module } from '@nestjs/common';
import {MongooseModule} from '@nestjs/mongoose/dist/mongoose.module'; import { ConfigModule, ConfigService } from '@nestjs/config';
import {AppController} from './app.controller'; import { TypeOrmModule } from '@nestjs/typeorm';
import {AppService} from './app.service'; import { AppController } from './app.controller';
import {DB_IMAGES, DB_AUTHORS, MONGO_URL} from './consts'; import { AppService } from './app.service';
import {AuthorDocument, AuthorScheme, ImageDocument, ImageScheme} from './schemas'; import { AuthorEntity } from './entities/author.entity';
import { ImageEntity } from './entities/image.entity';
import { HealthModule } from './health/health.module';
@Module({ @Module({
imports: [ imports: [
MongooseModule.forRoot(`${MONGO_URL}/${DB_AUTHORS}`, { ConfigModule.forRoot({
connectionName: DB_AUTHORS, isGlobal: true,
envFilePath: ['.env.local', '.env'],
}), }),
MongooseModule.forRoot(`${MONGO_URL}/${DB_IMAGES}`, { TypeOrmModule.forRootAsync({
connectionName: DB_IMAGES, 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', 'image_list_db'),
entities: [AuthorEntity, ImageEntity],
synchronize: true,
}), }),
MongooseModule.forFeature([ }),
{name: AuthorDocument.name, schema: AuthorScheme}, TypeOrmModule.forFeature([AuthorEntity, ImageEntity]),
], DB_AUTHORS), HealthModule,
MongooseModule.forFeature([
{name: ImageDocument.name, schema: ImageScheme},
], DB_IMAGES),
], ],
controllers: [AppController], controllers: [AppController],
providers: [AppService], providers: [AppService],
}) })
export class AppModule { } export class AppModule {}

View File

@ -1,20 +1,28 @@
import {BadRequestException, Injectable, NotAcceptableException} from '@nestjs/common'; import {
import {InjectModel} from '@nestjs/mongoose'; BadRequestException,
import {Model} from 'mongoose'; Injectable,
import {v4} from 'uuid'; NotAcceptableException,
import {AuthorDocument, ImageDocument} from './schemas'; } from '@nestjs/common';
import {Author, Image, ImageCreate} from './types'; import { InjectRepository } from '@nestjs/typeorm';
import { ILike, Repository } from 'typeorm';
import { v4 } from 'uuid';
import { AuthorEntity } from './entities/author.entity';
import { ImageEntity } from './entities/image.entity';
import { Author, Image, ImageCreate } from './types';
@Injectable() @Injectable()
export class AppService { export class AppService {
constructor( constructor(
@InjectModel(AuthorDocument.name) private authorModel: Model<AuthorDocument>, @InjectRepository(AuthorEntity)
@InjectModel(ImageDocument.name) private imageModel: Model<ImageDocument>, private authorRepository: Repository<AuthorEntity>,
) { } @InjectRepository(ImageEntity)
private imageRepository: Repository<ImageEntity>,
) {}
async checkRequest(token?: string): Promise<Author> { async checkRequest(token?: string): Promise<Author> {
const authorList = await this.authorModel.find().exec(); const searchAuthor = await this.authorRepository.findOne({
const searchAuthor = authorList.find((author) => author.token === token); where: { token },
});
if (searchAuthor) { if (searchAuthor) {
return { return {
login: searchAuthor.login, login: searchAuthor.login,
@ -25,117 +33,110 @@ export class AppService {
} }
async getImageList(): Promise<Image[]> { async getImageList(): Promise<Image[]> {
const imageList = await this.imageModel.find().exec(); const imageList = await this.imageRepository.find({
return imageList.map(({url, author, likes, id, create_at}) => ({ order: { createdAt: 'DESC' },
});
return imageList.map(({ url, author, likes, id, createdAt }) => ({
url, url,
author, author,
likes, likes,
id, id,
create_at, create_at: createdAt.toISOString(),
})); }));
} }
async getUserList(searchLogin: string): Promise<string[]> { async getUserList(searchLogin: string): Promise<string[]> {
const authorList = await this.authorModel.find().exec(); const authorList = await this.authorRepository.find({
return authorList.reduce((acc, {login}) => { where: searchLogin
if (login.includes(searchLogin)) { ? { login: ILike(`%${searchLogin}%`) }
acc.push(login); : undefined,
} });
return acc; return authorList.map(({ login }) => login);
}, []);
} }
async authUser(login: string): Promise<string> { async authUser(login: string): Promise<string> {
const authorList = await this.authorModel.find().exec(); const searchAuthor = await this.authorRepository.findOne({
const searchAuthor = authorList.find((author) => author.login === login); where: { login },
});
if (searchAuthor) { if (searchAuthor) {
return searchAuthor.token; return searchAuthor.token;
} }
const Model = await this.authorModel; const newAuthor = await this.authorRepository.save({
const userModel = new Model({
login, login,
token: v4(), token: v4(),
}); });
const newUser = await userModel.save(); return newAuthor.token;
return newUser.token;
} }
async addImage(login: string, image: ImageCreate): Promise<Image> { async addImage(login: string, image: ImageCreate): Promise<Image> {
const Model = await this.imageModel; if (!image.url) {
const imageModel = new Model({ throw new BadRequestException('url обязателен');
}
const newImage = await this.imageRepository.save({
url: image.url, url: image.url,
likes: [], likes: [],
author: login, author: login,
create_at: new Date().toISOString()
}); });
try {
await imageModel.validate();
} catch (e) {
throw new BadRequestException(e.message);
}
const newImage = await imageModel.save();
return { return {
url: newImage.url, url: newImage.url,
author: newImage.author, author: newImage.author,
likes: [], likes: newImage.likes,
id: newImage.id, id: newImage.id,
create_at: newImage.create_at, create_at: newImage.createdAt.toISOString(),
}; };
} }
async getImageById(id: string): Promise<Image> { async getImageById(id: string): Promise<Image> {
const searchImage = await this.imageModel.findById(id); const searchImage = await this.imageRepository.findOne({ where: { id } });
if (searchImage) { if (searchImage) {
return { return {
url: searchImage.url, url: searchImage.url,
author: searchImage.author, author: searchImage.author,
likes: searchImage.likes, likes: searchImage.likes,
id: searchImage.id, id: searchImage.id,
create_at: searchImage.create_at, create_at: searchImage.createdAt.toISOString(),
}; };
} }
throw new BadRequestException(`Картинка с id - "${id}" не найдена`); throw new BadRequestException(`Картинка с id - "${id}" не найдена`);
} }
async deleteImageById(login: string, id: string): Promise<Image> { async deleteImageById(login: string, id: string): Promise<Image> {
const searchImage = await this.imageModel.findById(id); const searchImage = await this.imageRepository.findOne({ where: { id } });
if (!searchImage) { if (!searchImage) {
throw new BadRequestException(`Картинка с id - "${id}" не найдена`); throw new BadRequestException(`Картинка с id - "${id}" не найдена`);
} }
if (searchImage.author !== login) { if (searchImage.author !== login) {
throw new NotAcceptableException(`Нельзя удалить чужую картинку`); throw new NotAcceptableException(`Нельзя удалить чужую картинку`);
} }
const Model = await this.imageModel;
await Model.findByIdAndDelete(id); await this.imageRepository.delete(id);
return { return {
url: searchImage.url, url: searchImage.url,
author: searchImage.author, author: searchImage.author,
likes: searchImage.likes, likes: searchImage.likes,
id: searchImage.id, id: searchImage.id,
create_at: searchImage.create_at, create_at: searchImage.createdAt.toISOString(),
}; };
} }
async toggleLike(login: string, id: string): Promise<Image> { async toggleLike(login: string, id: string): Promise<Image> {
const searchImage = await this.imageModel.findById(id); const searchImage = await this.imageRepository.findOne({ where: { id } });
if (!searchImage) { if (!searchImage) {
throw new BadRequestException(`Картинка с id - "${id}" не найдена`); throw new BadRequestException(`Картинка с id - "${id}" не найдена`);
} }
const updatedLikes = searchImage.likes.includes(login) const updatedLikes = searchImage.likes.includes(login)
? searchImage.likes.filter(userLogin => userLogin !== login) ? searchImage.likes.filter((userLogin) => userLogin !== login)
: searchImage.likes.concat(login); : searchImage.likes.concat(login);
await searchImage.updateOne({
likes: updatedLikes, await this.imageRepository.update(id, { likes: updatedLikes });
});
return { return {
url: searchImage.url, url: searchImage.url,
author: searchImage.author, author: searchImage.author,
likes: updatedLikes, likes: updatedLikes,
id: searchImage.id, id: searchImage.id,
create_at: searchImage.create_at, create_at: searchImage.createdAt.toISOString(),
}; };
} }
} }

View File

@ -1,10 +1,3 @@
// Подключение к MongoDB
export const MONGO_URL = process.env.MONGODB_URI || 'mongodb://localhost:27017';
// Имя базы данных для пользователей и хранилищ
export const DB_AUTHORS = process.env.DB_AUTHORS || 'image-back-authors';
export const DB_IMAGES = process.env.DB_IMAGES || 'image-back-images';
// Порт приложения // Порт приложения
export const APP_PORT = parseInt(process.env.APP_PORT || '3000', 10); export const APP_PORT = parseInt(process.env.APP_PORT || '3000', 10);
@ -15,9 +8,17 @@ export const APP_CONTROLLER = 'image-app';
export const ALLOW_ORIGIN_ALL: [string, string] = [ export const ALLOW_ORIGIN_ALL: [string, string] = [
'Access-Control-Allow-Origin', 'Access-Control-Allow-Origin',
process.env.CORS_ORIGIN || '*', process.env.CORS_ORIGIN || '*',
]; ];
export const ALLOW_CREDENTIALS: [string, string] = [
export const ALLOW_CREDENTIALS: [string, string] = ['Access-Control-Allow-Credentials', 'true']; 'Access-Control-Allow-Credentials',
'true',
];
export const CONTENT_LENGTH: [string, string] = ['Content-Length', '0']; export const CONTENT_LENGTH: [string, string] = ['Content-Length', '0'];
export const ALLOW_METHOD: [string, string] = ['Access-Control-Allow-Methods', 'GET,HEAD,PUT,PATCH,POST,DELETE']; export const ALLOW_METHOD: [string, string] = [
export const ALLOW_HEADERS: [string, string] = ['Access-Control-Allow-Headers', 'Version, Authorization, Content-Type, Api-Name, x-turbo-id, x-turbo-compression, chrome-proxy, chrome-proxy-ect']; 'Access-Control-Allow-Methods',
'GET,HEAD,PUT,PATCH,POST,DELETE',
];
export const ALLOW_HEADERS: [string, string] = [
'Access-Control-Allow-Headers',
'Version, Authorization, Content-Type, Api-Name, x-turbo-id, x-turbo-compression, chrome-proxy, chrome-proxy-ect',
];

View File

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

View File

@ -0,0 +1,19 @@
import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn } from 'typeorm';
@Entity('images')
export class ImageEntity {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ type: 'text' })
url: string;
@Column({ type: 'varchar' })
author: string;
@Column({ type: 'text', array: true, default: '{}' })
likes: string[];
@CreateDateColumn({ type: 'timestamp' })
createdAt: Date;
}

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

@ -25,4 +25,5 @@ async function bootstrap() {
await app.listen(APP_PORT); await app.listen(APP_PORT);
} }
bootstrap();
void bootstrap();

View File

@ -1,7 +1,5 @@
import {ApiProperty} from '@nestjs/swagger/dist/decorators'; import { ApiProperty } from '@nestjs/swagger';
import {Document} from 'mongoose'; import { Author, Image } from './types';
import {Prop, Schema, SchemaFactory} from '@nestjs/mongoose';
import {Author, Image} from './types';
export class AuthRequest { export class AuthRequest {
@ApiProperty() @ApiProperty()
@ -37,48 +35,3 @@ export class ImageResponse implements Image {
@ApiProperty() @ApiProperty()
create_at: string; create_at: string;
} }
@Schema()
export class AuthorDocument extends Document {
@Prop({
type: String,
required: true,
})
login: string;
@Prop({
type: String,
required: true,
})
token: string;
}
@Schema()
export class ImageDocument extends Document {
@Prop({
type: String,
required: true,
})
url: string;
@Prop({
type: String,
required: true,
})
author: string;
@Prop({
type: [String],
required: true,
})
likes: string[];
@Prop({
type: String,
required: true,
})
create_at: string;
}
export const ImageScheme = SchemaFactory.createForClass(ImageDocument);
export const AuthorScheme = SchemaFactory.createForClass(AuthorDocument);

View File

@ -6,11 +6,16 @@
"emitDecoratorMetadata": true, "emitDecoratorMetadata": true,
"experimentalDecorators": true, "experimentalDecorators": true,
"allowSyntheticDefaultImports": true, "allowSyntheticDefaultImports": true,
"target": "es2017", "target": "ES2023",
"sourceMap": true, "sourceMap": true,
"outDir": "./dist", "outDir": "./dist",
"baseUrl": "./", "baseUrl": "./",
"incremental": true, "incremental": true,
"skipLibCheck": true "skipLibCheck": true,
"strictNullChecks": true,
"forceConsistentCasingInFileNames": true,
"noImplicitAny": false,
"strictBindCallApply": false,
"noFallthroughCasesInSwitch": false
} }
} }