This commit is contained in:
leo 2024-11-20 09:53:03 +05:00
commit edb8978fdc
100 changed files with 17627 additions and 0 deletions

195
API.rest Executable file
View File

@ -0,0 +1,195 @@
// STOCKS
// Create Product:
POST http://localhost/stocks/api/product/create
content-Type: application/json
{
"plu": 11297,
"name": "Apple"
}
###
// Create Shop:
POST http://localhost/stocks/api/shop/create
content-Type: application/json
{
"name": "Магнит"
}
###
// Create Stocks:
POST http://localhost/stocks/api/stocks/create
content-Type: application/json
{
"plu": 1111,
"shop_id": 4,
"in_stock": 3789,
"in_order": 5000
}
###
// Increase Stocks by plu/shop_id:
PATCH http://localhost/stocks/api/stocks/increase
content-Type: application/json
{
//"id": 5,
"plu": "1111",
"shop_id": 1,
"increase_shelf": 12,
"increase_order": 15
}
###
// [Optional] Increase Stocks by id:
PATCH http://localhost/stocks/api/stocks/increase
content-Type: application/json
{
"id": 2,
"increase_shelf": 12,
"increase_order": 15
}
###
// Decrease Stocks by plu/shop_id:
PATCH http://localhost/stocks/api/stocks/decrease
content-Type: application/json
{
// "id": 5, // Optional OR:
"plu": 1112,
"shop_id": 3,
"decrease_shelf": 12,
"decrease_order": 15
}
###
// [Optional] Decrease Stocks by id:
PATCH http://localhost/stocks/api/stocks/decrease
content-Type: application/json
{
"id": 1,
"decrease_shelf": 12,
"decrease_order": 15
}
###
// Get Stocks:
// plu=1111
// shop_id=1
// shelf_from=0
// shelf_to=3800
// order_from=0
// order_to=10000
// page=1
// page_size=2
// [ALL PARAMS]:
GET http://localhost/stocks/api/stocks?plu=1111&shop_id=1&shelf_from=0&shelf_to=3800&order_from=0&order_to=10000&page=1&page_size=2
###
// [SANDBOX]:
GET http://localhost/stocks/api/stocks?shop_id=1&page=1&page_size=1
###
// Get Products:
// plu=1111
// name=appl
// page=1
// page_size=2
// [ALL PARAMS]:
GET http://localhost/stocks/api/products?plu=&name=ябл&page=1&page_size=2
###
//===============================================================================================================================
// Service 2
// Get History:
// plu=1111
// shop_id=1
// action=decreaseStocks
// date_from=1731906196 (Unix Timestamp)
// date_to=1731906196
// page=1
// page_size=3
// [ALL PARAMS]:
GET http://localhost/history/api/history?plu=1111&shop_id=1&action=create&date_from=105653336&date_to=2034634345&page=1&page_size=10
###
// [ALL]:
GET http://localhost/history/api/history?page=1&page_size=10
###
// [SANDBOX]:
GET http://localhost/history/api/history?&page=1&page_size=30&date_to=1731906190
###
//===============================================================================================================================
// Users
// Create User:
POST http://localhost/users/api/users/create
content-Type: application/json
{
"firstName": "Иван",
"lastName": "Иванов",
"birthday": "2006-11-19 19:00:00.000 +00:00",
"sex": 1,
"problems": true
}
###
// Delete All Users
DELETE http://localhost/users/api/users/delete-all
###
// Create All Users
POST http://localhost/users/api/users/create-all
content-Type: application/json
{
"count": 1000000
}
###
// Get Users
GET http://localhost/users/api/users?page=1&pageSize=10
###
// Reset Problems:
PATCH http://localhost/users/api/users/reset-problems
###
POST http://localhost/users/api/rand-problems
###

1
LICENSE.md Executable file
View File

@ -0,0 +1 @@
Free

40
README.md Executable file
View File

@ -0,0 +1,40 @@
### Тестовое задание
Репозиторий: `em-shop`
Исполнитель: `Leonid Ost`
Email: `4267@mail.ru`
Telegram: `@leo4267`
Site: `checkerwars.com`
Описание всех API находится в файле `API.rest`
### Stocks
Path API `http://localhost/stocks/api/`
### History
Path API `http://localhost/history/api/`
### User Swagger
Path API `http://localhost/users/api/`
Swagger: `http://localhost/users/api/docs/`
### Traefik dashboard
Path: `http://localhost/dashboard/`
User: admin
Password: admin
### RabbitMQ dashboard
Path: `http://localhost:15672/`
User: rabbit
Password: 5jbya3ptfrezyop6gy8w
# PostgreSQL
Host: postgres
Port: 5432
User: postgres
DB: postgres
Password: 2wroxrnr8fdxicvw2nsd

5
history/.dockerignore Executable file
View File

@ -0,0 +1,5 @@
/node_modules
docker-compose.yml
docker-compose.dev.yml
run.dev.sh
run.prod.sh

18
history/.gitignore vendored Executable file
View File

@ -0,0 +1,18 @@
/node_modules
# IDEs and editors
/.idea
.project
.classpath
.c9/
*.launch
.settings/
*.sublime-workspace
# IDE - VSCode
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json

23
history/Dockerfile Executable file
View File

@ -0,0 +1,23 @@
# develop stage
FROM node:22.11.0-alpine3.20 AS develop-stage
WORKDIR /app
COPY package*.json ./
RUN npm config set fund false --location=global
COPY . .
# build stage
FROM develop-stage AS build-stage
RUN npm install
RUN npm run build
# production stage
FROM node:22.11.0-alpine3.20 AS production-stage
WORKDIR /app
COPY --from=build-stage /app/node_modules /app/node_modules
COPY --from=build-stage /app/dist /app/dist
COPY package.json ./
ENV NODE_PATH=./node_modules
CMD ["node", "dist/index.js"]
EXPOSE 3002

115
history/README.md Executable file
View File

@ -0,0 +1,115 @@
### History
Backend history v0.1
Repository name: `em-shop`
--------------------------------------------------------------------
*cheat sheet:*
```sh
# Запуск сервера в production mode:
sudo bash run.prod.sh
sudo docker compose up -d --build --force-recreate
# Запуск сервера в development mode:
sudo bash run.dev.sh
sudo docker compose -f docker-compose.dev.yml up --build --force-recreate
# Подключение к консоли контейнера:
sudo docker exec -it history sh
sudo docker exec -it history-dev sh
sudo docker start history
sudo docker stop history
sudo docker start history-dev
sudo docker stop history-dev
```
--------------------------------------------------------------------
#### Общие сведения
Для успешного запуска и функционирования требуются следующий файл конфигурации:
```yml
env_file:
- ./config.env
```
В `config.env` указываюся переменные:
(Используйте эти значения только для разработки)
```conf
PORT=3002
URL=/history/api
POSTGRES_HOST=postgres
POSTGRES_PORT=5432
POSTGRES_USER=postgres
POSTGRES_DB=postgres
POSTGRES_PASSWORD=2wroxrnr8fdxicvw2nsd
RABBITMQ_HOST=rabbitmq
RABBITMQ_PORT=5672
RABBITMQ_USER=rabbit
RABBITMQ_PASSWORD=5jbya3ptfrezyop6gy8w
RABBITMQ_VHOST=vrabbit
```
#### Production mode:
Режим запуска сервера по умолчанию.
Конфигурация описана в `docker-compose.yml`
Запуск сервера осуществляется простой командой:
`docker compose up -d`
После изменений в образе, запуск производится с пересборкой образа:
`docker compose up -d --build --force-recreate`
Для быстрого удобного запуска предусмотрен `скрипт run.prod.sh`, который выполняет команду выше.
*Пояснения:*
Запуск производится по средствам выполнения в образе команды:
`command: ["node", "src/index.js"]`
#### Development mode:
Запуск сервера в режиме разработки. Требуется указать соответствующий образ:
`docker compose -f docker-compose.dev.yml up -d`
После изменений в образе, запуск производится с пересборкой образа:
`docker compose -f docker-compose.dev.yml up -d --build --force-recreate`
Для быстрого удобного запуска предусмотрен скрипт `run.dev.sh`, который выполняет команду выше.
*Пояснения:*
Запуск производится по средствам выполнения в образе команды:
`command: sh -c "npm install && npm run start:dev"`
`start:dev` описана в `package.json` и содержит следующую команду:
`nodemon ./src/index.js`
причем, перед запуском сервера выполняется установка `npm install`, что необходимо для корректного прозрачного отображения папки
`node_modules` в каталоге, что бы в процессе разработки у IDE был доступ к этой папке для корректного анализа,
и подсветки кода в IDE.
Так же, весь каталог с репозиторием монтируется в режиме записи в как корневой `/app` в Docker контейнере:
```yml
volumes:
- .:/app:rw
```
что необходимо для внесения изменений в код без пересборки контейнера.
В `profuction` режиме сборка осуществляется обычно, `node_modules` и все файлы находятся внутри образа, никаких дополнительных команд, volumes, не используется.

14
history/config.env Executable file
View File

@ -0,0 +1,14 @@
PORT=3002
URL=/history/api
POSTGRES_HOST=postgres
POSTGRES_PORT=5432
POSTGRES_USER=postgres
POSTGRES_DB=postgres
POSTGRES_PASSWORD=2wroxrnr8fdxicvw2nsd
RABBITMQ_HOST=rabbitmq
RABBITMQ_PORT=5672
RABBITMQ_USER=rabbit
RABBITMQ_PASSWORD=5jbya3ptfrezyop6gy8w
RABBITMQ_VHOST=vrabbit

30
history/docker-compose.dev.yml Executable file
View File

@ -0,0 +1,30 @@
services:
history-dev:
container_name: history-dev
build:
context: .
target: 'develop-stage'
env_file:
- ./config.env
environment:
NODE_ENV: development
volumes:
- .:/app:rw
command: sh -c "npm install && npm run start:dev"
restart: unless-stopped
networks:
- proxynet
labels:
- "traefik.enable=true"
- "traefik.http.routers.apihistory.rule=PathPrefix(`/history/api`)"
- "traefik.http.routers.apihistory.entrypoints=http"
- "traefik.http.routers.apihistory.service=apihistory-service"
- "traefik.http.services.apihistory-service.loadbalancer.server.port=3002"
logging:
driver: "json-file"
options:
max-size: "1m"
networks:
proxynet:
external: true

28
history/docker-compose.yml Executable file
View File

@ -0,0 +1,28 @@
services:
history:
container_name: history
build:
context: .
target: 'production-stage'
env_file:
- ./config.env
environment:
NODE_ENV: production
command: ["node", "dist/index.js"]
restart: unless-stopped
networks:
- proxynet
labels:
- "traefik.enable=true"
- "traefik.http.routers.apihistory.rule=PathPrefix(`/history/api`)"
- "traefik.http.routers.apihistory.entrypoints=http"
- "traefik.http.routers.apihistory.service=apihistory-service"
- "traefik.http.services.apihistory-service.loadbalancer.server.port=3002"
logging:
driver: "json-file"
options:
max-size: "1m"
networks:
proxynet:
external: true

2016
history/package-lock.json generated Executable file

File diff suppressed because it is too large Load Diff

31
history/package.json Executable file
View File

@ -0,0 +1,31 @@
{
"name": "history",
"version": "0.0.1",
"description": "History api server",
"main": "index.ts",
"scripts": {
"start": "ts-node ./src/index.ts",
"start:dev": "nodemon --exec ts-node ./src/index.ts",
"build": "tsc"
},
"author": "Leonid",
"license": "Free",
"dependencies": {
"amqplib": "^0.10.4",
"cors": "^2.8.5",
"dotenv": "^16.4.5",
"express": "^4.21.1",
"express-validator": "^7.2.0",
"pg": "^8.13.1",
"sequelize": "^6.37.5",
"typescript": "^5.6.3"
},
"devDependencies": {
"nodemon": "^3.1.7",
"typescript": "^5.6.3",
"@types/express": "^5.0.0",
"@types/node": "^22.9.0",
"ts-node": "^10.9.2",
"@types/sequelize": "^4.28.20"
}
}

8
history/run.dev.sh Executable file
View File

@ -0,0 +1,8 @@
#!/bin/bash
if [ "$(id -u)" != "0" ]; then
echo -e "\033[31mThis script requires superuser rights.\033[0m"
exit 0
fi
sudo docker compose -f docker-compose.dev.yml up --build --force-recreate

8
history/run.prod.sh Executable file
View File

@ -0,0 +1,8 @@
#!/bin/bash
if [ "$(id -u)" != "0" ]; then
echo -e "\033[31mThis script requires superuser rights.\033[0m"
exit 0
fi
sudo docker compose up -d --build --force-recreate

View File

@ -0,0 +1,33 @@
import historyService from '../service/history.service';
import { validationResult } from 'express-validator';
import ApiError from '../exceptions/api.error';
import { Request, Response, NextFunction } from 'express';
class HistoryController {
async getHistory(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return next(ApiError.BadRequest('Validation error', errors.array()));
}
const { shop_id, plu, action, date_from, date_to, page, page_size } = req.query;
const history = await historyService.getHistory({
shopId: shop_id ? parseInt(shop_id as string) : null,
plu: plu ? parseInt(plu as string) : null,
action: action ? action as string : null,
dateFrom: date_from ? parseInt(date_from as string) : null,
dateTo: date_to ? parseInt(date_to as string) : null,
page: parseInt(page as string),
pageSize: parseInt(page_size as string)
});
res.json(history);
} catch (e) {
next(e);
}
}
}
export default new HistoryController();

12
history/src/db.ts Executable file
View File

@ -0,0 +1,12 @@
import { Sequelize } from 'sequelize';
const sequelize = new Sequelize({
dialect: 'postgres',
host: process.env.POSTGRES_HOST,
port: Number(process.env.POSTGRES_PORT),
username: process.env.POSTGRES_USER,
database: process.env.POSTGRES_DB,
password: process.env.POSTGRES_PASSWORD
});
export default sequelize;

View File

@ -0,0 +1,20 @@
class ApiError extends Error {
status: number;
errors: any[];
constructor(status: number, message: string, errors = []) {
super(message);
this.status = status;
this.errors = errors;
}
static UnauthorizedError() {
return new ApiError(401, 'Пользователь не авторизован')
}
static BadRequest(message: string, errors = []) {
return new ApiError(400, message, errors);
}
}
export default ApiError;

56
history/src/index.ts Executable file
View File

@ -0,0 +1,56 @@
// History v0.1
import http from 'http';
import express from 'express';
import sequelize from './db';
import rabbitMqService from './rabbitmq';
import router from './router/router';
import errorMiddleware from './middlewares/error.middleware';
import historyService from './service/history.service';
import { ConsumeMessage } from 'amqplib';
const PORT = Number(process.env.PORT) || 3000;
const app = express();
const server = http.createServer(app);
app.use(express.json());
app.use(process.env.URL, router);
app.use(errorMiddleware);
const start = async () => {
try {
await sequelize.authenticate();
console.log('Connection has been established successfully');
await sequelize.sync().then(() => {
console.log('Database and tables created!');
});
await rabbitMqService.connectRabbitMq('logs');
const channel = await rabbitMqService.getChannel();
await channel.consume(
'logs', async (msg: ConsumeMessage) => {
try {
const logEntry = JSON.parse(msg.content.toString());
await historyService.createHistory(
logEntry.action,
logEntry.shop_id,
logEntry.plu,
logEntry.oldData,
logEntry.newData
);
channel.ack(msg);
} catch (error) {
console.error('Error processing message:', error);
}
},
{ noAck: false }
);
server.listen(PORT, () => console.log(`Server started on PORT = ${PORT}`));
} catch (e) {
console.log(e);
}
}
start();

View File

@ -0,0 +1,15 @@
import ApiError from '../exceptions/api.error'
import { Request, Response, NextFunction } from 'express';
export default function (req: Request, res: Response, next: NextFunction) {
try {
if (false) {
return next(ApiError.UnauthorizedError());
}
next();
} catch (e) {
return next(ApiError.UnauthorizedError());
}
};

View File

@ -0,0 +1,10 @@
import ApiError from '../exceptions/api.error';
import { Request, Response, NextFunction } from 'express';
export default function (err: Error, req: Request, res: Response, next: NextFunction) {
console.log(err);
if (err instanceof ApiError) {
res.status(err.status).json({ message: err.message, errors: err.errors });
}
res.status(500).json({message: 'Ошибка сервера'});
};

View File

@ -0,0 +1,34 @@
import { Sequelize, DataTypes, Model, Optional } from 'sequelize';
import sequelize from '../db';
interface HistoryAttributes {
id: number;
action: string;
shop_id: number | null;
plu: number | null;
old_data: JSON | null;
new_data: JSON | null;
}
class History extends Model<HistoryAttributes> implements HistoryAttributes {
public id: number;
public action: string;
public shop_id: number | null;
public plu: number | null;
public old_data: JSON | null;
public new_data: JSON | null;
}
History.init({
id: { type: DataTypes.INTEGER, autoIncrement: true, primaryKey: true },
action: { type: DataTypes.STRING, defaultValue: 'undefined' },
shop_id: { type: DataTypes.INTEGER, defaultValue: null },
plu: { type: DataTypes.INTEGER, defaultValue: null },
old_data: { type: DataTypes.JSON, defaultValue: null },
new_data: { type: DataTypes.JSON, defaultValue: null }
}, {
sequelize,
modelName: 'history',
});
export default History;

41
history/src/rabbitmq.ts Executable file
View File

@ -0,0 +1,41 @@
import amqp, { Channel, Connection } from "amqplib";
class RabbitMqService {
private channel: Channel | null = null;
private queue: string | null = null;
public async connectRabbitMq(queue: string): Promise<void> {
try {
const connection: Connection = await amqp.connect({
protocol: 'amqp',
hostname: process.env.RABBITMQ_HOST as string,
port: Number(process.env.RABBITMQ_PORT),
username: process.env.RABBITMQ_USER as string,
password: process.env.RABBITMQ_PASSWORD as string,
vhost: process.env.RABBITMQ_VHOST as string,
});
this.channel = await connection.createChannel();
await this.channel.assertQueue(queue);
this.queue = queue;
console.log("RabbitMQ connected");
} catch (err) {
console.error("Error connecting to RabbitMQ", err);
throw err;
}
}
public getChannel(): Channel | null {
return this.channel;
}
public sendToQueue(data: any): void {
if (!this.channel || !this.queue) {
throw new Error("Channel or queue not initialized.");
}
this.channel.sendToQueue(this.queue, Buffer.from(JSON.stringify(data)));
}
}
const rabbitMqService = new RabbitMqService();
export default rabbitMqService;

15
history/src/router/router.ts Executable file
View File

@ -0,0 +1,15 @@
import { Router } from 'express';
import historyController from '../controllers/history.controller';
import authMiddleware from '../middlewares/auth.middleware';
import { getHistoryDataValidate } from './validators';
const router = Router();
router.get(
'/history',
authMiddleware,
getHistoryDataValidate,
historyController.getHistory
);
export default router;

View File

@ -0,0 +1,26 @@
import { query } from 'express-validator';
// history:
export const getHistoryDataValidate = [
query('shop_id')
.toFloat()
.default(null),
query('plu')
.toFloat()
.default(null),
query('action')
.default(null),
query('date_from')
.toFloat()
.default(null),
query('date_to')
.toFloat()
.default(null),
query('page')
.toFloat()
.default(1),
query('page_size')
.toFloat()
.default(10),
];

View File

@ -0,0 +1,67 @@
import HistoryModel from '../models/history.model';
import { Op } from 'sequelize';
interface GetHistoryParams {
shopId?: number | null;
plu?: number | null;
action?: string | null;
dateFrom?: number | null;
dateTo?: number | null;
page?: number;
pageSize?: number;
}
interface HistoryResponse {
history: HistoryModel[];
page: number;
pageSize: number;
totalPages: number;
totalHistory: number;
}
class HistoryService {
async createHistory(action: string, shop_id: number, plu: number, old_data: JSON, new_data: JSON): Promise<HistoryModel> {
const history = await HistoryModel.create({ action, shop_id, plu, old_data, new_data });
return history;
}
async getHistory({
shopId = null,
plu = null,
action = null,
dateFrom = null,
dateTo = null,
page = 1,
pageSize = 10
}: GetHistoryParams): Promise<HistoryResponse> {
const offset = (page - 1) * pageSize;
const limit = pageSize;
const where: any = {};
if (dateFrom) {
const formattedDateFrom = new Date(dateFrom * 1000).toISOString();
where.createdAt = { ...where.createdAt, [Op.gt]: formattedDateFrom };
}
if (dateTo) {
const formattedDateTo = new Date(dateTo * 1000).toISOString();
where.createdAt = { ...where.createdAt, [Op.lt]: formattedDateTo };
}
if (shopId) where.shop_id = shopId;
if (plu) where.plu = plu;
if (action) where.action = action;
const history = await HistoryModel.findAndCountAll({ limit, offset, where });
const totalPages = Math.ceil(history.count / pageSize);
return {
history: history.rows,
page,
pageSize,
totalPages,
totalHistory: history.count
};
}
}
export default new HistoryService();

18
history/tsconfig.json Executable file
View File

@ -0,0 +1,18 @@
{
"compilerOptions": {
"module": "commonjs",
"declaration": true,
"removeComments": true,
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"allowSyntheticDefaultImports": true,
"target": "ES2021",
"sourceMap": true,
"outDir": "./dist",
"baseUrl": "./",
"incremental": true,
"forceConsistentCasingInFileNames": true,
"skipLibCheck": true,
"esModuleInterop": true
}
}

2
postgres/.gitignore vendored Executable file
View File

@ -0,0 +1,2 @@
/pgdata

8
postgres/README.md Executable file
View File

@ -0,0 +1,8 @@
### Postgres
*docker commands*
Start: `sudo docker start postgres`
`sudo docker compose up -d`
Stop: `sudo docker stop postgres`

6
postgres/config.env Executable file
View File

@ -0,0 +1,6 @@
POSTGRES_HOST: postgres
POSTGRES_PORT: 5432
POSTGRES_USER=postgres
POSTGRES_DB=postgres
POSTGRES_PASSWORD=2wroxrnr8fdxicvw2nsd

21
postgres/docker-compose.yml Executable file
View File

@ -0,0 +1,21 @@
services:
postgres:
container_name: postgres
image: postgres:16.4-alpine3.20
restart: unless-stopped
networks:
- proxynet
env_file:
- ./config.env
environment:
PG_DATA: /var/lib/postgresql/data
volumes:
- ./pgdata:/var/lib/postgresql/data
labels:
- "traefik.enable=true"
- "traefik.tcp.routers.postgresql.rule=HostSNI(`*`)"
- "traefik.tcp.services.postgresql.loadbalancer.server.port=5432"
- "traefik.tcp.routers.postgresql.entrypoints=postgres"
networks:
proxynet:
external: true

2
rabbitmq/.gitignore vendored Executable file
View File

@ -0,0 +1,2 @@
/rabbitmq

17
rabbitmq/README.md Executable file
View File

@ -0,0 +1,17 @@
### RabbitMQ
*WEB GUI*
Path: `http://localhost:15672/`
User: rabbit
Password: 5jbya3ptfrezyop6gy8w
*docker commands*
Start: `sudo docker compose up -d`
`sudo docker start rabbitmq`
Stop: `sudo docker stop rabbitmq`

3
rabbitmq/config.env Executable file
View File

@ -0,0 +1,3 @@
RABBITMQ_DEFAULT_VHOST: vrabbit
RABBITMQ_DEFAULT_USER: rabbit
RABBITMQ_DEFAULT_PASS: 5jbya3ptfrezyop6gy8w

23
rabbitmq/docker-compose.yml Executable file
View File

@ -0,0 +1,23 @@
services:
rabbitmq:
container_name: rabbitmq
image: rabbitmq:4.0.3-management-alpine
restart: unless-stopped
networks:
- proxynet
env_file:
- ./config.env
volumes:
- ./rabbitmq:/var/lib/rabbitmq
stop_grace_period: 10s
ports:
- "5672:5672"
labels:
# WEB GUI
- "traefik.enable=true"
- "traefik.tcp.routers.mq.rule=HostSNI(`*`)"
- "traefik.tcp.services.mq.loadbalancer.server.port=15672"
- "traefik.tcp.routers.mq.entrypoints=rabbitmq-gui"
networks:
proxynet:
external: true

26
run.all.sh Executable file
View File

@ -0,0 +1,26 @@
#!/bin/sh
cd traefik
docker compose up -d
cd ..
cd postgres
docker compose up -d
cd ..
cd rabbitmq
docker compose up -d
cd ..
cd stocks
docker compose up -d
cd ..
cd history
docker compose up -d
cd ..
cd users
docker compose up -d
cd ..

5
stocks/.dockerignore Executable file
View File

@ -0,0 +1,5 @@
/node_modules
docker-compose.yml
docker-compose.dev.yml
run.dev.sh
run.prod.sh

18
stocks/.gitignore vendored Executable file
View File

@ -0,0 +1,18 @@
/node_modules
# IDEs and editors
/.idea
.project
.classpath
.c9/
*.launch
.settings/
*.sublime-workspace
# IDE - VSCode
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json

22
stocks/Dockerfile Executable file
View File

@ -0,0 +1,22 @@
# develop stage
FROM node:22.11.0-alpine3.20 AS develop-stage
WORKDIR /app
COPY package*.json ./
RUN npm config set fund false --location=global
COPY . .
# build stage
FROM develop-stage AS build-stage
RUN npm install
# production stage
FROM node:22.11.0-alpine3.20 AS production-stage
WORKDIR /app
COPY --from=build-stage /app/node_modules /app/node_modules
COPY --from=build-stage /app/src /app/src
COPY package.json ./
ENV NODE_PATH=./node_modules
CMD ["node", "src/index.js"]
EXPOSE 3001

115
stocks/README.md Executable file
View File

@ -0,0 +1,115 @@
### Stocks
Backend stocks v0.1
Repository name: `em-shop`
--------------------------------------------------------------------
*cheat sheet:*
```sh
# Запуск сервера в production mode:
sudo bash run.prod.sh
sudo docker compose up -d --build --force-recreate
# Запуск сервера в development mode:
sudo bash run.dev.sh
sudo docker compose -f docker-compose.dev.yml up --build --force-recreate
# Подключение к консоли контейнера:
sudo docker exec -it stocks sh
sudo docker exec -it stocks-dev sh
sudo docker start stocks
sudo docker stop stocks
sudo docker start stocks-dev
sudo docker stop stocks-dev
```
--------------------------------------------------------------------
#### Общие сведения
Для успешного запуска и функционирования требуются следующий файл конфигурации:
```yml
env_file:
- ./config.env
```
В `config.env` указываюся переменные:
(Используйте эти значения только для разработки)
```conf
PORT=3001
URL=/stocks/api
POSTGRES_HOST=postgres
POSTGRES_PORT=5432
POSTGRES_USER=postgres
POSTGRES_DB=postgres
POSTGRES_PASSWORD=2wroxrnr8fdxicvw2nsd
RABBITMQ_HOST=rabbitmq
RABBITMQ_PORT=5672
RABBITMQ_USER=rabbit
RABBITMQ_PASSWORD=5jbya3ptfrezyop6gy8w
RABBITMQ_VHOST=vrabbit
```
#### Production mode:
Режим запуска сервера по умолчанию.
Конфигурация описана в `docker-compose.yml`
Запуск сервера осуществляется простой командой:
`docker compose up -d`
После изменений в образе, запуск производится с пересборкой образа:
`docker compose up -d --build --force-recreate`
Для быстрого удобного запуска предусмотрен `скрипт run.prod.sh`, который выполняет команду выше.
*Пояснения:*
Запуск производится по средствам выполнения в образе команды:
`command: ["node", "src/index.js"]`
#### Development mode:
Запуск сервера в режиме разработки. Требуется указать соответствующий образ:
`docker compose -f docker-compose.dev.yml up -d`
После изменений в образе, запуск производится с пересборкой образа:
`docker compose -f docker-compose.dev.yml up -d --build --force-recreate`
Для быстрого удобного запуска предусмотрен скрипт `run.dev.sh`, который выполняет команду выше.
*Пояснения:*
Запуск производится по средствам выполнения в образе команды:
`command: sh -c "npm install && npm run start:dev"`
`start:dev` описана в `package.json` и содержит следующую команду:
`nodemon ./src/index.js`
причем, перед запуском сервера выполняется установка `npm install`, что необходимо для корректного прозрачного отображения папки
`node_modules` в каталоге, что бы в процессе разработки у IDE был доступ к этой папке для корректного анализа,
и подсветки кода в IDE.
Так же, весь каталог с репозиторием монтируется в режиме записи в как корневой `/app` в Docker контейнере:
```yml
volumes:
- .:/app:rw
```
что необходимо для внесения изменений в код без пересборки контейнера.
В `profuction` режиме сборка осуществляется обычно, `node_modules` и все файлы находятся внутри образа, никаких дополнительных команд, volumes, не используется.

14
stocks/config.env Executable file
View File

@ -0,0 +1,14 @@
PORT=3001
URL=/stocks/api
POSTGRES_HOST=postgres
POSTGRES_PORT=5432
POSTGRES_USER=postgres
POSTGRES_DB=postgres
POSTGRES_PASSWORD=2wroxrnr8fdxicvw2nsd
RABBITMQ_HOST=rabbitmq
RABBITMQ_PORT=5672
RABBITMQ_USER=rabbit
RABBITMQ_PASSWORD=5jbya3ptfrezyop6gy8w
RABBITMQ_VHOST=vrabbit

30
stocks/docker-compose.dev.yml Executable file
View File

@ -0,0 +1,30 @@
services:
stocks-dev:
container_name: stocks-dev
build:
context: .
target: 'develop-stage'
env_file:
- ./config.env
environment:
NODE_ENV: development
volumes:
- .:/app:rw
command: sh -c "npm install && npm run start:dev"
restart: unless-stopped
networks:
- proxynet
labels:
- "traefik.enable=true"
- "traefik.http.routers.apistocks.rule=PathPrefix(`/stocks/api`)"
- "traefik.http.routers.apistocks.entrypoints=http"
- "traefik.http.routers.apistocks.service=apistocks-service"
- "traefik.http.services.apistocks-service.loadbalancer.server.port=3001"
logging:
driver: "json-file"
options:
max-size: "1m"
networks:
proxynet:
external: true

28
stocks/docker-compose.yml Executable file
View File

@ -0,0 +1,28 @@
services:
stocks:
container_name: stocks
build:
context: .
target: 'production-stage'
env_file:
- ./config.env
environment:
NODE_ENV: production
command: ["node", "src/index.js"]
restart: unless-stopped
networks:
- proxynet
labels:
- "traefik.enable=true"
- "traefik.http.routers.apistocks.rule=PathPrefix(`/stocks/api`)"
- "traefik.http.routers.apistocks.entrypoints=http"
- "traefik.http.routers.apistocks.service=apistocks-service"
- "traefik.http.services.apistocks-service.loadbalancer.server.port=3001"
logging:
driver: "json-file"
options:
max-size: "1m"
networks:
proxynet:
external: true

1674
stocks/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

25
stocks/package.json Executable file
View File

@ -0,0 +1,25 @@
{
"name": "stocks",
"version": "0.0.1",
"description": "Stocks api server",
"main": "index.js",
"type": "module",
"scripts": {
"start": "node ./src/index.js",
"start:dev": "nodemon ./src/index.js"
},
"author": "Leonid",
"license": "Free",
"dependencies": {
"cors": "^2.8.5",
"dotenv": "^16.4.5",
"express": "^4.21.1",
"express-validator": "^7.2.0",
"sequelize": "^6.37.5",
"pg": "^8.13.1",
"amqplib": "^0.10.4"
},
"devDependencies": {
"nodemon": "^3.1.7"
}
}

8
stocks/run.dev.sh Executable file
View File

@ -0,0 +1,8 @@
#!/bin/bash
if [ "$(id -u)" != "0" ]; then
echo -e "\033[31mThis script requires superuser rights.\033[0m"
exit 0
fi
sudo docker compose -f docker-compose.dev.yml up --build --force-recreate

8
stocks/run.prod.sh Executable file
View File

@ -0,0 +1,8 @@
#!/bin/bash
if [ "$(id -u)" != "0" ]; then
echo -e "\033[31mThis script requires superuser rights.\033[0m"
exit 0
fi
sudo docker compose up -d --build --force-recreate

View File

@ -0,0 +1,39 @@
import productService from '../service/product-service.js';
import { validationResult } from 'express-validator';
import ApiError from '../exceptions/api-error.js';
class ProductController {
async createProduct(req, res, next) {
try {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return next(ApiError.BadRequest('Validation error', errors.array()));
}
const { plu, name } = req.body;
const productData = await productService.createProduct(plu, name);
return res.json(productData);
} catch (e) {
next(e);
}
}
async getProducts(req, res, next) {
try {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return next(ApiError.BadRequest('Validation error', errors.array()));
}
const { plu, name, page, page_size } = req.query;
const products = await productService.getProducts(plu, name, page, page_size);
return res.json(products);
} catch (e) {
next(e);
}
}
}
export default new ProductController();

View File

@ -0,0 +1,24 @@
import shopService from '../service/shop-service.js';
import { validationResult } from 'express-validator';
import ApiError from '../exceptions/api-error.js';
class ShopController {
async createShop(req, res, next) {
try {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return next(ApiError.BadRequest('Validation error', errors.array()));
}
const { name } = req.body;
const shopData = await shopService.createShop(name);
return res.json(shopData);
} catch (e) {
next(e);
}
}
}
export default new ShopController();

View File

@ -0,0 +1,69 @@
import stocksService from '../service/stocks-service.js';
import { validationResult } from 'express-validator';
import ApiError from '../exceptions/api-error.js';
class StocksController {
async createStocks(req, res, next) {
try {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return next(ApiError.BadRequest('Validation error', errors.array()));
}
const { plu, shop_id, in_stock, in_order } = req.body;
const stocksData = await stocksService.createStocks(plu, shop_id, in_stock, in_order);
return res.json(stocksData);
} catch (e) {
next(e);
}
}
async getStocks(req, res, next) {
try {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return next(ApiError.BadRequest('Validation error', errors.array()));
}
const { plu, shop_id, shelf_from, shelf_to, order_from, order_to, page, page_size } = req.query;
const stocks = await stocksService.getStocks(plu, shop_id, shelf_from, shelf_to, order_from, order_to, page, page_size);
return res.json(stocks);
} catch (e) {
next(e);
}
}
async increaseStocks(req, res, next) {
try {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return next(ApiError.BadRequest('Validation error', errors.array()));
}
const { id, plu, shop_id, increase_shelf, increase_order } = req.body;
const increased = await stocksService.changeStocks(true, id, plu, shop_id, increase_shelf, increase_order);
return res.json(increased);
} catch (e) {
next(e);
}
}
async decreaseStocks(req, res, next) {
try {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return next(ApiError.BadRequest('Validation error', errors.array()));
}
const { id, plu, shop_id, decrease_shelf, decrease_order } = req.body;
const decreased = await stocksService.changeStocks(false, id, plu, shop_id, decrease_shelf, decrease_order);
return res.json(decreased);
} catch (e) {
next(e);
}
}
}
export default new StocksController();

13
stocks/src/db.js Executable file
View File

@ -0,0 +1,13 @@
import { Sequelize } from 'sequelize';
const sequelize = new Sequelize({
dialect: 'postgres',
host: process.env.POSTGRES_HOST,
port: process.env.POSTGRES_PORT,
username: process.env.POSTGRES_USER,
database: process.env.POSTGRES_DB,
password: process.env.POSTGRES_PASSWORD
});
export default sequelize;

View File

@ -0,0 +1,21 @@
class ApiError extends Error {
status;
errors;
constructor(status, message, errors = []) {
super(message);
this.status = status;
this.errors = errors;
}
static UnauthorizedError() {
return new ApiError(401, 'Пользователь не авторизован')
}
static BadRequest(message, errors = []) {
return new ApiError(400, message, errors);
}
}
export default ApiError;

35
stocks/src/index.js Executable file
View File

@ -0,0 +1,35 @@
// Stocks v0.1
import http from 'http';
import express from 'express';
import sequelize from './db.js';
import rabbitMqService from './rabbitmq.js';
import router from './router/router.js';
import errorMiddleware from './middlewares/error-middleware.js';
const PORT = process.env.PORT || 3000;
const app = express();
const server = http.createServer(app);
app.use(express.json());
app.use(process.env.URL, router);
app.use(errorMiddleware);
const start = async () => {
try {
await sequelize.authenticate();
console.log('Connection has been established successfully');
await sequelize.sync().then(() => {
console.log('Database and tables created!');
});
rabbitMqService.connectRabbitMq('logs');
server.listen(PORT, () => console.log(`Server started on PORT = ${PORT}`));
} catch (e) {
console.log(e);
}
}
start();

View File

@ -0,0 +1,14 @@
import ApiError from '../exceptions/api-error.js'
export default function (req, res, next) {
try {
if (false) {
return next(ApiError.UnauthorizedError());
}
next();
} catch (e) {
return next(ApiError.UnauthorizedError());
}
};

View File

@ -0,0 +1,11 @@
import ApiError from '../exceptions/api-error.js';
export default function (err, req, res, next) {
console.log(err);
if (err instanceof ApiError) {
return res.status(err.status).json({message: err.message, errors: err.errors})
}
return res.status(500).json({message: 'Ошибка сервера'})
};

View File

@ -0,0 +1,21 @@
import sequelize from '../db.js';
import { DataTypes, Model } from 'sequelize';
class Product extends Model {}
Product.init({
plu: { type: DataTypes.INTEGER, primaryKey: true, allowNull: false },
name: { type: DataTypes.STRING, defaultValue: "" },
}, {
sequelize,
modelName: 'product',
});
export default Product;

20
stocks/src/models/shop-model.js Executable file
View File

@ -0,0 +1,20 @@
import sequelize from '../db.js';
import { DataTypes, Model } from 'sequelize';
class Shop extends Model {}
Shop.init({
name: { type: DataTypes.STRING, defaultValue: "" },
}, {
sequelize,
modelName: 'shop',
});
export default Shop;

View File

@ -0,0 +1,28 @@
import sequelize from '../db.js';
import { DataTypes, Model } from 'sequelize';
import ProductModel from './product-model.js';
import ShopModel from './shop-model.js';
class Stocks extends Model {}
Stocks.init({
plu: { type: DataTypes.INTEGER, defaultValue: null },
shop_id: { type: DataTypes.INTEGER, defaultValue: null },
in_shelf: { type: DataTypes.INTEGER, defaultValue: 0 },
in_order: { type: DataTypes.INTEGER, defaultValue: 0 }
}, {
sequelize,
modelName: 'stocks',
});
Stocks.belongsTo(ShopModel, { as: 'shop', foreignKey: 'shop_id' });
Stocks.belongsTo(ProductModel, { as: 'product', foreignKey: 'plu' });
export default Stocks;

41
stocks/src/rabbitmq.js Executable file
View File

@ -0,0 +1,41 @@
import amqp from "amqplib";
class RabbitMqService {
constructor() {
this.channel = null;
this.queue = null;
}
async connectRabbitMq(queue) {
try {
const connection = await amqp.connect({
protocol: 'amqp',
hostname: process.env.RABBITMQ_HOST,
port: process.env.RABBITMQ_PORT,
username: process.env.RABBITMQ_USER,
password: process.env.RABBITMQ_PASSWORD,
vhost: process.env.RABBITMQ_VHOST,
});
this.channel = await connection.createChannel();
await this.channel.assertQueue(queue);
this.queue = queue;
console.log("RabbitMQ connected");
} catch (err) {
console.error("Error connecting to RabbitMQ", err);
throw err;
}
}
getChannel() {
return this.channel;
}
sendToQueue(data) {
this.channel.sendToQueue(this.queue, Buffer.from(JSON.stringify(data)));
}
}
const rabbitMqService = new RabbitMqService();
export default rabbitMqService;

69
stocks/src/router/router.js Executable file
View File

@ -0,0 +1,69 @@
import { Router } from 'express';
import productController from '../controllers/product-controller.js';
import shopController from '../controllers/shop-controller.js';
import stocksController from '../controllers/stocks-controller.js';
import authMiddleware from '../middlewares/auth-middleware.js';
import {
productDataValidate,
shopDataValidate,
stocksDataValidate,
increaseDataValidate,
decreaseDataValidate,
getStocksDataValidate,
getProductsDataValidate
} from './validators.js';
const router = new Router();
router.post(
'/product/create',
authMiddleware,
productDataValidate,
productController.createProduct
);
router.post(
'/shop/create',
authMiddleware,
shopDataValidate,
shopController.createShop
);
router.post(
'/stocks/create',
authMiddleware,
stocksDataValidate,
stocksController.createStocks
);
router.patch(
"/stocks/increase",
authMiddleware,
increaseDataValidate,
stocksController.increaseStocks
);
router.patch(
'/stocks/decrease',
authMiddleware,
decreaseDataValidate,
stocksController.decreaseStocks
);
router.get(
'/stocks',
authMiddleware,
getStocksDataValidate,
stocksController.getStocks
);
router.get(
'/products',
authMiddleware,
getProductsDataValidate,
productController.getProducts
);
export default router;

View File

@ -0,0 +1,134 @@
import { body, query } from 'express-validator';
// product/create:
export const productDataValidate = [
body('plu')
.exists({ checkFalsy: true, checkNull: true })
.withMessage('поле plu обязательно')
.isNumeric()
.withMessage('plu должен быть числом')
.isLength({ min: 4, max: 5 })
.withMessage('plu должен состоять из 4-5 цифр'),
body('name')
.exists({ checkFalsy: true, checkNull: true })
.withMessage('поле name обязательно')
.isLength({ min: 3, max: 100 })
.withMessage('поле name должно содержать не менее трех и не более 100 символов')
];
// shop/create:
export const shopDataValidate = [
body('name')
.exists({ checkFalsy: true, checkNull: true })
.withMessage('поле name обязательно')
.isLength({ min: 3, max: 100 })
.withMessage('поле name должно содержать не менее трех и не более 100 символов')
];
// stocks/create:
export const stocksDataValidate = [
body('plu')
.exists({ checkFalsy: true, checkNull: true })
.withMessage('поле plu обязательно')
.isNumeric()
.withMessage('plu должен быть числом')
.isLength({ min: 4, max: 5 })
.withMessage('plu должен состоять из 4-5 цифр'),
body('shop_id')
.exists({ checkFalsy: true, checkNull: true })
.withMessage('поле shop_id обязательно')
.isNumeric()
.withMessage('поле shop_id должно быть числом'),
body('in_stock')
.default(0)
.exists({ checkFalsy: true, checkNull: true })
.isNumeric()
.withMessage('поле in_stock должно быть числом'),
body('in_order')
.default(0)
.exists({ checkFalsy: true, checkNull: true })
.isNumeric()
.withMessage('поле in_order должно быть числом'),
];
// stocks/increase:
export const increaseDataValidate = [
body('id')
.toFloat()
.default(null),
body('plu')
.toFloat()
.default(null),
body('shop_id')
.toFloat()
.default(null),
body('increase_shelf')
.toFloat()
.default(0),
body('increase_order')
.toFloat()
.default(0),
];
// stocks/decrease:
export const decreaseDataValidate = [
body('id')
.toFloat()
.default(null),
body('plu')
.toFloat()
.default(null),
body('shop_id')
.toFloat()
.default(null),
body('decrease_shelf')
.toFloat()
.default(0),
body('decrease_order')
.toFloat()
.default(0),
];
// stocks:
export const getStocksDataValidate = [
query('plu')
.toFloat()
.default(null),
query('shop_id')
.toFloat()
.default(null),
query('shelf_from')
.toFloat()
.default(null),
query('shelf_to')
.toFloat()
.default(null),
query('order_from')
.toFloat()
.default(null),
query('order_to')
.toFloat()
.default(null),
query('page')
.toFloat()
.default(1),
query('page_size')
.toFloat()
.default(10),
];
export const getProductsDataValidate = [
query('plu')
.toFloat()
.default(null),
query('name')
.default(null),
query('page')
.toFloat()
.default(1),
query('page_size')
.toFloat()
.default(10),
];

View File

@ -0,0 +1,61 @@
import ProductModel from '../models/product-model.js';
import ApiError from '../exceptions/api-error.js';
import rabbitMqService from '../rabbitmq.js';
import { Op } from 'sequelize';
class ProductService {
async createProduct(plu, name) {
const existingProduct = await ProductModel.findOne({ where: { plu }});
if (existingProduct) {
throw ApiError.BadRequest('Продукт с таким PLU уже существует');
}
const product = await ProductModel.create({ plu, name });
rabbitMqService.sendToQueue({
action: 'createProduct',
shop_id: null,
plu: product.plu,
oldData: null,
newData: product
});
return product;
}
async getProducts(
plu = null,
name = null,
page = 1,
pageSize = 10
) {
const offset = (page - 1) * pageSize;
const limit = pageSize;
const where = {};
if(plu) where.plu = plu;
if(name) where.name = {[Op.iLike]: `%${name}%`};
const products = await ProductModel.findAndCountAll({ limit, offset, where });
const totalPages = Math.ceil(products.count / pageSize);
rabbitMqService.sendToQueue({
action: 'getProducts',
shop_id: null,
plu,
oldData: null,
newData: null
});
return {
products: products.rows,
page,
pageSize,
totalPages,
totalProducts: products.count
}
}
}
export default new ProductService();

View File

@ -0,0 +1,27 @@
import ApiError from '../exceptions/api-error.js';
import ShopModel from '../models/shop-model.js';
import rabbitMqService from '../rabbitmq.js';
class ShopService {
async createShop(name) {
const existingShop = await ShopModel.findOne({ where: { name }});
if (existingShop) {
throw ApiError.BadRequest('Магазин с таким названием уже существует');
}
const shop = await ShopModel.create({ name });
rabbitMqService.sendToQueue({
action: 'createShop',
shop_id: shop.id,
plu: null,
oldData: null,
newData: shop
});
return shop;
}
}
export default new ShopService();

View File

@ -0,0 +1,147 @@
import ApiError from '../exceptions/api-error.js';
import StocksModel from '../models/stocks-model.js';
import ShopModel from '../models/shop-model.js';
import ProductModel from '../models/product-model.js';
import rabbitMqService from '../rabbitmq.js';
import { Op } from 'sequelize';
class StocksService {
async createStocks(plu, shop_id, in_shelf, in_order) {
const existingStock = await StocksModel.findOne({ where: { plu, shop_id } });
if (existingStock) {
throw ApiError.BadRequest('Запись об остатке с таким товаром и магазином уже существует в базе данных');
}
const existingPlu = await ProductModel.findOne({ where: { plu } });
if (!existingPlu) {
throw ApiError.BadRequest(`Товар с PLU ${plu} отсутствует в базе`);
}
const existingShop = await ShopModel.findOne({ where: { id: shop_id } });
if (!existingShop) {
throw ApiError.BadRequest(`Магазин с кодом ${shop_id} отсутствует в базе`);
}
const stocks = await StocksModel.create({ plu, shop_id, in_shelf, in_order });
rabbitMqService.sendToQueue({
action: 'createStocks',
shop_id: stocks.shop_id,
plu: stocks.plu,
oldData: null,
newData: stocks
});
return stocks;
}
async getStocks(
plu = null,
shopId = null,
shelfFrom = null,
shelfTo = null,
orderFrom = null,
orderTo = null,
page = 1,
pageSize = 10
) {
const offset = (page - 1) * pageSize;
const limit = pageSize;
const where = {};
if(plu) where.plu = plu;
if(shopId) where.shop_id = shopId;
if(shelfFrom) { where.in_shelf = {}; where.in_shelf[Op.gt] = shelfFrom; };
if(shelfTo) { where.in_shelf = {}; where.in_shelf[Op.lt] = shelfTo; };
if(orderFrom) { where.in_order = {}; where.in_order[Op.gt] = orderFrom; };
if(orderTo) { where.in_order = {}; where.in_order[Op.lt] = orderTo; };
const stocks = await StocksModel.findAndCountAll({
limit,
offset,
where,
include: [
{ model: ShopModel, as: 'shop', attributes: ['name'] },
{ model: ProductModel, as: 'product', attributes: ['name'] },
],
});
const totalPages = Math.ceil(stocks.count / pageSize);
rabbitMqService.sendToQueue({
action: 'getStocks',
shop_id: shopId,
plu,
oldData: null,
newData: null
});
const formattedStocks = stocks.rows.map(stock => ({
id: stock.id,
plu: stock.plu,
shopId: stock.shop_id,
inShelf: stock.in_shelf,
inOrder: stock.in_order,
createdAt: stock.createdAt,
updatedAt: stock.updatedAt,
shopName: stock.shop.name,
productName: stock.product.name
}));
return {
stocks: formattedStocks,
page,
pageSize,
totalPages,
totalStocks: stocks.count
}
}
async changeStocks(isIncrease, id, plu, shop_id, change_shelf, change_order) {
if(!isIncrease) {
change_shelf *= -1;
change_order *= -1;
}
let stock;
if(id) {
stock = await StocksModel.findOne({ where: { id }});
if (!stock) {
throw ApiError.BadRequest('Остаток с таким id не найден');
}
}
if(plu && shop_id) {
stock = await StocksModel.findOne({ where: { plu, shop_id }});
if (!stock) {
throw ApiError.BadRequest(`Остаток с PLU ${plu} в магазине с кодом ${shop_id} не найден`);
}
}
if(!stock) throw ApiError.BadRequest('Неверный запрос');
const oldStock = { ...stock.dataValues };
stock.in_shelf = stock.in_shelf + change_shelf;
stock.in_order = stock.in_order + change_order;
const newStock = await stock.save();
rabbitMqService.sendToQueue({
action: isIncrease ? 'increaseStocks' : 'decreaseStocks',
shop_id: stock.shop_id,
plu: stock.plu,
oldData: oldStock,
newData: newStock
});
return newStock;
}
}
export default new StocksService();

13
stop.all.sh Executable file
View File

@ -0,0 +1,13 @@
#!/bin/sh
docker stop traefik
docker stop postgres
docker stop rabbitmq
docker stop stocks
docker stop history
docker stop users

14
traefik/README.md Executable file
View File

@ -0,0 +1,14 @@
### Traefik
Path: `http://localhost/dashboard/`
User: `admin`
Password: `admin`
*docker commands*
Start: `sudo docker compose -f /data/traefik/docker-compose.yml up -d`
`sudo docker start traefik`
Stop: `sudo docker stop traefik`

38
traefik/docker-compose.yml Executable file
View File

@ -0,0 +1,38 @@
services:
traefik:
container_name: traefik
image: traefik:v3.2.0
restart: unless-stopped
command:
- "--providers.docker.network=proxynet"
- "--api.insecure=true"
- "--api.dashboard=true"
- "--providers.docker"
- "--log=true"
- "--log.level=DEBUG"
- "--providers.docker.exposedByDefault=false"
# Entrypoints:
- "--entrypoints.http.address=:80"
- "--entrypoints.postgres.address=:5432"
- "--entrypoints.rabbitmq-gui.address=:15672"
labels:
- "traefik.enable=true"
- "traefik.http.routers.dashboard.rule=PathPrefix(`/api`) || PathPrefix(`/dashboard`)"
- "traefik.http.routers.dashboard.service=api@internal"
- "traefik.http.routers.dashboard.middlewares=auth"
- "traefik.http.middlewares.auth.basicauth.usersfile=/httpauth/usersfile.htpasswd"
ports:
- "80:80"
- "5432:5432"
- "15672:15672"
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
- ./httpauth:/httpauth
logging:
driver: "json-file"
options:
max-size: "1m"
networks:
default:
name: proxynet
external: true

View File

@ -0,0 +1 @@
admin:$2y$10$bPwS1U.tBzICOvX2uhG9cOY4nNA0NvoYqTnCD1Wr5MTcwWupfBTIG

25
users/.eslintrc.js Normal file
View File

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

400
users/.gitignore vendored Normal file
View File

@ -0,0 +1,400 @@
# Created by .ignore support plugin (hsz.mobi)
### JetBrains template
# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio and Webstorm
# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839
# User-specific stuff:
.idea/**/workspace.xml
.idea/**/tasks.xml
.idea/dictionaries
# Sensitive or high-churn files:
.idea/**/dataSources/
.idea/**/dataSources.ids
.idea/**/dataSources.xml
.idea/**/dataSources.local.xml
.idea/**/sqlDataSources.xml
.idea/**/dynamic.xml
.idea/**/uiDesigner.xml
# Gradle:
.idea/**/gradle.xml
.idea/**/libraries
# CMake
cmake-build-debug/
# Mongo Explorer plugin:
.idea/**/mongoSettings.xml
## File-based project format:
*.iws
## Plugin-specific files:
# IntelliJ
out/
# mpeltonen/sbt-idea plugin
.idea_modules/
# JIRA plugin
atlassian-ide-plugin.xml
# Cursive Clojure plugin
.idea/replstate.xml
# Crashlytics plugin (for Android Studio and IntelliJ)
com_crashlytics_export_strings.xml
crashlytics.properties
crashlytics-build.properties
fabric.properties
### VisualStudio template
## Ignore Visual Studio temporary files, build results, and
## files generated by popular Visual Studio add-ons.
##
## Get latest from https://github.com/github/gitignore/blob/master/VisualStudio.gitignore
# User-specific files
*.suo
*.user
*.userosscache
*.sln.docstates
# User-specific files (MonoDevelop/Xamarin Studio)
*.userprefs
# Build results
[Dd]ebug/
[Dd]ebugPublic/
[Rr]elease/
[Rr]eleases/
x64/
x86/
bld/
[Bb]in/
[Oo]bj/
[Ll]og/
# Visual Studio 2015 cache/options directory
.vs/
# Uncomment if you have tasks that create the project's static files in wwwroot
#wwwroot/
# MSTest test Results
[Tt]est[Rr]esult*/
[Bb]uild[Ll]og.*
# NUNIT
*.VisualState.xml
TestResult.xml
# Build Results of an ATL Project
[Dd]ebugPS/
[Rr]eleasePS/
dlldata.c
# Benchmark Results
BenchmarkDotNet.Artifacts/
# .NET Core
project.lock.json
project.fragment.lock.json
artifacts/
**/Properties/launchSettings.json
*_i.c
*_p.c
*_i.h
*.ilk
*.meta
*.obj
*.pch
*.pdb
*.pgc
*.pgd
*.rsp
*.sbr
*.tlb
*.tli
*.tlh
*.tmp
*.tmp_proj
*.log
*.vspscc
*.vssscc
.builds
*.pidb
*.svclog
*.scc
# Chutzpah Test files
_Chutzpah*
# Visual C++ cache files
ipch/
*.aps
*.ncb
*.opendb
*.opensdf
*.sdf
*.cachefile
*.VC.db
*.VC.VC.opendb
# Visual Studio profiler
*.psess
*.vsp
*.vspx
*.sap
# Visual Studio Trace Files
*.e2e
# TFS 2012 Local Workspace
$tf/
# Guidance Automation Toolkit
*.gpState
# ReSharper is a .NET coding add-in
_ReSharper*/
*.[Rr]e[Ss]harper
*.DotSettings.user
# JustCode is a .NET coding add-in
.JustCode
# TeamCity is a build add-in
_TeamCity*
# DotCover is a Code Coverage Tool
*.dotCover
# AxoCover is a Code Coverage Tool
.axoCover/*
!.axoCover/settings.json
# Visual Studio code coverage results
*.coverage
*.coveragexml
# NCrunch
_NCrunch_*
.*crunch*.local.xml
nCrunchTemp_*
# MightyMoose
*.mm.*
AutoTest.Net/
# Web workbench (sass)
.sass-cache/
# Installshield output folder
[Ee]xpress/
# DocProject is a documentation generator add-in
DocProject/buildhelp/
DocProject/Help/*.HxT
DocProject/Help/*.HxC
DocProject/Help/*.hhc
DocProject/Help/*.hhk
DocProject/Help/*.hhp
DocProject/Help/Html2
DocProject/Help/html
# Click-Once directory
publish/
# Publish Web Output
*.[Pp]ublish.xml
*.azurePubxml
# Note: Comment the next line if you want to checkin your web deploy settings,
# but database connection strings (with potential passwords) will be unencrypted
*.pubxml
*.publishproj
# Microsoft Azure Web App publish settings. Comment the next line if you want to
# checkin your Azure Web App publish settings, but sensitive information contained
# in these scripts will be unencrypted
PublishScripts/
# NuGet Packages
*.nupkg
# The packages folder can be ignored because of Package Restore
**/[Pp]ackages/*
# except build/, which is used as an MSBuild target.
!**/[Pp]ackages/build/
# Uncomment if necessary however generally it will be regenerated when needed
#!**/[Pp]ackages/repositories.config
# NuGet v3's project.json files produces more ignorable files
*.nuget.props
*.nuget.targets
# Microsoft Azure Build Output
csx/
*.build.csdef
# Microsoft Azure Emulator
ecf/
rcf/
# Windows Store app package directories and files
AppPackages/
BundleArtifacts/
Package.StoreAssociation.xml
_pkginfo.txt
*.appx
# Visual Studio cache files
# files ending in .cache can be ignored
*.[Cc]ache
# but keep track of directories ending in .cache
!*.[Cc]ache/
# Others
ClientBin/
~$*
*~
*.dbmdl
*.dbproj.schemaview
*.jfm
*.pfx
*.publishsettings
orleans.codegen.cs
# Since there are multiple workflows, uncomment next line to ignore bower_components
# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622)
#bower_components/
# RIA/Silverlight projects
Generated_Code/
# Backup & report files from converting an old project file
# to a newer Visual Studio version. Backup files are not needed,
# because we have git ;-)
_UpgradeReport_Files/
Backup*/
UpgradeLog*.XML
UpgradeLog*.htm
# SQL Server files
*.mdf
*.ldf
*.ndf
# Business Intelligence projects
*.rdl.data
*.bim.layout
*.bim_*.settings
# Microsoft Fakes
FakesAssemblies/
# GhostDoc plugin setting file
*.GhostDoc.xml
# Node.js Tools for Visual Studio
.ntvs_analysis.dat
node_modules/
# Typescript v1 declaration files
typings/
# Visual Studio 6 build log
*.plg
# Visual Studio 6 workspace options file
*.opt
# Visual Studio 6 auto-generated workspace file (contains which files were open etc.)
*.vbw
# Visual Studio LightSwitch build output
**/*.HTMLClient/GeneratedArtifacts
**/*.DesktopClient/GeneratedArtifacts
**/*.DesktopClient/ModelManifest.xml
**/*.Server/GeneratedArtifacts
**/*.Server/ModelManifest.xml
_Pvt_Extensions
# Paket dependency manager
.paket/paket.exe
paket-files/
# FAKE - F# Make
.fake/
# JetBrains Rider
.idea/
*.sln.iml
# IDE - VSCode
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
# CodeRush
.cr/
# Python Tools for Visual Studio (PTVS)
__pycache__/
*.pyc
# Cake - Uncomment if you are using it
# tools/**
# !tools/packages.config
# Tabs Studio
*.tss
# Telerik's JustMock configuration file
*.jmconfig
# BizTalk build output
*.btp.cs
*.btm.cs
*.odx.cs
*.xsd.cs
# OpenCover UI analysis results
OpenCover/
coverage/
### macOS template
# General
.DS_Store
.AppleDouble
.LSOverride
# Icon must end with two \r
Icon
# Thumbnails
._*
# Files that might appear in the root of a volume
.DocumentRevisions-V100
.fseventsd
.Spotlight-V100
.TemporaryItems
.Trashes
.VolumeIcon.icns
.com.apple.timemachine.donotpresent
# Directories potentially created on remote AFP share
.AppleDB
.AppleDesktop
Network Trash Folder
Temporary Items
.apdisk
=======
# Local
.env
dist
.webpack
.serverless/**/*.zip

4
users/.prettierrc Normal file
View File

@ -0,0 +1,4 @@
{
"singleQuote": true,
"trailingComma": "all"
}

22
users/Dockerfile Executable file
View File

@ -0,0 +1,22 @@
# develop stage
FROM node:22.11.0-alpine3.20 AS develop-stage
WORKDIR /app
COPY package*.json ./
RUN npm config set fund false --location=global
COPY . .
# build stage
FROM develop-stage AS build-stage
RUN npm install
RUN npm run build
# production stage
FROM node:22.11.0-alpine3.20 AS production-stage
WORKDIR /app
COPY --from=build-stage /app/node_modules /app/node_modules
COPY --from=build-stage /app/dist /app/dist
COPY package.json ./
CMD ["node", "dist/main.js"]
EXPOSE 3003

81
users/README.md Normal file
View File

@ -0,0 +1,81 @@
### Users
Backend users v0.1
Repository name: `em-shop`
Swagger: `http://localhost/users/api/docs/`
--------------------------------------------------------------------
*cheat sheet:*
```sh
# Запуск сервера в production mode:
sudo bash run.prod.sh
sudo docker compose up -d --build --force-recreate
# Запуск сервера в development mode:
sudo bash run.dev.sh
sudo docker compose -f docker-compose.dev.yml up --build --force-recreate
# Подключение к консоли контейнера:
sudo docker exec -it users sh
sudo docker exec -it users-dev sh
sudo docker start users
sudo docker stop users
sudo docker start users-dev
sudo docker stop users-dev
```
--------------------------------------------------------------------
#### Общие сведения
Для успешного запуска и функционирования требуются следующий файл конфигурации:
```yml
env_file:
- ./config.env
```
В `config.env` указываюся переменные:
(Используйте эти значения только для разработки)
```conf
PORT=3003
URL=/users/api
POSTGRES_HOST=postgres
POSTGRES_PORT=5432
POSTGRES_USER=postgres
POSTGRES_DB=postgres
POSTGRES_PASSWORD=2wroxrnr8fdxicvw2nsd
```
#### Production mode:
Режим запуска сервера по умолчанию.
Конфигурация описана в `docker-compose.yml`
Запуск сервера осуществляется простой командой:
`docker compose up -d`
После изменений в образе, запуск производится с пересборкой образа:
`docker compose up -d --build --force-recreate`
Для быстрого удобного запуска предусмотрен `скрипт run.prod.sh`, который выполняет команду выше.
#### Development mode:
Запуск сервера в режиме разработки. Требуется указать соответствующий образ:
`docker compose -f docker-compose.dev.yml up -d`
После изменений в образе, запуск производится с пересборкой образа:
`docker compose -f docker-compose.dev.yml up -d --build --force-recreate`
Для быстрого удобного запуска предусмотрен скрипт `run.dev.sh`, который выполняет команду выше.

8
users/config.env Executable file
View File

@ -0,0 +1,8 @@
PORT=3003
URL=/users/api
POSTGRES_HOST=postgres
POSTGRES_PORT=5432
POSTGRES_USER=postgres
POSTGRES_DB=postgres
POSTGRES_PASSWORD=2wroxrnr8fdxicvw2nsd

23
users/config/config.js Executable file
View File

@ -0,0 +1,23 @@
module.exports = {
development: {
username: process.env.POSTGRES_USER,
password: process.env.POSTGRES_PASSWORD,
database: process.env.POSTGRES_DB,
host: process.env.POSTGRES_HOST,
dialect: "postgres"
},
test: {
username: process.env.POSTGRES_USER,
password: process.env.POSTGRES_PASSWORD,
database: process.env.POSTGRES_DB,
host: process.env.POSTGRES_HOST,
dialect: "postgres"
},
production: {
username: process.env.POSTGRES_USER,
password: process.env.POSTGRES_PASSWORD,
database: process.env.POSTGRES_DB,
host: process.env.POSTGRES_HOST,
dialect: "postgres"
}
};

30
users/docker-compose.dev.yml Executable file
View File

@ -0,0 +1,30 @@
services:
users-dev:
container_name: users-dev
build:
context: .
target: 'develop-stage'
env_file:
- ./config.env
environment:
NODE_ENV: development
volumes:
- .:/app:rw
command: sh -c "npm install && npm run start:dev"
restart: unless-stopped
networks:
- proxynet
labels:
- "traefik.enable=true"
- "traefik.http.routers.users.rule=PathPrefix(`/users/api`)"
- "traefik.http.routers.users.entrypoints=http"
- "traefik.http.routers.users.service=users-service"
- "traefik.http.services.users-service.loadbalancer.server.port=3003"
logging:
driver: "json-file"
options:
max-size: "1m"
networks:
proxynet:
external: true

28
users/docker-compose.yml Executable file
View File

@ -0,0 +1,28 @@
services:
users:
container_name: users
build:
context: .
target: 'production-stage'
env_file:
- ./config.env
environment:
NODE_ENV: production
command: ["node", "dist/main.js"]
restart: unless-stopped
networks:
- proxynet
labels:
- "traefik.enable=true"
- "traefik.http.routers.users.rule=PathPrefix(`/users/api`)"
- "traefik.http.routers.users.entrypoints=http"
- "traefik.http.routers.users.service=users-service"
- "traefik.http.services.users-service.loadbalancer.server.port=3003"
logging:
driver: "json-file"
options:
max-size: "1m"
networks:
proxynet:
external: true

8
users/migrate.dev.sh Executable file
View File

@ -0,0 +1,8 @@
#!/bin/sh
if [ "$(id -u)" != "0" ]; then
echo -e "\033[31mThis script requires superuser rights.\033[0m"
exit 0
fi
sudo docker exec -it users-dev npx sequelize db:migrate

8
users/migrate.prod.sh Executable file
View File

@ -0,0 +1,8 @@
#!/bin/sh
if [ "$(id -u)" != "0" ]; then
echo -e "\033[31mThis script requires superuser rights.\033[0m"
exit 0
fi
sudo docker exec -it users npx sequelize db:migrate

View File

@ -0,0 +1,33 @@
'use strict';
module.exports = {
up: async (queryInterface) => {
const count = 1000000;
const chunkSize = 10000;
for (let i = 0; i < count; i += chunkSize) {
const users = Array.from(
{ length: Math.min(chunkSize, count - i) },
() => ({
firstName:
'Firstname_' +
(Math.floor(Math.random() * 9_000_000_000) + 1_000_000_000),
lastName:
'Lastname_' +
(Math.floor(Math.random() * 9_000_000_000) + 1_000_000_000),
birthday: '2006-11-19 19:00:00.000 +00:00',
sex: Math.floor(Math.random() * 2) + 1,
problems: Math.random() < 0.5,
createdAt: '2006-11-19 19:00:00.000 +00:00',
updatedAt: '2006-11-19 19:00:00.000 +00:00',
}),
);
await queryInterface.bulkInsert('user', users, {});
}
},
down: async (queryInterface) => {
await queryInterface.bulkDelete('user', null, {});
},
};

8
users/nest-cli.json Normal file
View File

@ -0,0 +1,8 @@
{
"$schema": "https://json.schemastore.org/nest-cli",
"collection": "@nestjs/schematics",
"sourceRoot": "src",
"compilerOptions": {
"deleteOutDir": true
}
}

10601
users/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

84
users/package.json Normal file
View File

@ -0,0 +1,84 @@
{
"name": "nest-typescript-starter",
"private": true,
"version": "1.0.0",
"description": "Nest TypeScript starter repository",
"license": "MIT",
"scripts": {
"build": "nest build",
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
"start": "nest start",
"start:dev": "nest start --watch",
"start:debug": "nest start --debug --watch",
"start:prod": "node dist/main",
"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/jest/bin/jest --runInBand",
"test:e2e": "jest --config ./test/jest-e2e.json"
},
"engines": {
"npm": ">=8.0.0",
"node": ">=16.0.0"
},
"dependencies": {
"@nestjs/common": "^10.3.2",
"@nestjs/core": "^10.4.4",
"@nestjs/platform-express": "^10.4.4",
"reflect-metadata": "^0.2.1",
"rxjs": "^7.8.1",
"@nestjs/sequelize": "^10.0.1",
"sequelize": "^6.37.5",
"sequelize-typescript": "^2.1.6",
"pg": "^8.13.1",
"@nestjs/swagger": "^8.0.7",
"swagger-ui-express": "^5.0.1",
"class-validator": "^0.14.1",
"class-transformer": "^0.5.1"
},
"devDependencies": {
"@nestjs/cli": "^10.4.5",
"@nestjs/schematics": "^10.1.0",
"@nestjs/testing": "^10.3.2",
"@swc/cli": "^0.3.9",
"@swc/core": "^1.4.0",
"@types/express": "^4.17.21",
"@types/jest": "^29.5.12",
"@types/node": "^20.11.16",
"@types/supertest": "^6.0.2",
"@typescript-eslint/eslint-plugin": "^6.21.0",
"@typescript-eslint/parser": "^6.21.0",
"eslint": "^8.56.0",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-prettier": "^5.1.3",
"jest": "^29.7.0",
"prettier": "^3.2.5",
"source-map-support": "^0.5.21",
"supertest": "^6.3.4",
"ts-jest": "^29.1.2",
"ts-loader": "^9.5.1",
"ts-node": "^10.9.2",
"tsconfig-paths": "^4.2.0",
"typescript": "^5.3.3",
"@types/sequelize": "^4.28.20",
"sequelize-cli": "^6.6.2"
},
"jest": {
"moduleFileExtensions": [
"js",
"json",
"ts"
],
"rootDir": "src",
"testRegex": ".*\\.spec\\.ts$",
"transform": {
"^.+\\.(t|j)s$": "ts-jest"
},
"collectCoverageFrom": [
"**/*.(t|j)s"
],
"coverageDirectory": "../coverage",
"testEnvironment": "node"
}
}

8
users/run.dev.sh Executable file
View File

@ -0,0 +1,8 @@
#!/bin/bash
if [ "$(id -u)" != "0" ]; then
echo -e "\033[31mThis script requires superuser rights.\033[0m"
exit 0
fi
sudo docker compose -f docker-compose.dev.yml up --build --force-recreate

8
users/run.prod.sh Executable file
View File

@ -0,0 +1,8 @@
#!/bin/bash
if [ "$(id -u)" != "0" ]; then
echo -e "\033[31mThis script requires superuser rights.\033[0m"
exit 0
fi
sudo docker compose up -d --build --force-recreate

View File

@ -0,0 +1,21 @@
import { Test, TestingModule } from '@nestjs/testing';
import { AppController } from './app.controller';
import { AppService } from './app.service';
describe('AppController', () => {
let app: TestingModule;
beforeAll(async () => {
app = await Test.createTestingModule({
controllers: [AppController],
providers: [AppService],
}).compile();
});
describe('getHello', () => {
it('should return "Index Page!"', () => {
const appController = app.get(AppController);
expect(appController.getHello()).toBe('Index Page');
});
});
});

View File

@ -0,0 +1,12 @@
import { Controller, Get } from '@nestjs/common';
import { AppService } from './app.service';
@Controller()
export class AppController {
constructor(private readonly appService: AppService) {}
@Get()
getHello(): string {
return this.appService.getHello();
}
}

26
users/src/app.module.ts Normal file
View File

@ -0,0 +1,26 @@
import { Module } from '@nestjs/common';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { SequelizeModule } from '@nestjs/sequelize';
import { UsersModule } from './users/user.module';
import { User } from './users/user.model';
@Module({
imports: [
SequelizeModule.forRoot({
dialect: 'postgres',
host: process.env.POSTGRES_HOST,
port: +process.env.POSTGRES_PORT,
username: process.env.POSTGRES_USER,
password: process.env.POSTGRES_PASSWORD,
database: process.env.POSTGRES_DB,
synchronize: true,
autoLoadModels: true,
models: [User],
}),
UsersModule,
],
controllers: [AppController],
providers: [AppService],
})
export class AppModule {}

8
users/src/app.service.ts Normal file
View File

@ -0,0 +1,8 @@
import { Injectable } from '@nestjs/common';
@Injectable()
export class AppService {
getHello(): string {
return 'Index Page';
}
}

24
users/src/main.ts Normal file
View File

@ -0,0 +1,24 @@
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import { ValidationPipe } from '@nestjs/common';
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
const PORT = process.env.PORT || 3000;
app.setGlobalPrefix(process.env.URL);
const config = new DocumentBuilder()
.setTitle('Users')
.setDescription('Documentation REST API')
.setVersion('0.0.1')
.addTag('em-shop')
.build();
const document = SwaggerModule.createDocument(app, config);
SwaggerModule.setup('/users/api/docs', app, document);
app.useGlobalPipes(new ValidationPipe({ transform: true }));
await app.listen(PORT, () => console.log(`Server port: ${PORT}`));
}
bootstrap();

View File

@ -0,0 +1,40 @@
import { ApiProperty } from '@nestjs/swagger';
import {
IsString,
Length,
IsNumber,
IsBoolean,
IsNotEmpty,
} from 'class-validator';
export class CreateUserDto {
@ApiProperty({ example: 'Alice', description: 'First Name' })
@IsNotEmpty({ message: 'Укажите имя' })
@IsString({ message: 'Имя должно быть строкой' })
@Length(3, 20, { message: 'Имя не меньше 3 и не больше 20 символов' })
readonly firstName: string;
@ApiProperty({ example: 'Smith', description: 'Last Name' })
@IsNotEmpty({ message: 'Укажите фамилию' })
@IsString({ message: 'Фамилия должна быть строкой' })
@Length(3, 20, { message: 'Фамилия не меньше 3 и не больше 20 символов' })
readonly lastName: string;
@ApiProperty({
example: '2006-11-19 19:00:00.000 +00:00',
description: 'Birthday',
})
@IsNotEmpty({ message: 'Укажите дату рождения' })
@IsString({ message: 'Дата рождения должна быть строкой' })
@Length(0, 30, { message: 'Дата рождения не более 20 символов' })
readonly birthday: string;
@ApiProperty({ example: 1, description: 'Sex' })
@IsNotEmpty({ message: 'Укажите пол' })
@IsNumber({}, { message: 'Пол должен быть числом' })
readonly sex: number;
@ApiProperty({ example: true, description: 'Problems' })
@IsBoolean({ message: 'Проблема должна быть булевым значением' })
readonly problems: boolean;
}

View File

@ -0,0 +1,17 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsNumber, IsOptional } from 'class-validator';
import { Type } from 'class-transformer';
export class PaginationDto {
@ApiProperty({ example: 1, description: 'Номер страницы' })
@IsOptional()
@Type(() => Number)
@IsNumber({}, { message: 'Номер страницы должен быть числом' })
readonly page?: number;
@ApiProperty({ example: 10, description: 'Размер страницы' })
@IsOptional()
@Type(() => Number)
@IsNumber({}, { message: 'Размер страницы должен быть числом' })
readonly pageSize?: number;
}

View File

@ -0,0 +1,77 @@
import {
Controller,
Get,
Post,
Patch,
Delete,
Body,
Query,
} from '@nestjs/common';
import { UsersService } from './user.service';
import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger';
import { CreateUserDto } from './dto/create.user.dto';
import { PaginationDto } from './dto/pagination.dto';
import { User } from './user.model';
@ApiTags('Пользователи')
@Controller('users')
export class UsersController {
constructor(private readonly usersService: UsersService) {}
@ApiOperation({ summary: 'Создание нового пользователя' })
@ApiResponse({ status: 201 })
@Post('create')
async createUser(@Body() dto: CreateUserDto): Promise<User> {
const newUser = await this.usersService.createUser(
dto.firstName,
dto.lastName,
dto.birthday,
dto.sex,
dto.problems,
);
return newUser;
}
@ApiOperation({ summary: 'Заполнить таблицу с пользователями' })
@ApiResponse({ status: 201 })
@Post('create-all')
async createAll(@Body('count') count: number): Promise<{
executionTime: number;
recordsCreated: number;
}> {
return await this.usersService.createAll(count);
}
@ApiOperation({ summary: 'Получить список пользователей' })
@ApiResponse({ status: 200 })
@Get('/')
async getUsers(@Query() dto: PaginationDto): Promise<{
users: User[];
page: number;
pageSize: number;
totalPages: number;
totalUsers: number;
}> {
const users = await this.usersService.getUsers(dto.page, dto.pageSize);
return users;
}
@ApiOperation({ summary: 'Решить все проблемы у пользователей' })
@ApiResponse({ status: 201 })
@Patch('reset-problems')
async resetProblems(): Promise<{
solvedProblems: number;
executionTime: number;
}> {
const problems = await this.usersService.resetProblems();
return problems;
}
@ApiOperation({ summary: 'Очистить таблицу пользователей' })
@ApiResponse({ status: 200 })
@Delete('delete-all')
async deleteAll(): Promise<{ deleteAll: boolean }> {
return await this.usersService.deleteAll();
}
}

View File

@ -0,0 +1,3 @@
export interface Sex {
value: 0 | 1 | 2;
}

48
users/src/users/user.model.ts Executable file
View File

@ -0,0 +1,48 @@
import { Column, DataType, Model, Table } from 'sequelize-typescript';
import { ApiProperty } from '@nestjs/swagger';
interface UserCreationAttrs {
firstName: string;
lastName: string;
birthday: string;
sex: number;
problems: boolean;
}
@Table({ tableName: 'user' })
export class User extends Model<User, UserCreationAttrs> {
@ApiProperty({ example: 1, description: 'Уникальный идентификатор' })
@Column({
type: DataType.INTEGER,
unique: true,
autoIncrement: true,
primaryKey: true,
})
id: number;
@ApiProperty({ example: 'Alice', description: 'First Name' })
@Column({ type: DataType.STRING, allowNull: false })
firstName: string;
@ApiProperty({ example: 'Smith', description: 'Last Name' })
@Column({ type: DataType.STRING, allowNull: false })
lastName: string;
@ApiProperty({
example: '2006-11-19 19:00:00.000 +00:00',
description: 'Birthday',
})
@Column(DataType.STRING)
birthday: string;
@ApiProperty({ example: 1, description: 'Sex' })
@Column({
type: DataType.INTEGER,
defaultValue: 0,
})
sex: number;
@ApiProperty({ example: true, description: 'Problems' })
@Column({ type: DataType.BOOLEAN, defaultValue: false })
problems: boolean;
}

12
users/src/users/user.module.ts Executable file
View File

@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { UsersService } from './user.service';
import { UsersController } from './user.controller';
import { SequelizeModule } from '@nestjs/sequelize';
import { User } from './user.model';
@Module({
controllers: [UsersController],
providers: [UsersService],
imports: [SequelizeModule.forFeature([User])],
})
export class UsersModule {}

128
users/src/users/user.service.ts Executable file
View File

@ -0,0 +1,128 @@
import { Injectable } from '@nestjs/common';
import { InjectModel } from '@nestjs/sequelize';
import { User } from './user.model';
import { Sequelize } from 'sequelize-typescript';
@Injectable()
export class UsersService {
constructor(
@InjectModel(User) private userRepository: typeof User,
private sequelize: Sequelize,
) {}
async createUser(
firstName: string,
lastName: string,
birthday: string,
sex: number,
problems: boolean,
): Promise<User> {
const user = await this.userRepository.create({
firstName,
lastName,
birthday,
sex,
problems,
});
return user;
}
async createAll(
count: number = 1000000,
): Promise<{ executionTime: number; recordsCreated: number }> {
const chunkSize = 10000;
let totalCreated = 0;
const startTime = Date.now();
for (let i = 0; i < count; i += chunkSize) {
const users = Array.from(
{ length: Math.min(chunkSize, count - i) },
() => ({
firstName:
'Firstname_' +
(Math.floor(Math.random() * 9_000_000_000) + 1_000_000_000),
lastName:
'Lastname_' +
(Math.floor(Math.random() * 9_000_000_000) + 1_000_000_000),
birthday: '2006-11-19 19:00:00.000 +00:00',
sex: Math.floor(Math.random() * 2) + 1,
problems: Math.random() < 0.5,
}),
);
const created = await this.userRepository.bulkCreate(users);
totalCreated += created.length;
}
const endTime = Date.now();
return {
executionTime: endTime - startTime,
recordsCreated: totalCreated,
};
}
async getUsers(
page: number = 1,
pageSize: number = 10,
): Promise<{
users: User[];
page: number;
pageSize: number;
totalPages: number;
totalUsers: number;
}> {
const offset = (page - 1) * pageSize;
const users = await this.userRepository.findAndCountAll({
limit: pageSize,
offset,
});
const totalPages = Math.ceil(users.count / pageSize);
return {
users: users.rows,
page,
pageSize,
totalPages,
totalUsers: users.count,
};
}
async resetProblems(): Promise<{
solvedProblems: number;
executionTime: number;
}> {
const transaction = await this.sequelize.transaction();
try {
const startTime = Date.now();
const count = await this.userRepository.count({
where: {
problems: true,
},
transaction,
});
await this.userRepository.update(
{ problems: false },
{ where: {}, transaction },
);
await transaction.commit();
const endTime = Date.now();
return {
solvedProblems: count,
executionTime: endTime - startTime,
};
} catch (error) {
await transaction.rollback();
throw error;
}
}
async deleteAll(): Promise<{ deleteAll: boolean }> {
try {
await this.userRepository.destroy({ truncate: true });
return { deleteAll: true };
} catch (error) {
return { deleteAll: false };
}
}
}

View File

@ -0,0 +1,28 @@
import * as request from 'supertest';
import { Test } from '@nestjs/testing';
import { AppModule } from './../src/app.module';
import { INestApplication } from '@nestjs/common';
describe('AppController (e2e)', () => {
let app: INestApplication;
beforeAll(async () => {
const moduleFixture = await Test.createTestingModule({
imports: [AppModule],
}).compile();
app = moduleFixture.createNestApplication();
await app.init();
});
afterAll(async () => {
await app.close();
});
it('/ (GET)', () => {
return request(app.getHttpServer())
.get('/')
.expect(200)
.expect('Hello World!');
});
});

9
users/test/jest-e2e.json Normal file
View File

@ -0,0 +1,9 @@
{
"moduleFileExtensions": ["js", "json", "ts"],
"rootDir": ".",
"testEnvironment": "node",
"testRegex": "\\.e2e-spec\\.ts$",
"transform": {
"^.+\\.(t|j)s$": "ts-jest"
}
}

View File

@ -0,0 +1,4 @@
{
"extends": "./tsconfig.json",
"exclude": ["node_modules", "dist", "test", "**/*spec.ts"]
}

17
users/tsconfig.json Normal file
View File

@ -0,0 +1,17 @@
{
"compilerOptions": {
"module": "commonjs",
"declaration": true,
"removeComments": true,
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"allowSyntheticDefaultImports": true,
"target": "ES2021",
"sourceMap": true,
"outDir": "./dist",
"baseUrl": "./",
"incremental": true,
"forceConsistentCasingInFileNames": true,
"skipLibCheck": true
}
}