Compare commits
193 Commits
freetracke
...
feature/wo
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
919714089a | ||
|
|
7066252bcb | ||
| d477a0a5f1 | |||
| 4c35decfd7 | |||
| 599170df2c | |||
| 449aef6f54 | |||
| 1d4521b803 | |||
| fa860921da | |||
|
|
2480f7c376 | ||
|
|
414383163e | ||
|
|
f856d94596 | ||
|
|
dd75c54b32 | ||
|
|
f6f9163c3f | ||
|
|
4c166a8d33 | ||
| 284be82e1e | |||
| 41b5cb6fae | |||
| c4664edd7e | |||
| 69eddf47db | |||
| 71f3f353ab | |||
| 0d1dcf21c1 | |||
| 35493a09b5 | |||
| 390d97e6d5 | |||
| eca5cba858 | |||
| 6c190b80fb | |||
| a6065dd95c | |||
| 99127c42e2 | |||
| 599ccd1582 | |||
| 2b5e5564c8 | |||
| 7937be469b | |||
| 9f72d5885e | |||
| f65fd175ca | |||
|
|
d049c29f93 | ||
|
|
351ea75072 | ||
|
|
34163788f3 | ||
|
|
4ef4dd3c1b | ||
|
|
80498a0ff0 | ||
|
|
00386cc135 | ||
|
|
f5faae7907 | ||
|
|
659f9fd684 | ||
|
|
256de78e64 | ||
|
|
1500486cd8 | ||
|
|
63a825f153 | ||
|
|
1383e360a1 | ||
|
|
ca01d1c538 | ||
|
|
a315c8d4ef | ||
|
|
5ac9559b8f | ||
|
|
7b9e7d0a99 | ||
|
|
63b25928ff | ||
|
|
7d3b563759 | ||
|
|
baba20c028 | ||
|
|
87a9b8b02d | ||
|
|
cc41fa73cd | ||
|
|
ba923b9f91 | ||
|
|
cede47157e | ||
|
|
279c4fc86d | ||
|
|
c2f8d6ecee | ||
|
|
b4858efa73 | ||
|
|
6154932d9e | ||
|
|
d4cc85f644 | ||
|
|
82e8b785c4 | ||
|
|
5785e50cc5 | ||
|
|
de101348fc | ||
|
|
f442544912 | ||
|
|
d09dbcb697 | ||
|
|
f25bae1a08 | ||
|
|
800b60fb6d | ||
|
|
36558dfb85 | ||
|
|
c11bcd5d26 | ||
|
|
8450cc2d4d | ||
|
|
b1a9ee1403 | ||
|
|
80b9d9c8c8 | ||
|
|
db6665736a | ||
|
|
81980fa011 | ||
|
|
ac5f3eee96 | ||
|
|
9d87f7479c | ||
|
|
3639524fc7 | ||
|
|
f66114b22f | ||
|
|
8090de8031 | ||
|
|
081d663711 | ||
|
|
4fe16e5aa8 | ||
|
|
1fd5495570 | ||
|
|
9d68ee735a | ||
|
|
076e51c53a | ||
|
|
409a315a25 | ||
|
|
7a3264d43d | ||
|
|
effa320fa8 | ||
|
|
cc2a66367d | ||
|
|
989b5b010e | ||
|
|
f0e7ba94d2 | ||
|
|
3739fc8449 | ||
|
|
a74d191b30 | ||
|
|
a391cc88c9 | ||
|
|
12f8e63390 | ||
|
|
37238a1385 | ||
|
|
48cd044131 | ||
|
|
5665c4bf1e | ||
|
|
ad35d47ff5 | ||
|
|
f13cdd82df | ||
|
|
d6ebe10421 | ||
|
|
6e59e801b0 | ||
|
|
5dafd60299 | ||
|
|
825d7f1dd2 | ||
|
|
a3ea53c2f0 | ||
|
|
f37f34d803 | ||
|
|
bd0b11dc4a | ||
|
|
b36106cc8c | ||
|
|
07d35c4516 | ||
|
|
471cbacb66 | ||
|
|
229b181972 | ||
|
|
72615c7b98 | ||
|
|
45cafbee91 | ||
|
|
580651094f | ||
|
|
0ee92e98b2 | ||
|
|
3d8d9ee171 | ||
|
|
bde67dc7c3 | ||
|
|
a7be793608 | ||
|
|
ca81e19d14 | ||
|
|
7bd82fedce | ||
|
|
1aeb62d490 | ||
|
|
5886270e29 | ||
|
|
8f544d5c99 | ||
|
|
8dd8ec8930 | ||
|
|
3af82f7478 | ||
|
|
39a62818e9 | ||
|
|
24ff712306 | ||
|
|
ec6b30e220 | ||
|
|
548dbfcc9d | ||
|
|
09174abaa4 | ||
|
|
7ecb73ac6e | ||
|
|
8ade320440 | ||
|
|
bffa3fa2a3 | ||
|
|
4cf29c97b9 | ||
|
|
9377771531 | ||
|
|
0a96a87f94 | ||
|
|
5c14212429 | ||
|
|
e49d38657d | ||
|
|
1c7d1fc1ae | ||
|
|
7503d076e8 | ||
|
|
04f70aaa45 | ||
|
|
7b2b7b477f | ||
|
|
da7e25d339 | ||
|
|
b9f6e4d7aa | ||
|
|
396633932b | ||
|
|
46ad6ea9f3 | ||
|
|
1fa09ecac3 | ||
|
|
18b33ae10a | ||
|
|
e4e00184a5 | ||
|
|
9177765e8c | ||
|
|
0c0c62fe1b | ||
|
|
a0c9c5bab1 | ||
|
|
01b6e4ae72 | ||
|
|
2e36ee6e8b | ||
|
|
18cfa427d2 | ||
|
|
904a227adb | ||
|
|
23e532b770 | ||
|
|
f658e1f828 | ||
|
|
0500497fc1 | ||
|
|
ea691536ac | ||
|
|
c251a640b6 | ||
|
|
8031938b2f | ||
|
|
ca4bfdade4 | ||
|
|
b5f6f6d30f | ||
|
|
36107afbc2 | ||
|
|
539b1d2277 | ||
|
|
a9490da5a6 | ||
|
|
845e57d688 | ||
|
|
6835c84cc4 | ||
|
|
337e3ee2bf | ||
|
|
72d298ef2f | ||
|
|
d410164941 | ||
|
|
6b5ae7bce1 | ||
|
|
d80c4efb49 | ||
|
|
ddcf27b022 | ||
|
|
26c53e7455 | ||
|
|
0fbbe33e8a | ||
|
|
687508d26f | ||
|
|
f89729dbeb | ||
|
|
d90fee82d5 | ||
|
|
bde6ab4c7a | ||
|
|
2d0b97be44 | ||
|
|
3c22354130 | ||
|
|
ab555cd70e | ||
|
|
95bcaf3c5e | ||
|
|
48167530fd | ||
|
|
f909d90b6f | ||
|
|
e7d114a9d9 | ||
|
|
b83e0d603c | ||
|
|
7f57b2a4d3 | ||
|
|
c8f7e47181 | ||
|
|
e5d6b7cecd | ||
|
|
8a1868482c | ||
|
|
1bf68cea08 | ||
|
|
110e8300a1 |
9
.env.example
Normal file
9
.env.example
Normal file
@@ -0,0 +1,9 @@
|
||||
# Application settings
|
||||
TZ=Europe/Moscow
|
||||
APP_PORT=8044
|
||||
|
||||
MONGO_INITDB_ROOT_USERNAME=qqq
|
||||
MONGO_INITDB_ROOT_PASSWORD=qqq
|
||||
|
||||
# MongoDB connection string
|
||||
MONGO_ADDR=mongodb://qqq:qqq@127.0.0.1:27018
|
||||
138
.gitea/workflows/check.yaml
Normal file
138
.gitea/workflows/check.yaml
Normal file
@@ -0,0 +1,138 @@
|
||||
name: Code Quality Checks
|
||||
run-name: Проверка кода (lint & typecheck) от ${{ gitea.actor }}
|
||||
on: [push]
|
||||
|
||||
jobs:
|
||||
lint-and-typecheck:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out repository code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: 'npm'
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Run ESLint
|
||||
run: npm run eslint
|
||||
|
||||
- name: Run TypeScript type check
|
||||
run: npx tsc --noEmit
|
||||
|
||||
- name: Run tests
|
||||
run: npm test
|
||||
|
||||
smoke-test:
|
||||
runs-on: ubuntu-latest
|
||||
needs: lint-and-typecheck
|
||||
steps:
|
||||
- name: Check out repository code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: 'npm'
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Start MongoDB
|
||||
run: |
|
||||
docker run -d \
|
||||
--name mongodb-smoke-test \
|
||||
-p 27017:27017 \
|
||||
-e MONGO_INITDB_ROOT_USERNAME=admin \
|
||||
-e MONGO_INITDB_ROOT_PASSWORD=password \
|
||||
mongo:8.0.3
|
||||
|
||||
- name: Wait for MongoDB to be ready
|
||||
run: |
|
||||
timeout=30
|
||||
elapsed=0
|
||||
while ! docker exec mongodb-smoke-test mongosh --eval "db.adminCommand('ping')" --quiet > /dev/null 2>&1; do
|
||||
if [ $elapsed -ge $timeout ]; then
|
||||
echo "MongoDB не запустился за $timeout секунд"
|
||||
exit 1
|
||||
fi
|
||||
echo "Ожидание запуска MongoDB... ($elapsed/$timeout)"
|
||||
sleep 2
|
||||
elapsed=$((elapsed + 2))
|
||||
done
|
||||
echo "MongoDB готов"
|
||||
|
||||
- name: Start application
|
||||
env:
|
||||
MONGO_ADDR: mongodb://admin:password@localhost:27017/test_db?authSource=admin
|
||||
PORT: 8044
|
||||
NODE_ENV: test
|
||||
run: |
|
||||
npm start > app.log 2>&1 &
|
||||
echo $! > app.pid
|
||||
echo "Приложение запущено с PID: $(cat app.pid)"
|
||||
|
||||
- name: Wait for application to start
|
||||
run: |
|
||||
timeout=30
|
||||
elapsed=0
|
||||
while ! node -e "require('http').get('http://localhost:8044', (r) => process.exit(r.statusCode === 200 ? 0 : 1)).on('error', () => process.exit(1))" 2>/dev/null; do
|
||||
if [ $elapsed -ge $timeout ]; then
|
||||
echo "Приложение не запустилось за $timeout секунд"
|
||||
cat app.log
|
||||
exit 1
|
||||
fi
|
||||
echo "Ожидание запуска приложения... ($elapsed/$timeout)"
|
||||
sleep 2
|
||||
elapsed=$((elapsed + 2))
|
||||
done
|
||||
echo "Приложение запущено"
|
||||
|
||||
- name: Check application stability (30 seconds)
|
||||
run: |
|
||||
duration=30
|
||||
elapsed=0
|
||||
while [ $elapsed -lt $duration ]; do
|
||||
if ! kill -0 $(cat app.pid) 2>/dev/null; then
|
||||
echo "❌ Приложение упало через $elapsed секунд"
|
||||
cat app.log
|
||||
exit 1
|
||||
fi
|
||||
if ! node -e "require('http').get('http://localhost:8044', (r) => process.exit(r.statusCode === 200 ? 0 : 1)).on('error', () => process.exit(1))" 2>/dev/null; then
|
||||
echo "❌ Приложение не отвечает через $elapsed секунд"
|
||||
cat app.log
|
||||
exit 1
|
||||
fi
|
||||
echo "✓ Приложение работает ($elapsed/$duration секунд)"
|
||||
sleep 5
|
||||
elapsed=$((elapsed + 5))
|
||||
done
|
||||
echo "✅ Приложение стабильно работает в течение $duration секунд"
|
||||
|
||||
- name: Stop application
|
||||
if: always()
|
||||
run: |
|
||||
if [ -f app.pid ]; then
|
||||
pid=$(cat app.pid)
|
||||
if kill -0 $pid 2>/dev/null; then
|
||||
echo "Остановка приложения (PID: $pid)"
|
||||
kill -TERM $pid || true
|
||||
sleep 3
|
||||
if kill -0 $pid 2>/dev/null; then
|
||||
echo "Принудительная остановка приложения"
|
||||
kill -9 $pid 2>/dev/null || true
|
||||
fi
|
||||
fi
|
||||
rm -f app.pid
|
||||
fi
|
||||
|
||||
- name: Stop MongoDB
|
||||
if: always()
|
||||
run: |
|
||||
docker stop mongodb-smoke-test || true
|
||||
docker rm mongodb-smoke-test || true
|
||||
34
Dockerfile
34
Dockerfile
@@ -1,16 +1,38 @@
|
||||
FROM node:20
|
||||
FROM node:22 AS builder
|
||||
|
||||
WORKDIR /usr/src/app/
|
||||
|
||||
# Сначала копируем только файлы, необходимые для установки зависимостей
|
||||
COPY ./package.json /usr/src/app/package.json
|
||||
COPY ./package-lock.json /usr/src/app/package-lock.json
|
||||
|
||||
# Устанавливаем все зависимости
|
||||
RUN npm ci
|
||||
|
||||
# Затем копируем исходный код проекта и файлы конфигурации
|
||||
COPY ./tsconfig.json /usr/src/app/tsconfig.json
|
||||
COPY ./server /usr/src/app/server
|
||||
|
||||
# Сборка проекта
|
||||
RUN npm run build
|
||||
|
||||
# Вторая стадия - рабочий образ
|
||||
FROM node:22
|
||||
|
||||
RUN mkdir -p /usr/src/app/server/log/
|
||||
WORKDIR /usr/src/app/
|
||||
|
||||
COPY ./server /usr/src/app/server
|
||||
# Копирование только package.json/package-lock.json для продакшн зависимостей
|
||||
COPY ./package.json /usr/src/app/package.json
|
||||
COPY ./package-lock.json /usr/src/app/package-lock.json
|
||||
COPY ./.serverrc.js /usr/src/app/.serverrc.js
|
||||
# COPY ./.env /usr/src/app/.env
|
||||
|
||||
# RUN npm i --omit=dev
|
||||
RUN npm ci
|
||||
# Установка только продакшн зависимостей
|
||||
RUN npm ci --production
|
||||
|
||||
# Копирование собранного приложения из билдера
|
||||
COPY --from=builder /usr/src/app/dist /usr/src/app/dist
|
||||
COPY --from=builder /usr/src/app/server /usr/src/app/server
|
||||
|
||||
EXPOSE 8044
|
||||
|
||||
CMD ["npm", "run", "up:prod"]
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
#!/bin/sh
|
||||
|
||||
docker stop ms-mongo
|
||||
docker volume remove ms_volume
|
||||
docker volume create ms_volume
|
||||
docker run --rm -v ms_volume:/data/db --name ms-mongo -p 27017:27017 -d mongo:8.0.3
|
||||
docker volume remove ms_volume8
|
||||
docker volume create ms_volume8
|
||||
docker run --rm \
|
||||
-v ms_volume8:/data/db \
|
||||
--name ms-mongo \
|
||||
-p 27018:27017 \
|
||||
-e MONGO_INITDB_ROOT_USERNAME=qqq \
|
||||
-e MONGO_INITDB_ROOT_PASSWORD=qqq \
|
||||
-d mongo:8.0.3
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
version: "3"
|
||||
|
||||
volumes:
|
||||
ms_volume8:
|
||||
ms_logs:
|
||||
|
||||
services:
|
||||
mongoDb:
|
||||
image: mongo:8.0.3
|
||||
volumes:
|
||||
- ms_volume8:/data/db
|
||||
restart: always
|
||||
# ports:
|
||||
# - 27017:27017
|
||||
multy-stubs:
|
||||
# build: .
|
||||
image: bro.js/ms/bh:$TAG
|
||||
restart: always
|
||||
volumes:
|
||||
- ms_logs:/usr/src/app/server/log
|
||||
ports:
|
||||
- 8044:8044
|
||||
environment:
|
||||
- TZ=Europe/Moscow
|
||||
- MONGO_ADDR=mongodb
|
||||
30
docker-compose.yml
Normal file
30
docker-compose.yml
Normal file
@@ -0,0 +1,30 @@
|
||||
version: "3"
|
||||
|
||||
volumes:
|
||||
ms_volume8:
|
||||
ms_logs:
|
||||
|
||||
services:
|
||||
multy-stubs:
|
||||
image: bro.js/ms/bh:$TAG
|
||||
restart: always
|
||||
volumes:
|
||||
- ms_logs:/usr/src/app/server/log
|
||||
ports:
|
||||
- 8044:8044
|
||||
environment:
|
||||
- TZ=Europe/Moscow
|
||||
- MONGO_ADDR=${MONGO_ADDR}
|
||||
# depends_on:
|
||||
# mongoDb:
|
||||
# condition: service_started
|
||||
# mongoDb:
|
||||
# image: mongo:8.0.3
|
||||
# volumes:
|
||||
# - ms_volume8:/data/db
|
||||
# restart: always
|
||||
# environment:
|
||||
# - MONGO_INITDB_ROOT_USERNAME=${MONGO_INITDB_ROOT_USERNAME}
|
||||
# - MONGO_INITDB_ROOT_PASSWORD=${MONGO_INITDB_ROOT_PASSWORD}
|
||||
# ports:
|
||||
# - 27018:27017
|
||||
@@ -4,7 +4,7 @@ import pluginJs from "@eslint/js";
|
||||
|
||||
export default [
|
||||
{ ignores: ['server/routers/old/*'] },
|
||||
{ files: ["**/*.js"], languageOptions: { sourceType: "commonjs" } },
|
||||
{ files: ["**/*.js"], languageOptions: { } },
|
||||
{ languageOptions: { globals: globals.node } },
|
||||
pluginJs.configs.recommended,
|
||||
{
|
||||
|
||||
145
jest.config.js
145
jest.config.js
@@ -1,43 +1,43 @@
|
||||
/**
|
||||
* For a detailed explanation regarding each configuration property, visit:
|
||||
* Для подробного объяснения каждого свойства конфигурации, посетите:
|
||||
* https://jestjs.io/docs/configuration
|
||||
*/
|
||||
|
||||
/** @type {import('jest').Config} */
|
||||
const config = {
|
||||
// All imported modules in your tests should be mocked automatically
|
||||
// Все импортированные модули в тестах должны быть автоматически замоканы
|
||||
// automock: false,
|
||||
|
||||
// Stop running tests after `n` failures
|
||||
// Остановить выполнение тестов после `n` неудач
|
||||
// bail: 0,
|
||||
|
||||
// The directory where Jest should store its cached dependency information
|
||||
// Директория, где Jest должен хранить кэшированную информацию о зависимостях
|
||||
// cacheDirectory: "C:\\Users\\alex\\AppData\\Local\\Temp\\jest",
|
||||
|
||||
// Automatically clear mock calls, instances, contexts and results before every test
|
||||
// Автоматически очищать вызовы моков, экземпляры, контексты и результаты перед каждым тестом
|
||||
clearMocks: true,
|
||||
|
||||
// Indicates whether the coverage information should be collected while executing the test
|
||||
// Указывает, должна ли собираться информация о покрытии во время выполнения тестов
|
||||
collectCoverage: true,
|
||||
|
||||
// An array of glob patterns indicating a set of files for which coverage information should be collected
|
||||
// Массив glob-паттернов, указывающих набор файлов, для которых должна собираться информация о покрытии
|
||||
collectCoverageFrom: [
|
||||
"<rootDir>/server/routers/**/*.js"
|
||||
],
|
||||
|
||||
// The directory where Jest should output its coverage files
|
||||
// Директория, куда Jest должен выводить файлы покрытия
|
||||
coverageDirectory: "coverage",
|
||||
|
||||
// An array of regexp pattern strings used to skip coverage collection
|
||||
// Массив строк regexp-паттернов, используемых для пропуска сбора покрытия
|
||||
coveragePathIgnorePatterns: [
|
||||
"\\\\node_modules\\\\",
|
||||
"<rootDir>/server/routers/old"
|
||||
],
|
||||
|
||||
// Indicates which provider should be used to instrument code for coverage
|
||||
// Указывает, какой провайдер должен использоваться для инструментирования кода для покрытия
|
||||
coverageProvider: "v8",
|
||||
|
||||
// A list of reporter names that Jest uses when writing coverage reports
|
||||
// Список имен репортеров, которые Jest использует при записи отчетов о покрытии
|
||||
// coverageReporters: [
|
||||
// "json",
|
||||
// "text",
|
||||
@@ -45,156 +45,159 @@ const config = {
|
||||
// "clover"
|
||||
// ],
|
||||
|
||||
// An object that configures minimum threshold enforcement for coverage results
|
||||
// Объект, который настраивает принудительное применение минимальных порогов для результатов покрытия
|
||||
// coverageThreshold: undefined,
|
||||
|
||||
// A path to a custom dependency extractor
|
||||
// Путь к пользовательскому извлекателю зависимостей
|
||||
// dependencyExtractor: undefined,
|
||||
|
||||
// Make calling deprecated APIs throw helpful error messages
|
||||
// Заставить вызовы устаревших API выбрасывать полезные сообщения об ошибках
|
||||
// errorOnDeprecated: false,
|
||||
|
||||
// The default configuration for fake timers
|
||||
// Конфигурация по умолчанию для поддельных таймеров
|
||||
// fakeTimers: {
|
||||
// "enableGlobally": false
|
||||
// },
|
||||
|
||||
// Force coverage collection from ignored files using an array of glob patterns
|
||||
// Принудительно собирать покрытие из игнорируемых файлов, используя массив glob-паттернов
|
||||
// forceCoverageMatch: [],
|
||||
|
||||
// A path to a module which exports an async function that is triggered once before all test suites
|
||||
// Путь к модулю, который экспортирует асинхронную функцию, вызываемую один раз перед всеми наборами тестов
|
||||
// globalSetup: undefined,
|
||||
|
||||
// A path to a module which exports an async function that is triggered once after all test suites
|
||||
// Путь к модулю, который экспортирует асинхронную функцию, вызываемую один раз после всех наборов тестов
|
||||
// globalTeardown: undefined,
|
||||
|
||||
// A set of global variables that need to be available in all test environments
|
||||
// Набор глобальных переменных, которые должны быть доступны во всех тестовых окружениях
|
||||
// globals: {},
|
||||
|
||||
// The maximum amount of workers used to run your tests. Can be specified as % or a number. E.g. maxWorkers: 10% will use 10% of your CPU amount + 1 as the maximum worker number. maxWorkers: 2 will use a maximum of 2 workers.
|
||||
// Максимальное количество воркеров, используемых для запуска тестов. Может быть указано в % или числом. Например, maxWorkers: 10% будет использовать 10% от количества CPU + 1 в качестве максимального числа воркеров. maxWorkers: 2 будет использовать максимум 2 воркера.
|
||||
// maxWorkers: "50%",
|
||||
|
||||
// An array of directory names to be searched recursively up from the requiring module's location
|
||||
// Массив имен директорий, которые должны быть рекурсивно найдены вверх от местоположения требуемого модуля
|
||||
// moduleDirectories: [
|
||||
// "node_modules"
|
||||
// ],
|
||||
|
||||
// An array of file extensions your modules use
|
||||
// moduleFileExtensions: [
|
||||
// "js",
|
||||
// "mjs",
|
||||
// "cjs",
|
||||
// "jsx",
|
||||
// "ts",
|
||||
// "tsx",
|
||||
// "json",
|
||||
// "node"
|
||||
// ],
|
||||
// Массив расширений файлов, которые используют ваши модули
|
||||
moduleFileExtensions: [
|
||||
"js",
|
||||
"mjs",
|
||||
"cjs",
|
||||
"jsx",
|
||||
"ts",
|
||||
"tsx",
|
||||
"json",
|
||||
"node"
|
||||
],
|
||||
|
||||
// A map from regular expressions to module names or to arrays of module names that allow to stub out resources with a single module
|
||||
// Карта из регулярных выражений в имена модулей или массивы имен модулей, которые позволяют заглушить ресурсы одним модулем
|
||||
// moduleNameMapper: {},
|
||||
|
||||
// An array of regexp pattern strings, matched against all module paths before considered 'visible' to the module loader
|
||||
// Массив строк regexp-паттернов, сопоставляемых со всеми путями модулей перед тем, как они будут считаться 'видимыми' для загрузчика модулей
|
||||
// modulePathIgnorePatterns: [],
|
||||
|
||||
// Activates notifications for test results
|
||||
// Активирует уведомления для результатов тестов
|
||||
// notify: false,
|
||||
|
||||
// An enum that specifies notification mode. Requires { notify: true }
|
||||
// Перечисление, которое указывает режим уведомлений. Требует { notify: true }
|
||||
// notifyMode: "failure-change",
|
||||
|
||||
// A preset that is used as a base for Jest's configuration
|
||||
// preset: undefined,
|
||||
// Пресет, который используется в качестве основы для конфигурации Jest
|
||||
preset: 'ts-jest',
|
||||
|
||||
// Run tests from one or more projects
|
||||
// Запускать тесты из одного или нескольких проектов
|
||||
// projects: undefined,
|
||||
|
||||
// Use this configuration option to add custom reporters to Jest
|
||||
// Используйте эту опцию конфигурации для добавления пользовательских репортеров в Jest
|
||||
// reporters: undefined,
|
||||
|
||||
// Automatically reset mock state before every test
|
||||
// Автоматически сбрасывать состояние моков перед каждым тестом
|
||||
// resetMocks: false,
|
||||
|
||||
// Reset the module registry before running each individual test
|
||||
// Сбрасывать реестр модулей перед запуском каждого отдельного теста
|
||||
// resetModules: false,
|
||||
|
||||
// A path to a custom resolver
|
||||
// Путь к пользовательскому резолверу
|
||||
// resolver: undefined,
|
||||
|
||||
// Automatically restore mock state and implementation before every test
|
||||
// Автоматически восстанавливать состояние моков и реализацию перед каждым тестом
|
||||
// restoreMocks: false,
|
||||
|
||||
// The root directory that Jest should scan for tests and modules within
|
||||
// Корневая директория, которую Jest должен сканировать для поиска тестов и модулей
|
||||
// rootDir: undefined,
|
||||
|
||||
// A list of paths to directories that Jest should use to search for files in
|
||||
// Список путей к директориям, которые Jest должен использовать для поиска файлов
|
||||
// roots: [
|
||||
// "<rootDir>"
|
||||
// ],
|
||||
|
||||
// Allows you to use a custom runner instead of Jest's default test runner
|
||||
// Позволяет использовать пользовательский раннер вместо стандартного тестового раннера Jest
|
||||
// runner: "jest-runner",
|
||||
|
||||
// The paths to modules that run some code to configure or set up the testing environment before each test
|
||||
// Пути к модулям, которые выполняют некоторый код для настройки или подготовки тестового окружения перед каждым тестом
|
||||
// setupFiles: [],
|
||||
|
||||
// A list of paths to modules that run some code to configure or set up the testing framework before each test
|
||||
// Список путей к модулям, которые выполняют некоторый код для настройки или подготовки тестового фреймворка перед каждым тестом
|
||||
// setupFilesAfterEnv: [],
|
||||
|
||||
// The number of seconds after which a test is considered as slow and reported as such in the results.
|
||||
// Количество секунд, после которого тест считается медленным и сообщается как таковой в результатах.
|
||||
// slowTestThreshold: 5,
|
||||
|
||||
// A list of paths to snapshot serializer modules Jest should use for snapshot testing
|
||||
// Список путей к модулям сериализаторов снимков, которые Jest должен использовать для тестирования снимков
|
||||
// snapshotSerializers: [],
|
||||
|
||||
// The test environment that will be used for testing
|
||||
// testEnvironment: "jest-environment-node",
|
||||
// Тестовое окружение, которое будет использоваться для тестирования
|
||||
testEnvironment: "node",
|
||||
|
||||
// Options that will be passed to the testEnvironment
|
||||
// Опции, которые будут переданы в testEnvironment
|
||||
// testEnvironmentOptions: {},
|
||||
|
||||
// Adds a location field to test results
|
||||
// Добавляет поле местоположения к результатам тестов
|
||||
// testLocationInResults: false,
|
||||
|
||||
// The glob patterns Jest uses to detect test files
|
||||
// testMatch: [
|
||||
// "**/__tests__/**/*.[jt]s?(x)",
|
||||
// "**/?(*.)+(spec|test).[tj]s?(x)"
|
||||
// ],
|
||||
// Glob-паттерны, которые Jest использует для обнаружения тестовых файлов
|
||||
testMatch: [
|
||||
"**/__tests__/**/*.[jt]s?(x)",
|
||||
"**/?(*.)+(spec|test).[tj]s?(x)"
|
||||
],
|
||||
|
||||
// An array of regexp pattern strings that are matched against all test paths, matched tests are skipped
|
||||
// Массив строк regexp-паттернов, которые сопоставляются со всеми тестовыми путями, сопоставленные тесты пропускаются
|
||||
// testPathIgnorePatterns: [
|
||||
// "\\\\node_modules\\\\"
|
||||
// ],
|
||||
|
||||
// The regexp pattern or array of patterns that Jest uses to detect test files
|
||||
// Regexp-паттерн или массив паттернов, которые Jest использует для обнаружения тестовых файлов
|
||||
// testRegex: [],
|
||||
|
||||
// This option allows the use of a custom results processor
|
||||
// Эта опция позволяет использовать пользовательский процессор результатов
|
||||
// testResultsProcessor: undefined,
|
||||
|
||||
// This option allows use of a custom test runner
|
||||
// Эта опция позволяет использовать пользовательский тестовый раннер
|
||||
// testRunner: "jest-circus/runner",
|
||||
|
||||
// A map from regular expressions to paths to transformers
|
||||
// transform: undefined,
|
||||
// Карта из регулярных выражений в пути к трансформерам
|
||||
transform: {
|
||||
'^.+\\.ts$': 'ts-jest',
|
||||
'^.+\\.tsx$': 'ts-jest',
|
||||
},
|
||||
|
||||
// An array of regexp pattern strings that are matched against all source file paths, matched files will skip transformation
|
||||
// Массив строк regexp-паттернов, которые сопоставляются со всеми путями исходных файлов, сопоставленные файлы будут пропускать трансформацию
|
||||
// transformIgnorePatterns: [
|
||||
// "\\\\node_modules\\\\",
|
||||
// "\\.pnp\\.[^\\\\]+$"
|
||||
// ],
|
||||
|
||||
// An array of regexp pattern strings that are matched against all modules before the module loader will automatically return a mock for them
|
||||
// Массив строк regexp-паттернов, которые сопоставляются со всеми модулями перед тем, как загрузчик модулей автоматически вернет мок для них
|
||||
// unmockedModulePathPatterns: undefined,
|
||||
|
||||
// Indicates whether each individual test should be reported during the run
|
||||
// Указывает, должен ли каждый отдельный тест сообщаться во время выполнения
|
||||
verbose: true,
|
||||
|
||||
// An array of regexp patterns that are matched against all source file paths before re-running tests in watch mode
|
||||
// Массив regexp-паттернов, которые сопоставляются со всеми путями исходных файлов перед повторным запуском тестов в режиме наблюдения
|
||||
// watchPathIgnorePatterns: [],
|
||||
|
||||
// Whether to use watchman for file crawling
|
||||
// Использовать ли watchman для обхода файлов
|
||||
// watchman: true,
|
||||
};
|
||||
|
||||
|
||||
3362
package-lock.json
generated
3362
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
35
package.json
35
package.json
@@ -1,15 +1,13 @@
|
||||
{
|
||||
"name": "multi-stub",
|
||||
"version": "1.2.1",
|
||||
"version": "2.0.0",
|
||||
"description": "",
|
||||
"main": "index.js",
|
||||
"main": "server/index.ts",
|
||||
"type": "commonjs",
|
||||
"scripts": {
|
||||
"start": "cross-env PORT=8033 npx nodemon ./server",
|
||||
"up:prod": "cross-env NODE_ENV=\"production\" node ./server",
|
||||
"deploy:d:stop": "docker compose down",
|
||||
"deploy:d:build": "docker compose build",
|
||||
"deploy:d:up": "docker compose up -d",
|
||||
"redeploy": "npm run deploy:d:stop && npm run deploy:d:build && npm run deploy:d:up",
|
||||
"start": "cross-env NODE_ENV=\"development\" ts-node-dev .",
|
||||
"build": "tsc",
|
||||
"up:prod": "node dist/server/index.js",
|
||||
"eslint": "npx eslint ./server",
|
||||
"eslint:fix": "npx eslint ./server --fix",
|
||||
"test": "jest"
|
||||
@@ -23,9 +21,13 @@
|
||||
"license": "MIT",
|
||||
"homepage": "https://bitbucket.org/online-mentor/multi-stub#readme",
|
||||
"dependencies": {
|
||||
"@langchain/community": "^0.3.56",
|
||||
"@langchain/core": "^0.3.77",
|
||||
"@langchain/langgraph": "^0.4.9",
|
||||
"ai": "^4.1.13",
|
||||
"axios": "^1.7.7",
|
||||
"bcrypt": "^5.1.0",
|
||||
"bcryptjs": "^3.0.3",
|
||||
"body-parser": "^1.19.0",
|
||||
"cookie-parser": "^1.4.5",
|
||||
"cors": "^2.8.5",
|
||||
@@ -35,26 +37,33 @@
|
||||
"express": "5.0.1",
|
||||
"express-jwt": "^8.5.1",
|
||||
"express-session": "^1.18.1",
|
||||
"gigachat": "^0.0.16",
|
||||
"jsdom": "^25.0.1",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"mongodb": "^6.12.0",
|
||||
"mongoose": "^8.9.2",
|
||||
"langchain": "^0.3.34",
|
||||
"langchain-gigachat": "^0.0.14",
|
||||
"mongodb": "^6.20.0",
|
||||
"mongoose": "^8.18.2",
|
||||
"mongoose-sequence": "^6.0.1",
|
||||
"morgan": "^1.10.0",
|
||||
"morgan": "^1.10.1",
|
||||
"multer": "^1.4.5-lts.1",
|
||||
"pbkdf2-password": "^1.2.1",
|
||||
"rotating-file-stream": "^3.2.5",
|
||||
"socket.io": "^4.8.1",
|
||||
"uuid": "^11.0.3"
|
||||
"zod": "^3.24.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.17.0",
|
||||
"@types/jest": "^30.0.0",
|
||||
"@types/node": "22.10.2",
|
||||
"eslint": "^9.17.0",
|
||||
"globals": "^15.14.0",
|
||||
"jest": "^29.7.0",
|
||||
"mockingoose": "^2.16.2",
|
||||
"nodemon": "3.1.9",
|
||||
"supertest": "^7.0.0"
|
||||
"supertest": "^7.0.0",
|
||||
"ts-jest": "^29.4.6",
|
||||
"ts-node-dev": "2.0.0",
|
||||
"typescript": "5.7.3"
|
||||
}
|
||||
}
|
||||
|
||||
87
rules.md
Normal file
87
rules.md
Normal file
@@ -0,0 +1,87 @@
|
||||
## Правила оформления студенческих бэкендов в `multi-stub`
|
||||
|
||||
Этот документ описывает, как подключать новый студенческий бэкенд к общему серверу и как работать с JSON‑заглушками. Правила написаны так, чтобы их мог автоматически выполнять помощник Cursor.
|
||||
|
||||
### 1. Общая структура проекта студента
|
||||
|
||||
- **Размещение проекта**
|
||||
- Каждый студенческий бэкенд живёт в своей подпапке в `server/routers/<project-name>`.
|
||||
- В корне подпапки должен быть основной файл роутера `index.js` (или `index.ts`), который экспортирует `express.Router()`.
|
||||
- Подключение к общему серверу выполняется в `server/index.ts` через импорт и `app.use(<mountPath>, <router>)`.
|
||||
|
||||
- **Использование JSON‑заглушек**
|
||||
- Если проект переносится из фронтенд‑репозитория и должен только отдавать данные, то в подпапке проекта должна быть папка `json/` со всеми нужными `.json` файлами.
|
||||
- HTTP‑обработчики в роутере могут просто читать и возвращать содержимое этих файлов (например, через `require('./json/...')` или `import data from './json/...json'` с включённым `resolveJsonModule` / соответствующей конфигурацией bundler'а).
|
||||
|
||||
### 2. Правила для Cursor при указании директории заглушек
|
||||
|
||||
Когда пользователь явно указывает директорию с заглушками (например: `server/routers/<project-name>/json`), помощник Cursor должен последовательно выполнить следующие шаги.
|
||||
|
||||
- **2.1. Проверка валидности импортов JSON‑файлов**
|
||||
- Найти все `.js` / `.ts` файлы внутри подпапки проекта.
|
||||
- В каждом таком файле найти импорты/require, которые ссылаются на `.json` файлы (относительные пути вроде `'./json/.../file.json'`).
|
||||
- Для каждого такого импорта:
|
||||
- **Проверить, что файл реально существует** по указанному пути относительно файла-импортёра.
|
||||
- **Проверить расширение**: путь должен заканчиваться на `.json` (без опечаток).
|
||||
- **Проверить регистр и точное совпадение имени файла** (важно для кросс‑платформенности, даже если локально используется Windows).
|
||||
- Если найдены ошибки (файл не существует, опечатка в имени, неправильный относительный путь и т.п.):
|
||||
- Сформировать понятный список проблем: в каком файле, какая строка/импорт и что именно не так.
|
||||
- Предложить автоматически исправить пути (если по контексту можно однозначно угадать нужный `*.json` файл).
|
||||
|
||||
- **2.2. Проверка подключения основного роутера проекта**
|
||||
- Определить основной файл роутера проекта:
|
||||
- По умолчанию это `server/routers/<project-name>/index.js` (или `index.ts`).
|
||||
- Открыть `server/index.ts` и убедиться, что:
|
||||
- Есть импорт роутера из соответствующей подпапки, например:
|
||||
- `import <SomeUniqueName>Router from './routers/<project-name>'`
|
||||
- или `const <SomeUniqueName>Router = require('./routers/<project-name>')`
|
||||
- Имя переменной роутера **уникально** среди всех импортов роутеров (нет другого импорта с таким же именем).
|
||||
- Есть вызов `app.use('<mount-path>', <SomeUniqueName>Router)`:
|
||||
- `<mount-path>` должен быть осмысленным, совпадать с названием проекта или оговариваться пользователем.
|
||||
- Если импорт или `app.use` отсутствуют:
|
||||
- Сформировать предложение по добавлению корректного импорта и `app.use(...)`.
|
||||
- Убедиться, что используемое имя роутера не конфликтует с уже существующими.
|
||||
- Если обнаружен конфликт имён:
|
||||
- Предложить переименовать новый роутер в уникальное имя и обновить соответствующие места в `server/index.ts`.
|
||||
|
||||
### 3. Предложение «оживить» JSON‑заглушки
|
||||
|
||||
После того как проверка импортов и подключения роутера завершена, помощник Cursor должен **задать пользователю вопрос**, не хочет ли он превратить заглушки в полноценный бэкенд.
|
||||
|
||||
- **3.1. Формулировка предложения**
|
||||
- Спросить у пользователя примерно так:
|
||||
- «Обнаружены JSON‑заглушки в директории `<указанная-папка>`. Хотите, чтобы я попытался автоматически:
|
||||
1) построить модели данных (mongoose‑схемы) на основе структуры JSON;
|
||||
2) создать CRUD‑эндпоинты и/или более сложные маршруты, опираясь на существующие данные;
|
||||
3) заменить прямую отдачу `*.json` файлов на работу через базу данных?»
|
||||
|
||||
- **3.2. Поведение при согласии пользователя**
|
||||
- Проанализировать структуру JSON‑файлов:
|
||||
- Определить основные сущности и поля.
|
||||
- Выделить типы полей (строки, числа, даты, массивы, вложенные объекты и т.п.).
|
||||
- На основе анализа предложить:
|
||||
- Набор `mongoose`‑схем (`models`) с аккуратной сериализацией (виртуальное поле `id`, скрытие `_id` и `__v`).
|
||||
- Набор маршрутов `express` для работы с этими моделями (минимум: чтение списков и элементов; по возможности — создание/обновление/удаление).
|
||||
- Перед внесением изменений:
|
||||
- Показать пользователю краткий план того, какие файлы будут созданы/изменены.
|
||||
- Выполнить изменения только после явного подтверждения пользователя.
|
||||
|
||||
### 4. Минимальные требования к новому студенческому бэкенду
|
||||
|
||||
- **Обязательные элементы**
|
||||
- Подпапка в `server/routers/<project-name>`.
|
||||
- Основной роутер `index.js` / `index.ts`, экспортирующий `express.Router()`.
|
||||
- Подключение к общему серверу в `server/index.ts` (импорт + `app.use()` с уникальным именем роутера).
|
||||
|
||||
- **Если используются JSON‑заглушки**
|
||||
- Папка `json/` внутри проекта.
|
||||
- Все пути в импортирующих файлах должны указывать на реально существующие `*.json` файлы.
|
||||
- Не должно быть «магических» абсолютных путей; только относительные пути от файла до нужного JSON.
|
||||
|
||||
- **Если проект «оживлён»**
|
||||
- Папка `model/` с моделью(ями) данных (например, через `mongoose`).
|
||||
- Роуты, которые вместо прямой отдачи файлов работают с моделями и, при необходимости, с внешними сервисами.
|
||||
|
||||
Следуя этим правилам, можно подключать новые студенческие проекты в единый бэкенд, минимизировать типичные ошибки с путями к JSON и упростить автоматическое развитие заглушек до полноценного API.
|
||||
|
||||
|
||||
@@ -4,7 +4,6 @@ exports[`todo list app get list 1`] = `
|
||||
{
|
||||
"body": [
|
||||
{
|
||||
"_id": "670f69b5796ce7a9069da2f7",
|
||||
"created": "2024-10-16T07:22:29.042Z",
|
||||
"id": "670f69b5796ce7a9069da2f7",
|
||||
"items": [],
|
||||
|
||||
@@ -2,7 +2,7 @@ const { describe, it, expect } = require('@jest/globals')
|
||||
const request = require('supertest')
|
||||
const express = require('express')
|
||||
const mockingoose = require('mockingoose')
|
||||
const { ListModel } = require('../data/model/todo/list')
|
||||
const { ListModel } = require('../routers/todo/model/todo/list')
|
||||
|
||||
const todo = require('../routers/todo/routes')
|
||||
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
const noToken = 'No authorization token was found'
|
||||
|
||||
module.exports = (err, req, res, next) => {
|
||||
if (err.message === noToken) {
|
||||
res.status(400).send({
|
||||
success: false, error: 'Токен авторизации не найден',
|
||||
})
|
||||
}
|
||||
|
||||
res.status(400).send({
|
||||
success: false, error: err.message || 'Что-то пошло не так',
|
||||
})
|
||||
}
|
||||
28
server/error.ts
Normal file
28
server/error.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { ErrorLog } from './models/ErrorLog'
|
||||
|
||||
const noToken = 'No authorization token was found'
|
||||
|
||||
export const errorHandler = (err, req, res, next) => {
|
||||
// Сохраняем ошибку в базу данных
|
||||
const errorLog = new ErrorLog({
|
||||
message: err.message || 'Неизвестная ошибка',
|
||||
stack: err.stack,
|
||||
path: req.path,
|
||||
method: req.method,
|
||||
query: req.query,
|
||||
body: req.body
|
||||
})
|
||||
|
||||
errorLog.save()
|
||||
.catch(saveErr => console.error('Ошибка при сохранении лога ошибки:', saveErr))
|
||||
|
||||
if (err.message === noToken) {
|
||||
res.status(400).send({
|
||||
success: false, error: 'Токен авторизации не найден',
|
||||
})
|
||||
}
|
||||
|
||||
res.status(400).send({
|
||||
success: false, error: err.message || 'Что-то пошло не так',
|
||||
})
|
||||
}
|
||||
@@ -1,99 +0,0 @@
|
||||
const express = require("express")
|
||||
const bodyParser = require("body-parser")
|
||||
const cookieParser = require("cookie-parser")
|
||||
const session = require("express-session")
|
||||
const morgan = require("morgan")
|
||||
const path = require("path")
|
||||
const rfs = require("rotating-file-stream")
|
||||
|
||||
const app = express()
|
||||
require("dotenv").config()
|
||||
exports.app = app
|
||||
|
||||
const accessLogStream = rfs.createStream("access.log", {
|
||||
size: "10M",
|
||||
interval: "1d",
|
||||
compress: "gzip",
|
||||
path: path.join(__dirname, "log"),
|
||||
})
|
||||
|
||||
const errorLogStream = rfs.createStream("error.log", {
|
||||
size: "10M",
|
||||
interval: "1d",
|
||||
compress: "gzip",
|
||||
path: path.join(__dirname, "log"),
|
||||
})
|
||||
|
||||
const config = require("../.serverrc")
|
||||
const { setIo } = require("./io")
|
||||
|
||||
app.use(cookieParser())
|
||||
app.use(
|
||||
morgan("combined", {
|
||||
stream: accessLogStream,
|
||||
skip: function (req, res) {
|
||||
return res.statusCode >= 400
|
||||
},
|
||||
})
|
||||
)
|
||||
|
||||
// log all requests to access.log
|
||||
app.use(
|
||||
morgan("combined", {
|
||||
stream: errorLogStream,
|
||||
skip: function (req, res) {
|
||||
console.log('statusCode', res.statusCode, res.statusCode <= 400)
|
||||
return res.statusCode < 400
|
||||
},
|
||||
})
|
||||
)
|
||||
|
||||
const server = setIo(app)
|
||||
|
||||
const sess = {
|
||||
secret: "super-secret-key",
|
||||
resave: true,
|
||||
saveUninitialized: true,
|
||||
cookie: {},
|
||||
}
|
||||
if (app.get("env") === "production") {
|
||||
app.set("trust proxy", 1)
|
||||
sess.cookie.secure = true
|
||||
}
|
||||
app.use(session(sess))
|
||||
|
||||
app.use(
|
||||
bodyParser.json({
|
||||
limit: "50mb",
|
||||
})
|
||||
)
|
||||
app.use(
|
||||
bodyParser.urlencoded({
|
||||
limit: "50mb",
|
||||
extended: true,
|
||||
})
|
||||
)
|
||||
app.use(require("./root"))
|
||||
|
||||
/**
|
||||
* Добавляйте сюда свои routers.
|
||||
*/
|
||||
app.use("/kfu-m-24-1", require("./routers/kfu-m-24-1"))
|
||||
app.use("/epja-2024-1", require("./routers/epja-2024-1"))
|
||||
app.use("/v1/todo", require("./routers/todo"))
|
||||
app.use("/dogsitters-finder", require("./routers/dogsitters-finder"))
|
||||
app.use("/kazan-explore", require("./routers/kazan-explore"))
|
||||
app.use("/edateam", require("./routers/edateam-legacy"))
|
||||
app.use("/dry-wash", require("./routers/dry-wash"))
|
||||
app.use("/freetracker", require("./routers/freetracker"))
|
||||
app.use("/dhs-testing", require("./routers/dhs-testing"))
|
||||
app.use("/gamehub", require("./routers/gamehub"))
|
||||
app.use("/esc", require("./routers/esc"))
|
||||
app.use('/connectme', require('./routers/connectme'))
|
||||
app.use('/questioneer', require('./routers/questioneer'))
|
||||
|
||||
app.use(require("./error"))
|
||||
|
||||
server.listen(config.port, () =>
|
||||
console.log(`Listening on http://localhost:${config.port}`)
|
||||
)
|
||||
157
server/index.ts
Normal file
157
server/index.ts
Normal file
@@ -0,0 +1,157 @@
|
||||
import express from 'express'
|
||||
import cookieParser from 'cookie-parser'
|
||||
import session from 'express-session'
|
||||
import morgan from 'morgan'
|
||||
import path from 'path'
|
||||
import 'dotenv/config'
|
||||
|
||||
import root from './server'
|
||||
import { errorHandler } from './error'
|
||||
import kfuM241Router from './routers/kfu-m-24-1'
|
||||
import epja20241Router from './routers/epja-2024-1'
|
||||
import todoRouter from './routers/todo'
|
||||
import dogsittersFinderRouter from './routers/dogsitters-finder'
|
||||
import kazanExploreRouter from './routers/kazan-explore'
|
||||
import edateamRouter from './routers/edateam-legacy'
|
||||
import dryWashRouter from './routers/dry-wash'
|
||||
import freetrackerRouter from './routers/freetracker'
|
||||
import dhsTestingRouter from './routers/dhs-testing'
|
||||
import gamehubRouter from './routers/gamehub'
|
||||
import escRouter from './routers/esc'
|
||||
import connectmeRouter from './routers/connectme'
|
||||
import questioneerRouter from './routers/questioneer'
|
||||
import procurementRouter from './routers/procurement'
|
||||
import smokeTrackerRouter from './routers/smoke-tracker'
|
||||
import assessmentToolsRouter from './routers/assessment-tools'
|
||||
import { setIo } from './io'
|
||||
|
||||
export const app = express()
|
||||
|
||||
// Динамический импорт rotating-file-stream
|
||||
const initServer = async () => {
|
||||
const rfs = await import('rotating-file-stream')
|
||||
const accessLogStream = rfs.createStream("access.log", {
|
||||
size: "10M",
|
||||
interval: "1d",
|
||||
compress: "gzip",
|
||||
path: path.join(__dirname, "log"),
|
||||
})
|
||||
|
||||
const errorLogStream = rfs.createStream("error.log", {
|
||||
size: "10M",
|
||||
interval: "1d",
|
||||
compress: "gzip",
|
||||
path: path.join(__dirname, "log"),
|
||||
})
|
||||
|
||||
app.use(cookieParser())
|
||||
app.use(
|
||||
morgan("combined", {
|
||||
stream: accessLogStream,
|
||||
skip: function (req, res) {
|
||||
return res.statusCode >= 400
|
||||
},
|
||||
})
|
||||
)
|
||||
|
||||
// log all requests to access.log
|
||||
app.use(
|
||||
morgan("combined", {
|
||||
stream: errorLogStream,
|
||||
skip: function (req, res) {
|
||||
console.log('statusCode', res.statusCode, res.statusCode <= 400)
|
||||
return res.statusCode < 400
|
||||
},
|
||||
})
|
||||
)
|
||||
|
||||
console.log('warming up 🔥')
|
||||
|
||||
const sess = {
|
||||
secret: "super-secret-key",
|
||||
resave: true,
|
||||
saveUninitialized: true,
|
||||
cookie: {},
|
||||
}
|
||||
if (app.get("env") !== "development") {
|
||||
app.set("trust proxy", 1)
|
||||
}
|
||||
app.use(session(sess))
|
||||
|
||||
app.use(
|
||||
express.json({
|
||||
limit: "50mb",
|
||||
})
|
||||
)
|
||||
app.use(
|
||||
express.urlencoded({
|
||||
limit: "50mb",
|
||||
extended: true,
|
||||
})
|
||||
)
|
||||
app.use(root)
|
||||
|
||||
|
||||
/**
|
||||
* Добавляйте сюда свои routers.
|
||||
*/
|
||||
app.use("/kfu-m-24-1", kfuM241Router)
|
||||
app.use("/epja-2024-1", epja20241Router)
|
||||
app.use("/v1/todo", todoRouter)
|
||||
app.use("/dogsitters-finder", dogsittersFinderRouter)
|
||||
app.use("/kazan-explore", kazanExploreRouter)
|
||||
app.use("/edateam", edateamRouter)
|
||||
app.use("/dry-wash", dryWashRouter)
|
||||
app.use("/freetracker", freetrackerRouter)
|
||||
app.use("/dhs-testing", dhsTestingRouter)
|
||||
app.use("/gamehub", gamehubRouter)
|
||||
app.use("/esc", escRouter)
|
||||
app.use('/connectme', connectmeRouter)
|
||||
app.use('/questioneer', questioneerRouter)
|
||||
app.use('/procurement', procurementRouter)
|
||||
app.use('/smoke-tracker', smokeTrackerRouter)
|
||||
app.use('/assessment-tools', assessmentToolsRouter)
|
||||
app.use(errorHandler)
|
||||
|
||||
// Создаем обычный HTTP сервер
|
||||
const server = app.listen(process.env.PORT ?? 8044, () => {
|
||||
console.log(`🚀 Сервер запущен на http://localhost:${process.env.PORT ?? 8044}`)
|
||||
})
|
||||
|
||||
// Обработка сигналов завершения процесса
|
||||
process.on('SIGTERM', () => {
|
||||
console.log('🛑 Получен сигнал SIGTERM. Выполняется корректное завершение...')
|
||||
server.close(() => {
|
||||
console.log('✅ Сервер успешно остановлен')
|
||||
process.exit(0)
|
||||
})
|
||||
})
|
||||
|
||||
process.on('SIGINT', () => {
|
||||
console.log('🛑 Получен сигнал SIGINT. Выполняется корректное завершение...')
|
||||
server.close(() => {
|
||||
console.log('✅ Сервер успешно остановлен')
|
||||
process.exit(0)
|
||||
})
|
||||
})
|
||||
|
||||
// Обработка необработанных исключений
|
||||
process.on('uncaughtException', (err) => {
|
||||
console.error('❌ Необработанное исключение:', err)
|
||||
server.close(() => {
|
||||
process.exit(1)
|
||||
})
|
||||
})
|
||||
|
||||
// Обработка необработанных отклонений промисов
|
||||
process.on('unhandledRejection', (reason, promise) => {
|
||||
console.error('⚠️ Необработанное отклонение промиса:', reason)
|
||||
server.close(() => {
|
||||
process.exit(1)
|
||||
})
|
||||
})
|
||||
|
||||
return server
|
||||
}
|
||||
|
||||
initServer().catch(console.error)
|
||||
13
server/io.js
13
server/io.js
@@ -1,13 +0,0 @@
|
||||
const { Server } = require('socket.io')
|
||||
const { createServer } = require('http')
|
||||
|
||||
let io = null
|
||||
|
||||
module.exports.setIo = (app) => {
|
||||
const server = createServer(app)
|
||||
io = new Server(server, {})
|
||||
|
||||
return server
|
||||
}
|
||||
|
||||
module.exports.getIo = () => io
|
||||
13
server/io.ts
Normal file
13
server/io.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { Server } from 'socket.io'
|
||||
import { createServer } from 'http'
|
||||
|
||||
let io = null
|
||||
|
||||
export const setIo = (app) => {
|
||||
const server = createServer(app)
|
||||
io = new Server(server, {})
|
||||
|
||||
return server
|
||||
}
|
||||
|
||||
export const getIo = () => io
|
||||
16
server/models/ErrorLog.ts
Normal file
16
server/models/ErrorLog.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import mongoose from 'mongoose'
|
||||
|
||||
const ErrorLogSchema = new mongoose.Schema({
|
||||
message: { type: String, required: true },
|
||||
stack: { type: String },
|
||||
path: { type: String },
|
||||
method: { type: String },
|
||||
query: { type: Object },
|
||||
body: { type: Object },
|
||||
createdAt: { type: Date, default: Date.now },
|
||||
})
|
||||
|
||||
// Индекс для быстрого поиска по дате создания
|
||||
ErrorLogSchema.index({ createdAt: 1 })
|
||||
|
||||
export const ErrorLog = mongoose.model('ErrorLog', ErrorLogSchema)
|
||||
@@ -1,7 +1,7 @@
|
||||
const mongoose = require('mongoose');
|
||||
|
||||
// Типы вопросов
|
||||
const QUESTION_TYPES = {
|
||||
export const QUESTION_TYPES = {
|
||||
SINGLE_CHOICE: 'single_choice', // Один вариант
|
||||
MULTIPLE_CHOICE: 'multiple_choice', // Несколько вариантов
|
||||
TEXT: 'text', // Текстовый ответ
|
||||
@@ -10,7 +10,7 @@ const QUESTION_TYPES = {
|
||||
};
|
||||
|
||||
// Типы отображения
|
||||
const DISPLAY_TYPES = {
|
||||
export const DISPLAY_TYPES = {
|
||||
DEFAULT: 'default',
|
||||
TAG_CLOUD: 'tag_cloud',
|
||||
VOTING: 'voting',
|
||||
@@ -51,10 +51,5 @@ const questionnaireSchema = new mongoose.Schema({
|
||||
publicLink: { type: String, required: true } // ссылка для голосования
|
||||
});
|
||||
|
||||
const Questionnaire = mongoose.model('Questionnaire', questionnaireSchema);
|
||||
export const Questionnaire = mongoose.model('Questionnaire', questionnaireSchema);
|
||||
|
||||
module.exports = {
|
||||
Questionnaire,
|
||||
QUESTION_TYPES,
|
||||
DISPLAY_TYPES
|
||||
};
|
||||
@@ -1,33 +0,0 @@
|
||||
const fs = require('fs')
|
||||
const path = require('path')
|
||||
const router = require('express').Router()
|
||||
const mongoose = require('mongoose')
|
||||
|
||||
const pkg = require('../package.json')
|
||||
|
||||
require('./utils/mongoose')
|
||||
const folderPath = path.resolve(__dirname, './routers')
|
||||
const folders = fs.readdirSync(folderPath)
|
||||
|
||||
router.get('/', async (req, res) => {
|
||||
// throw new Error('check error message')
|
||||
res.send(`
|
||||
<h1>multy stub is working v${pkg.version}</h1>
|
||||
<ul>
|
||||
${folders.map((f) => `<li>${f}</li>`).join('')}
|
||||
</ul>
|
||||
|
||||
<h2>models</h2>
|
||||
<ul>${
|
||||
(await Promise.all(
|
||||
(await mongoose.modelNames()).map(async (name) => {
|
||||
const count = await mongoose.model(name).countDocuments()
|
||||
return `<li>${name} - ${count}</li>`
|
||||
}
|
||||
)
|
||||
)).map(t => t).join(' ')
|
||||
}</ul>
|
||||
`)
|
||||
})
|
||||
|
||||
module.exports = router
|
||||
18
server/routers/assessment-tools/index.js
Normal file
18
server/routers/assessment-tools/index.js
Normal file
@@ -0,0 +1,18 @@
|
||||
const router = require('express').Router();
|
||||
|
||||
// Импортировать mongoose из общего модуля (подключение происходит в server/utils/mongoose.ts)
|
||||
const mongoose = require('../../utils/mongoose');
|
||||
|
||||
const timer = (time = 300) => (req, res, next) => setTimeout(next, time);
|
||||
|
||||
router.use(timer());
|
||||
|
||||
// Подключение маршрутов - прямые пути без path.join и __dirname
|
||||
router.use('/events', require('./routes/event'));
|
||||
router.use('/teams', require('./routes/teams'));
|
||||
router.use('/experts', require('./routes/experts'));
|
||||
router.use('/criteria', require('./routes/criteria'));
|
||||
router.use('/ratings', require('./routes/ratings'));
|
||||
|
||||
module.exports = router;
|
||||
module.exports.default = router;
|
||||
50
server/routers/assessment-tools/models/Criteria.js
Normal file
50
server/routers/assessment-tools/models/Criteria.js
Normal file
@@ -0,0 +1,50 @@
|
||||
const mongoose = require('mongoose');
|
||||
|
||||
const criterionItemSchema = new mongoose.Schema({
|
||||
name: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
maxScore: {
|
||||
type: Number,
|
||||
default: 5,
|
||||
min: 0,
|
||||
max: 10
|
||||
}
|
||||
}, { _id: false });
|
||||
|
||||
const criteriaSchema = new mongoose.Schema({
|
||||
eventId: {
|
||||
type: mongoose.Schema.Types.ObjectId,
|
||||
ref: 'Event',
|
||||
required: true
|
||||
},
|
||||
blockName: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
criteriaType: {
|
||||
type: String,
|
||||
enum: ['team', 'participant', 'all'],
|
||||
default: 'all',
|
||||
required: true
|
||||
},
|
||||
criteria: [criterionItemSchema],
|
||||
order: {
|
||||
type: Number,
|
||||
default: 0
|
||||
},
|
||||
createdAt: {
|
||||
type: Date,
|
||||
default: Date.now
|
||||
},
|
||||
updatedAt: {
|
||||
type: Date,
|
||||
default: Date.now
|
||||
}
|
||||
}, {
|
||||
timestamps: true
|
||||
});
|
||||
|
||||
module.exports = mongoose.model('Criteria', criteriaSchema);
|
||||
|
||||
44
server/routers/assessment-tools/models/Event.js
Normal file
44
server/routers/assessment-tools/models/Event.js
Normal file
@@ -0,0 +1,44 @@
|
||||
const mongoose = require('mongoose');
|
||||
|
||||
const eventSchema = new mongoose.Schema({
|
||||
name: {
|
||||
type: String,
|
||||
required: true,
|
||||
default: 'Новое мероприятие'
|
||||
},
|
||||
description: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
eventDate: {
|
||||
type: Date,
|
||||
required: true,
|
||||
default: Date.now
|
||||
},
|
||||
location: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
status: {
|
||||
type: String,
|
||||
enum: ['draft', 'ready', 'active', 'completed'],
|
||||
default: 'draft'
|
||||
},
|
||||
votingEnabled: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
createdAt: {
|
||||
type: Date,
|
||||
default: Date.now
|
||||
},
|
||||
updatedAt: {
|
||||
type: Date,
|
||||
default: Date.now
|
||||
}
|
||||
}, {
|
||||
timestamps: true
|
||||
});
|
||||
|
||||
module.exports = mongoose.model('Event', eventSchema);
|
||||
|
||||
43
server/routers/assessment-tools/models/Expert.js
Normal file
43
server/routers/assessment-tools/models/Expert.js
Normal file
@@ -0,0 +1,43 @@
|
||||
const mongoose = require('mongoose');
|
||||
const crypto = require('crypto');
|
||||
|
||||
const expertSchema = new mongoose.Schema({
|
||||
eventId: {
|
||||
type: mongoose.Schema.Types.ObjectId,
|
||||
ref: 'Event',
|
||||
required: true
|
||||
},
|
||||
fullName: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
token: {
|
||||
type: String,
|
||||
unique: true
|
||||
},
|
||||
qrCodeUrl: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
createdAt: {
|
||||
type: Date,
|
||||
default: Date.now
|
||||
},
|
||||
updatedAt: {
|
||||
type: Date,
|
||||
default: Date.now
|
||||
}
|
||||
}, {
|
||||
timestamps: true
|
||||
});
|
||||
|
||||
// Generate unique token before saving
|
||||
expertSchema.pre('save', function(next) {
|
||||
if (!this.token) {
|
||||
this.token = crypto.randomBytes(16).toString('hex');
|
||||
}
|
||||
next();
|
||||
});
|
||||
|
||||
module.exports = mongoose.model('Expert', expertSchema);
|
||||
|
||||
64
server/routers/assessment-tools/models/Rating.js
Normal file
64
server/routers/assessment-tools/models/Rating.js
Normal file
@@ -0,0 +1,64 @@
|
||||
const mongoose = require('mongoose');
|
||||
|
||||
const ratingItemSchema = new mongoose.Schema({
|
||||
criteriaId: {
|
||||
type: mongoose.Schema.Types.ObjectId,
|
||||
ref: 'Criteria',
|
||||
required: true
|
||||
},
|
||||
criterionName: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
score: {
|
||||
type: Number,
|
||||
required: true,
|
||||
min: 0,
|
||||
max: 5
|
||||
}
|
||||
}, { _id: false });
|
||||
|
||||
const ratingSchema = new mongoose.Schema({
|
||||
eventId: {
|
||||
type: mongoose.Schema.Types.ObjectId,
|
||||
ref: 'Event',
|
||||
required: true
|
||||
},
|
||||
expertId: {
|
||||
type: mongoose.Schema.Types.ObjectId,
|
||||
ref: 'Expert',
|
||||
required: true
|
||||
},
|
||||
teamId: {
|
||||
type: mongoose.Schema.Types.ObjectId,
|
||||
ref: 'Team',
|
||||
required: true
|
||||
},
|
||||
ratings: [ratingItemSchema],
|
||||
totalScore: {
|
||||
type: Number,
|
||||
default: 0
|
||||
},
|
||||
createdAt: {
|
||||
type: Date,
|
||||
default: Date.now
|
||||
},
|
||||
updatedAt: {
|
||||
type: Date,
|
||||
default: Date.now
|
||||
}
|
||||
}, {
|
||||
timestamps: true
|
||||
});
|
||||
|
||||
// Calculate total score before saving
|
||||
ratingSchema.pre('save', function(next) {
|
||||
this.totalScore = this.ratings.reduce((sum, item) => sum + item.score, 0);
|
||||
next();
|
||||
});
|
||||
|
||||
// Ensure unique combination of expert and team
|
||||
ratingSchema.index({ expertId: 1, teamId: 1 }, { unique: true });
|
||||
|
||||
module.exports = mongoose.model('Rating', ratingSchema);
|
||||
|
||||
52
server/routers/assessment-tools/models/Team.js
Normal file
52
server/routers/assessment-tools/models/Team.js
Normal file
@@ -0,0 +1,52 @@
|
||||
const mongoose = require('mongoose');
|
||||
|
||||
const teamSchema = new mongoose.Schema({
|
||||
eventId: {
|
||||
type: mongoose.Schema.Types.ObjectId,
|
||||
ref: 'Event',
|
||||
required: true
|
||||
},
|
||||
type: {
|
||||
type: String,
|
||||
enum: ['team', 'participant'],
|
||||
required: true
|
||||
},
|
||||
name: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
projectName: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
caseDescription: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
isActive: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
votingStatus: {
|
||||
type: String,
|
||||
enum: ['not_evaluated', 'evaluating', 'evaluated'],
|
||||
default: 'not_evaluated'
|
||||
},
|
||||
isActiveForVoting: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
createdAt: {
|
||||
type: Date,
|
||||
default: Date.now
|
||||
},
|
||||
updatedAt: {
|
||||
type: Date,
|
||||
default: Date.now
|
||||
}
|
||||
}, {
|
||||
timestamps: true
|
||||
});
|
||||
|
||||
module.exports = mongoose.model('Team', teamSchema);
|
||||
|
||||
14
server/routers/assessment-tools/models/index.js
Normal file
14
server/routers/assessment-tools/models/index.js
Normal file
@@ -0,0 +1,14 @@
|
||||
const Event = require('./Event');
|
||||
const Team = require('./Team');
|
||||
const Expert = require('./Expert');
|
||||
const Criteria = require('./Criteria');
|
||||
const Rating = require('./Rating');
|
||||
|
||||
module.exports = {
|
||||
Event,
|
||||
Team,
|
||||
Expert,
|
||||
Criteria,
|
||||
Rating
|
||||
};
|
||||
|
||||
152
server/routers/assessment-tools/routes/criteria.js
Normal file
152
server/routers/assessment-tools/routes/criteria.js
Normal file
@@ -0,0 +1,152 @@
|
||||
const router = require('express').Router();
|
||||
const { Criteria } = require('../models');
|
||||
|
||||
// Критерии по умолчанию из hack.md
|
||||
const DEFAULT_CRITERIA = [
|
||||
{
|
||||
blockName: 'Оценка проекта команды',
|
||||
criteriaType: 'team',
|
||||
criteria: [
|
||||
{ name: 'Соответствие решения поставленной задаче', maxScore: 5 },
|
||||
{ name: 'Оригинальность - использование нестандартных технических и проектных подходов', maxScore: 5 },
|
||||
{ name: 'Работоспособность решения', maxScore: 1 },
|
||||
{ name: 'Технологическая сложность решения', maxScore: 2 },
|
||||
{ name: 'Объем функциональных возможностей решения', maxScore: 2 },
|
||||
{ name: 'Аргументация способа выбранного решения', maxScore: 5 },
|
||||
{ name: 'Качество предоставления информации', maxScore: 5 },
|
||||
{ name: 'Наличие удобного UX/UI', maxScore: 5 },
|
||||
{ name: 'Наличие не менее 5 AI-агентов', maxScore: 5 }
|
||||
],
|
||||
order: 0
|
||||
},
|
||||
{
|
||||
blockName: 'Оценка выступления участника',
|
||||
criteriaType: 'participant',
|
||||
criteria: [
|
||||
{ name: 'Качество презентации и донесения идеи', maxScore: 5 },
|
||||
{ name: 'Понимание технологии и решения', maxScore: 5 },
|
||||
{ name: 'Аргументация выбранного подхода', maxScore: 5 },
|
||||
{ name: 'Ответы на вопросы жюри', maxScore: 5 },
|
||||
{ name: 'Коммуникативные навыки', maxScore: 5 }
|
||||
],
|
||||
order: 1
|
||||
}
|
||||
];
|
||||
|
||||
// GET /api/criteria - получить все блоки критериев
|
||||
router.get('/', async (req, res) => {
|
||||
try {
|
||||
const { eventId, criteriaType } = req.query;
|
||||
const filter = {};
|
||||
if (eventId) filter.eventId = eventId;
|
||||
if (criteriaType && criteriaType !== 'all') {
|
||||
filter.$or = [
|
||||
{ criteriaType: criteriaType },
|
||||
{ criteriaType: 'all' }
|
||||
];
|
||||
}
|
||||
const criteria = await Criteria.find(filter).sort({ order: 1 });
|
||||
res.json(criteria);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/criteria/:id - получить блок критериев по ID
|
||||
router.get('/:id', async (req, res) => {
|
||||
try {
|
||||
const criteria = await Criteria.findById(req.params.id);
|
||||
if (!criteria) {
|
||||
return res.status(404).json({ error: 'Criteria not found' });
|
||||
}
|
||||
res.json(criteria);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/criteria - создать блок критериев
|
||||
router.post('/', async (req, res) => {
|
||||
try {
|
||||
const { eventId, blockName, criteriaType, criteria, order } = req.body;
|
||||
|
||||
if (!eventId || !blockName || !criteria || !Array.isArray(criteria)) {
|
||||
return res.status(400).json({ error: 'EventId, block name and criteria array are required' });
|
||||
}
|
||||
|
||||
const criteriaBlock = await Criteria.create({
|
||||
eventId,
|
||||
blockName,
|
||||
criteriaType: criteriaType || 'all',
|
||||
criteria,
|
||||
order: order !== undefined ? order : 0
|
||||
});
|
||||
|
||||
res.status(201).json(criteriaBlock);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/criteria/default - загрузить критерии по умолчанию из hack.md
|
||||
router.post('/default', async (req, res) => {
|
||||
try {
|
||||
const { eventId } = req.body;
|
||||
|
||||
if (!eventId) {
|
||||
return res.status(400).json({ error: 'EventId is required' });
|
||||
}
|
||||
|
||||
// Удаляем все существующие критерии для этого мероприятия
|
||||
await Criteria.deleteMany({ eventId });
|
||||
|
||||
// Создаем критерии по умолчанию с eventId
|
||||
const criteriaWithEventId = DEFAULT_CRITERIA.map(c => ({
|
||||
...c,
|
||||
eventId
|
||||
}));
|
||||
const createdCriteria = await Criteria.insertMany(criteriaWithEventId);
|
||||
|
||||
res.status(201).json(createdCriteria);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// PUT /api/criteria/:id - редактировать блок
|
||||
router.put('/:id', async (req, res) => {
|
||||
try {
|
||||
const { blockName, criteriaType, criteria, order } = req.body;
|
||||
|
||||
const criteriaBlock = await Criteria.findById(req.params.id);
|
||||
if (!criteriaBlock) {
|
||||
return res.status(404).json({ error: 'Criteria not found' });
|
||||
}
|
||||
|
||||
if (blockName !== undefined) criteriaBlock.blockName = blockName;
|
||||
if (criteriaType !== undefined) criteriaBlock.criteriaType = criteriaType;
|
||||
if (criteria !== undefined) criteriaBlock.criteria = criteria;
|
||||
if (order !== undefined) criteriaBlock.order = order;
|
||||
|
||||
await criteriaBlock.save();
|
||||
res.json(criteriaBlock);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// DELETE /api/criteria/:id - удалить блок
|
||||
router.delete('/:id', async (req, res) => {
|
||||
try {
|
||||
const criteria = await Criteria.findByIdAndDelete(req.params.id);
|
||||
if (!criteria) {
|
||||
return res.status(404).json({ error: 'Criteria not found' });
|
||||
}
|
||||
res.json({ message: 'Criteria deleted successfully', criteria });
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
|
||||
108
server/routers/assessment-tools/routes/event.js
Normal file
108
server/routers/assessment-tools/routes/event.js
Normal file
@@ -0,0 +1,108 @@
|
||||
const router = require('express').Router();
|
||||
const { Event } = require('../models');
|
||||
|
||||
// GET /api/events - получить все мероприятия
|
||||
router.get('/', async (req, res) => {
|
||||
try {
|
||||
const events = await Event.find().sort({ eventDate: -1 });
|
||||
res.json(events);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/events/:id - получить одно мероприятие
|
||||
router.get('/:id', async (req, res) => {
|
||||
try {
|
||||
const event = await Event.findById(req.params.id);
|
||||
|
||||
if (!event) {
|
||||
return res.status(404).json({ error: 'Мероприятие не найдено' });
|
||||
}
|
||||
|
||||
res.json(event);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/events - создать новое мероприятие
|
||||
router.post('/', async (req, res) => {
|
||||
try {
|
||||
const { name, description, eventDate, location, status } = req.body;
|
||||
|
||||
const event = await Event.create({
|
||||
name: name || 'Новое мероприятие',
|
||||
description: description || '',
|
||||
eventDate: eventDate || new Date(),
|
||||
location: location || '',
|
||||
status: status || 'draft',
|
||||
votingEnabled: false
|
||||
});
|
||||
|
||||
res.status(201).json(event);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// PUT /api/events/:id - обновить мероприятие
|
||||
router.put('/:id', async (req, res) => {
|
||||
try {
|
||||
const { name, description, eventDate, location, status } = req.body;
|
||||
|
||||
const event = await Event.findById(req.params.id);
|
||||
|
||||
if (!event) {
|
||||
return res.status(404).json({ error: 'Мероприятие не найдено' });
|
||||
}
|
||||
|
||||
if (name !== undefined) event.name = name;
|
||||
if (description !== undefined) event.description = description;
|
||||
if (eventDate !== undefined) event.eventDate = eventDate;
|
||||
if (location !== undefined) event.location = location;
|
||||
if (status !== undefined) event.status = status;
|
||||
|
||||
await event.save();
|
||||
|
||||
res.json(event);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// DELETE /api/events/:id - удалить мероприятие
|
||||
router.delete('/:id', async (req, res) => {
|
||||
try {
|
||||
const event = await Event.findByIdAndDelete(req.params.id);
|
||||
|
||||
if (!event) {
|
||||
return res.status(404).json({ error: 'Мероприятие не найдено' });
|
||||
}
|
||||
|
||||
res.json({ message: 'Мероприятие удалено', event });
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// PATCH /api/events/:id/toggle-voting - вкл/выкл оценку
|
||||
router.patch('/:id/toggle-voting', async (req, res) => {
|
||||
try {
|
||||
const event = await Event.findById(req.params.id);
|
||||
|
||||
if (!event) {
|
||||
return res.status(404).json({ error: 'Мероприятие не найдено' });
|
||||
}
|
||||
|
||||
event.votingEnabled = !event.votingEnabled;
|
||||
await event.save();
|
||||
|
||||
res.json(event);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
|
||||
117
server/routers/assessment-tools/routes/experts.js
Normal file
117
server/routers/assessment-tools/routes/experts.js
Normal file
@@ -0,0 +1,117 @@
|
||||
const router = require('express').Router();
|
||||
const { Expert } = require('../models');
|
||||
|
||||
// GET /api/experts - список экспертов
|
||||
router.get('/', async (req, res) => {
|
||||
try {
|
||||
const { eventId } = req.query;
|
||||
const filter = {};
|
||||
if (eventId) filter.eventId = eventId;
|
||||
const experts = await Expert.find(filter).sort({ createdAt: -1 });
|
||||
res.json(experts);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/experts/by-token/:token - получить данные эксперта по токену
|
||||
router.get('/by-token/:token', async (req, res) => {
|
||||
try {
|
||||
const expert = await Expert.findOne({ token: req.params.token });
|
||||
if (!expert) {
|
||||
return res.status(404).json({ error: 'Expert not found' });
|
||||
}
|
||||
res.json(expert);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/experts/:id - получить эксперта по ID
|
||||
router.get('/:id', async (req, res) => {
|
||||
try {
|
||||
const expert = await Expert.findById(req.params.id);
|
||||
if (!expert) {
|
||||
return res.status(404).json({ error: 'Expert not found' });
|
||||
}
|
||||
res.json(expert);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/experts - создать эксперта (с генерацией уникальной ссылки)
|
||||
router.post('/', async (req, res) => {
|
||||
try {
|
||||
const { eventId, fullName } = req.body;
|
||||
|
||||
if (!eventId || !fullName) {
|
||||
return res.status(400).json({ error: 'EventId and full name are required' });
|
||||
}
|
||||
|
||||
// Создаем нового эксперта
|
||||
const expert = new Expert({
|
||||
eventId,
|
||||
fullName
|
||||
});
|
||||
|
||||
// Сохраняем эксперта (токен генерируется в pre-save хуке)
|
||||
await expert.save();
|
||||
|
||||
// Формируем URL для QR кода ПОСЛЕ сохранения, когда токен уже сгенерирован
|
||||
// Приоритеты:
|
||||
// 1) Явная переменная окружения FRONTEND_BASE_URL (например, https://platform.brojs.ru)
|
||||
// 2) Проксируемые заголовки x-forwarded-proto / x-forwarded-host
|
||||
// 3) Локальные req.protocol + req.get('host')
|
||||
const forwardedProto = req.get('x-forwarded-proto');
|
||||
const forwardedHost = req.get('x-forwarded-host');
|
||||
const protocol = forwardedProto || req.protocol;
|
||||
const host = forwardedHost || req.get('host');
|
||||
const baseUrl = process.env.FRONTEND_BASE_URL || `${protocol}://${host}`;
|
||||
|
||||
expert.qrCodeUrl = `${baseUrl}/assessment-tools/expert/${expert.token}`;
|
||||
|
||||
// Сохраняем еще раз с обновленным qrCodeUrl
|
||||
await expert.save();
|
||||
|
||||
res.status(201).json(expert);
|
||||
} catch (error) {
|
||||
console.error('Error creating expert:', error);
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// PUT /api/experts/:id - редактировать эксперта
|
||||
router.put('/:id', async (req, res) => {
|
||||
try {
|
||||
const { fullName } = req.body;
|
||||
|
||||
const expert = await Expert.findById(req.params.id);
|
||||
if (!expert) {
|
||||
return res.status(404).json({ error: 'Expert not found' });
|
||||
}
|
||||
|
||||
if (fullName !== undefined) expert.fullName = fullName;
|
||||
|
||||
await expert.save();
|
||||
res.json(expert);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// DELETE /api/experts/:id - удалить эксперта
|
||||
router.delete('/:id', async (req, res) => {
|
||||
try {
|
||||
const expert = await Expert.findByIdAndDelete(req.params.id);
|
||||
if (!expert) {
|
||||
return res.status(404).json({ error: 'Expert not found' });
|
||||
}
|
||||
res.json({ message: 'Expert deleted successfully', expert });
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
|
||||
240
server/routers/assessment-tools/routes/ratings.js
Normal file
240
server/routers/assessment-tools/routes/ratings.js
Normal file
@@ -0,0 +1,240 @@
|
||||
const router = require('express').Router();
|
||||
const { Rating, Team, Expert, Criteria } = require('../models');
|
||||
|
||||
// GET /api/ratings - получить все оценки (с фильтрами)
|
||||
router.get('/', async (req, res) => {
|
||||
try {
|
||||
const { expertId, teamId, eventId } = req.query;
|
||||
const filter = {};
|
||||
|
||||
if (expertId) filter.expertId = expertId;
|
||||
if (teamId) filter.teamId = teamId;
|
||||
if (eventId) filter.eventId = eventId;
|
||||
|
||||
const ratings = await Rating.find(filter)
|
||||
.populate('expertId', 'fullName')
|
||||
.populate('teamId', 'name type')
|
||||
.sort({ createdAt: -1 });
|
||||
|
||||
res.json(ratings);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/ratings/team/:teamId - оценки конкретной команды
|
||||
router.get('/team/:teamId', async (req, res) => {
|
||||
try {
|
||||
const ratings = await Rating.find({ teamId: req.params.teamId })
|
||||
.populate('expertId', 'fullName')
|
||||
.populate('teamId', 'name type projectName');
|
||||
|
||||
res.json(ratings);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/ratings/expert/:expertId - оценки конкретного эксперта
|
||||
router.get('/expert/:expertId', async (req, res) => {
|
||||
try {
|
||||
const ratings = await Rating.find({ expertId: req.params.expertId })
|
||||
.populate('teamId', 'name type projectName');
|
||||
|
||||
res.json(ratings);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/ratings/statistics - статистика с группировкой по командам
|
||||
router.get('/statistics', async (req, res) => {
|
||||
try {
|
||||
const { type, eventId } = req.query;
|
||||
|
||||
// Получаем все команды
|
||||
const teamFilter = { isActive: true };
|
||||
if (type) teamFilter.type = type;
|
||||
if (eventId) teamFilter.eventId = eventId;
|
||||
const teams = await Team.find(teamFilter);
|
||||
|
||||
// Получаем все оценки
|
||||
const ratingFilter = {};
|
||||
if (eventId) ratingFilter.eventId = eventId;
|
||||
const ratings = await Rating.find(ratingFilter)
|
||||
.populate('expertId', 'fullName')
|
||||
.populate('teamId', 'name type projectName');
|
||||
|
||||
// Группируем оценки по командам
|
||||
const statistics = teams.map(team => {
|
||||
const teamRatings = ratings.filter(r => r.teamId && r.teamId._id.toString() === team._id.toString());
|
||||
|
||||
// Считаем средние оценки по критериям
|
||||
const criteriaStats = {};
|
||||
teamRatings.forEach(rating => {
|
||||
rating.ratings.forEach(item => {
|
||||
if (!criteriaStats[item.criterionName]) {
|
||||
criteriaStats[item.criterionName] = {
|
||||
name: item.criterionName,
|
||||
scores: [],
|
||||
average: 0
|
||||
};
|
||||
}
|
||||
criteriaStats[item.criterionName].scores.push(item.score);
|
||||
});
|
||||
});
|
||||
|
||||
// Вычисляем средние значения
|
||||
Object.keys(criteriaStats).forEach(key => {
|
||||
const scores = criteriaStats[key].scores;
|
||||
criteriaStats[key].average = scores.reduce((sum, s) => sum + s, 0) / scores.length;
|
||||
});
|
||||
|
||||
// Считаем общий балл команды (среднее от всех экспертов)
|
||||
const totalScore = teamRatings.length > 0
|
||||
? teamRatings.reduce((sum, r) => sum + r.totalScore, 0) / teamRatings.length
|
||||
: 0;
|
||||
|
||||
return {
|
||||
team: {
|
||||
_id: team._id,
|
||||
name: team.name,
|
||||
type: team.type,
|
||||
projectName: team.projectName
|
||||
},
|
||||
ratings: teamRatings.map(r => ({
|
||||
expert: r.expertId ? r.expertId.fullName : 'Unknown',
|
||||
criteria: r.ratings,
|
||||
totalScore: r.totalScore
|
||||
})),
|
||||
criteriaStats: Object.values(criteriaStats),
|
||||
totalScore: totalScore,
|
||||
ratingsCount: teamRatings.length
|
||||
};
|
||||
});
|
||||
|
||||
res.json(statistics);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/ratings/top3 - топ-3 команды и топ-3 участники отдельно
|
||||
// ВАЖНО: всегда возвращаем объект вида { teams: Top3Item[], participants: Top3Item[] },
|
||||
// чтобы фронтенд мог безопасно работать с data.teams / data.participants
|
||||
router.get('/top3', async (req, res) => {
|
||||
try {
|
||||
const { type, eventId } = req.query;
|
||||
|
||||
// Получаем все активные команды/участников
|
||||
const teamFilter = { isActive: true };
|
||||
if (eventId) teamFilter.eventId = eventId;
|
||||
const teams = await Team.find(teamFilter);
|
||||
|
||||
const ratingFilter = {};
|
||||
if (eventId) ratingFilter.eventId = eventId;
|
||||
const ratings = await Rating.find(ratingFilter).populate('teamId', 'name type projectName');
|
||||
|
||||
const calculateTop3 = (sourceTeams) => {
|
||||
const teamScores = sourceTeams.map((team) => {
|
||||
const teamRatings = ratings.filter(
|
||||
(r) => r.teamId && r.teamId._id.toString() === team._id.toString()
|
||||
);
|
||||
|
||||
const totalScore =
|
||||
teamRatings.length > 0
|
||||
? teamRatings.reduce((sum, r) => sum + r.totalScore, 0) / teamRatings.length
|
||||
: 0;
|
||||
|
||||
return {
|
||||
team: {
|
||||
_id: team._id,
|
||||
name: team.name,
|
||||
type: team.type,
|
||||
projectName: team.projectName
|
||||
},
|
||||
totalScore,
|
||||
ratingsCount: teamRatings.length
|
||||
};
|
||||
});
|
||||
|
||||
return teamScores
|
||||
.filter((t) => t.ratingsCount > 0)
|
||||
.sort((a, b) => b.totalScore - a.totalScore)
|
||||
.slice(0, 3);
|
||||
};
|
||||
|
||||
const teamEntities = teams.filter((t) => t.type === 'team');
|
||||
const participantEntities = teams.filter((t) => t.type === 'participant');
|
||||
|
||||
const teamTop3 = calculateTop3(teamEntities);
|
||||
const participantTop3 = calculateTop3(participantEntities);
|
||||
|
||||
// Параметр type управляет только содержимым, но не форматом ответа
|
||||
const response = {
|
||||
teams: !type || type === 'team' ? teamTop3 : [],
|
||||
participants: !type || type === 'participant' ? participantTop3 : []
|
||||
};
|
||||
|
||||
res.json(response);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/ratings - создать/обновить оценку эксперта
|
||||
router.post('/', async (req, res) => {
|
||||
try {
|
||||
const { eventId, expertId, teamId, ratings } = req.body;
|
||||
|
||||
if (!eventId || !expertId || !teamId || !ratings || !Array.isArray(ratings)) {
|
||||
return res.status(400).json({ error: 'EventId, expert ID, team ID, and ratings array are required' });
|
||||
}
|
||||
|
||||
// Проверяем существование эксперта и команды
|
||||
const expert = await Expert.findById(expertId);
|
||||
const team = await Team.findById(teamId);
|
||||
|
||||
if (!expert) {
|
||||
return res.status(404).json({ error: 'Expert not found' });
|
||||
}
|
||||
|
||||
if (!team) {
|
||||
return res.status(404).json({ error: 'Team not found' });
|
||||
}
|
||||
|
||||
// Проверяем, активна ли команда
|
||||
if (!team.isActive) {
|
||||
return res.status(400).json({ error: 'Team voting is disabled' });
|
||||
}
|
||||
|
||||
// Ищем существующую оценку
|
||||
let rating = await Rating.findOne({ eventId, expertId, teamId });
|
||||
|
||||
if (rating) {
|
||||
// Обновляем существующую оценку
|
||||
rating.ratings = ratings;
|
||||
await rating.save();
|
||||
} else {
|
||||
// Создаем новую оценку
|
||||
rating = await Rating.create({
|
||||
eventId,
|
||||
expertId,
|
||||
teamId,
|
||||
ratings
|
||||
});
|
||||
}
|
||||
|
||||
// Возвращаем с populate
|
||||
rating = await Rating.findById(rating._id)
|
||||
.populate('expertId', 'fullName')
|
||||
.populate('teamId', 'name type projectName');
|
||||
|
||||
res.status(rating ? 200 : 201).json(rating);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
|
||||
194
server/routers/assessment-tools/routes/teams.js
Normal file
194
server/routers/assessment-tools/routes/teams.js
Normal file
@@ -0,0 +1,194 @@
|
||||
const router = require('express').Router();
|
||||
const { Team } = require('../models');
|
||||
|
||||
// GET /api/teams - список всех команд
|
||||
router.get('/', async (req, res) => {
|
||||
try {
|
||||
const { type, eventId } = req.query;
|
||||
const filter = {};
|
||||
if (type) filter.type = type;
|
||||
if (eventId) filter.eventId = eventId;
|
||||
const teams = await Team.find(filter).sort({ createdAt: -1 });
|
||||
res.json(teams);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/teams/active/voting - получить активную для оценки команду (ДОЛЖЕН БЫТЬ ПЕРЕД /:id)
|
||||
router.get('/active/voting', async (req, res) => {
|
||||
try {
|
||||
const { eventId } = req.query;
|
||||
const filter = { isActiveForVoting: true };
|
||||
if (eventId) filter.eventId = eventId;
|
||||
const team = await Team.findOne(filter);
|
||||
res.json(team);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// PATCH /api/teams/stop-all-voting/global - остановить все оценивания (ДОЛЖЕН БЫТЬ ПЕРЕД /:id)
|
||||
router.patch('/stop-all-voting/global', async (req, res) => {
|
||||
try {
|
||||
const { eventId } = req.body;
|
||||
// Находим все команды, которые сейчас оцениваются
|
||||
const filter = { isActiveForVoting: true };
|
||||
if (eventId) filter.eventId = eventId;
|
||||
|
||||
const result = await Team.updateMany(
|
||||
filter,
|
||||
{
|
||||
isActiveForVoting: false,
|
||||
votingStatus: 'evaluated'
|
||||
}
|
||||
);
|
||||
|
||||
res.json({
|
||||
message: 'All voting stopped successfully',
|
||||
modifiedCount: result.modifiedCount
|
||||
});
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/teams/:id - получить команду по ID
|
||||
router.get('/:id', async (req, res) => {
|
||||
try {
|
||||
const team = await Team.findById(req.params.id);
|
||||
if (!team) {
|
||||
return res.status(404).json({ error: 'Team not found' });
|
||||
}
|
||||
res.json(team);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/teams - создать команду/участника
|
||||
router.post('/', async (req, res) => {
|
||||
try {
|
||||
const { eventId, type, name, projectName, caseDescription } = req.body;
|
||||
|
||||
if (!eventId || !type || !name) {
|
||||
return res.status(400).json({ error: 'EventId, type and name are required' });
|
||||
}
|
||||
|
||||
const team = await Team.create({
|
||||
eventId,
|
||||
type,
|
||||
name,
|
||||
projectName: projectName || '',
|
||||
caseDescription: caseDescription || '',
|
||||
isActive: true
|
||||
});
|
||||
|
||||
res.status(201).json(team);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// PUT /api/teams/:id - редактировать команду
|
||||
router.put('/:id', async (req, res) => {
|
||||
try {
|
||||
const { type, name, projectName, caseDescription } = req.body;
|
||||
|
||||
const team = await Team.findById(req.params.id);
|
||||
if (!team) {
|
||||
return res.status(404).json({ error: 'Team not found' });
|
||||
}
|
||||
|
||||
if (type !== undefined) team.type = type;
|
||||
if (name !== undefined) team.name = name;
|
||||
if (projectName !== undefined) team.projectName = projectName;
|
||||
if (caseDescription !== undefined) team.caseDescription = caseDescription;
|
||||
|
||||
await team.save();
|
||||
res.json(team);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// DELETE /api/teams/:id - удалить команду
|
||||
router.delete('/:id', async (req, res) => {
|
||||
try {
|
||||
const team = await Team.findByIdAndDelete(req.params.id);
|
||||
if (!team) {
|
||||
return res.status(404).json({ error: 'Team not found' });
|
||||
}
|
||||
res.json({ message: 'Team deleted successfully', team });
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// PATCH /api/teams/:id/activate-for-voting - активировать команду для оценки
|
||||
router.patch('/:id/activate-for-voting', async (req, res) => {
|
||||
try {
|
||||
// Получаем команду для активации
|
||||
const team = await Team.findById(req.params.id);
|
||||
if (!team) {
|
||||
return res.status(404).json({ error: 'Team not found' });
|
||||
}
|
||||
|
||||
// Деактивируем все команды этого мероприятия
|
||||
const previouslyActive = await Team.findOne({
|
||||
isActiveForVoting: true,
|
||||
eventId: team.eventId
|
||||
});
|
||||
if (previouslyActive) {
|
||||
previouslyActive.isActiveForVoting = false;
|
||||
previouslyActive.votingStatus = 'evaluated';
|
||||
await previouslyActive.save();
|
||||
}
|
||||
|
||||
// Активируем выбранную команду
|
||||
team.isActiveForVoting = true;
|
||||
team.votingStatus = 'evaluating';
|
||||
await team.save();
|
||||
|
||||
res.json(team);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// PATCH /api/teams/:id/stop-voting - остановить оценивание конкретной команды
|
||||
router.patch('/:id/stop-voting', async (req, res) => {
|
||||
try {
|
||||
const team = await Team.findById(req.params.id);
|
||||
if (!team) {
|
||||
return res.status(404).json({ error: 'Team not found' });
|
||||
}
|
||||
|
||||
team.isActiveForVoting = false;
|
||||
team.votingStatus = 'evaluated';
|
||||
await team.save();
|
||||
|
||||
res.json(team);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// PATCH /api/teams/:id/toggle-active - остановить оценку команды
|
||||
router.patch('/:id/toggle-active', async (req, res) => {
|
||||
try {
|
||||
const team = await Team.findById(req.params.id);
|
||||
if (!team) {
|
||||
return res.status(404).json({ error: 'Team not found' });
|
||||
}
|
||||
|
||||
team.isActive = !team.isActive;
|
||||
await team.save();
|
||||
|
||||
res.json(team);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,36 @@
|
||||
// Импортировать mongoose из общего модуля (подключение происходит автоматически)
|
||||
const mongoose = require('../../../utils/mongoose');
|
||||
const Event = require('../models/Event');
|
||||
|
||||
async function recreateTestUser() {
|
||||
try {
|
||||
// Проверяем подключение к MongoDB
|
||||
if (mongoose.connection.readyState !== 1) {
|
||||
console.log('Waiting for MongoDB connection...');
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
}
|
||||
|
||||
console.log('Connected to MongoDB');
|
||||
|
||||
// Создаем тестовое мероприятие если его нет
|
||||
let event = await Event.findOne();
|
||||
if (!event) {
|
||||
event = await Event.create({
|
||||
name: 'Tatar san',
|
||||
status: 'draft',
|
||||
votingEnabled: false
|
||||
});
|
||||
console.log('Test event created:', event.name);
|
||||
} else {
|
||||
console.log('Event already exists:', event.name);
|
||||
}
|
||||
|
||||
console.log('Database initialized successfully');
|
||||
} catch (error) {
|
||||
console.error('Error:', error);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
recreateTestUser();
|
||||
|
||||
@@ -342,7 +342,16 @@ const uploadImage = async (file, accessToken) => {
|
||||
}
|
||||
}
|
||||
|
||||
const analyzeImage = async (fileId, token) => {
|
||||
const COLORS_MAP = ['white', 'black', 'silver', 'gray', 'beige-brown', 'red', 'blue', 'green']
|
||||
|
||||
const getColorName = (colorKey) => {
|
||||
if (typeof colorKey === 'number' && COLORS_MAP[colorKey]) {
|
||||
return COLORS_MAP[colorKey]
|
||||
}
|
||||
return colorKey
|
||||
}
|
||||
|
||||
const analyzeImage = async (fileId, token, imgProps) => {
|
||||
const response = await fetch(`${GIGA_CHAT_API}/chat/completions`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
@@ -357,11 +366,26 @@ const analyzeImage = async (fileId, token) => {
|
||||
messages: [
|
||||
{
|
||||
role: "system",
|
||||
content: (await getSystemPrompt()) ?? `Ты эксперт по оценке степени загрязнения автомобилей. Твоя задача — анализировать фотографии машин и определять степень их загрязнения. ВАЖНО: Твой ответ ДОЛЖЕН быть СТРОГО в формате JSON и содержать ТОЛЬКО следующие поля: { "value": число от 0 до 10 (целое или с одним знаком после запятой), "description": "текстовое описание на русском языке" } Правила: 1. Поле "value": - Должно быть числом от 0 до 10 - 0 = машина абсолютно чистая - 10 = машина максимально грязная 2. Поле "description": - Должно содержать 2-3 предложения на русском языке - Обязательно указать конкретные признаки загрязнения - Объяснить почему выставлен именно такой балл НЕ ДОБАВЛЯЙ никаких дополнительных полей или комментариев вне JSON структуры. НЕ ИСПОЛЬЗУЙ markdown форматирование. ОТВЕТ ДОЛЖЕН БЫТЬ ВАЛИДНЫМ JSON. Если на фотографии нет одной машины, то оценка должна быть 0 и в описании должно быть указано, почему не удалось оценить.`,
|
||||
content: (await getSystemPrompt()) ?? `Ты эксперт по оценке степени загрязнения автомобилей. Твоя задача — анализировать фотографии машин и определять степень их загрязнения.
|
||||
Тебе предоставляют фотографию, где явно выделяется одна машина (например, она расположена в центре и имеет больший размер в кадре по сравнению с остальными).
|
||||
ВАЖНО: Твой ответ ДОЛЖЕН быть СТРОГО в формате JSON и содержать ТОЛЬКО следующие поля:
|
||||
{
|
||||
"value": число от 0 до 10 (целое или с одним знаком после запятой),
|
||||
"description": "текстовое описание на русском языке"
|
||||
}.
|
||||
Правила:
|
||||
1. Поле "value":
|
||||
- Должно быть числом от 0 до 10 (0 = машина абсолютно чистая, 10 = машина максимально грязная) ИЛИ undefined (если не удалось оценить);
|
||||
2. Поле "description":
|
||||
- Должно содержать 2-3 предложения на русском языке;
|
||||
- Обязательно указать конкретные признаки загрязнения;
|
||||
- Объяснить, почему выставлен именно такой балл.
|
||||
- Должно быть связано только с автомобилем.
|
||||
НЕ ДОБАВЛЯЙ никаких дополнительных полей или комментариев вне JSON структуры. НЕ ИСПОЛЬЗУЙ markdown форматирование. ОТВЕТ ДОЛЖЕН БЫТЬ ВАЛИДНЫМ JSON. Если на фотографии нельзя явно выделить одну машину, то ОЦЕНКА ДОЛЖНА ИМЕТЬ ЗНАЧЕНИЕ undefined и в описании должно быть указано, что по фотографии не удалось оценить степень загрязнения автомобиля, при этом НЕ ОПИСЫВАЙ НИЧЕГО ДРУГОГО КРОМЕ АВТОМОБИЛЯ`,
|
||||
},
|
||||
{
|
||||
role: "user",
|
||||
content: 'Дай оценку для приложенного файла изображения согласно структуре, ответ должен быть на русском языке',
|
||||
content: `Дай оценку для приложенного файла изображения согласно структуре, ответ должен быть на русском языке. Учти, что владелец указал, что исходный цвет машины: ${getColorName(imgProps.color)}`,
|
||||
attachments: [fileId],
|
||||
},
|
||||
],
|
||||
@@ -406,7 +430,7 @@ router.post('/:id/upload-car-img', upload.single('file'), async (req, res) => {
|
||||
const { access_token } = await getToken(req, res)
|
||||
|
||||
const fileId = await uploadImage(req.file, access_token)
|
||||
const { value, description } = await analyzeImage(fileId, access_token) ?? {}
|
||||
const { value, description } = await analyzeImage(fileId, access_token, { carColor: order.carColor }) ?? {}
|
||||
|
||||
const orderCarImg = await OrderCarImgModel.create({
|
||||
image: convertFileToBase64(req.file),
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
const router = require('express').Router();
|
||||
|
||||
router.get('/recipe-data', (request, response) => {
|
||||
response.send(require('./json/recipe-data/success.json'))
|
||||
})
|
||||
|
||||
router.get('/userpage-data', (req, res)=>{
|
||||
res.send(require('./json/userpage-data/success.json'))
|
||||
})
|
||||
|
||||
router.get('/homepage-data', (req, res)=>{
|
||||
res.send(require('./json/homepage-data/success.json'))
|
||||
})
|
||||
|
||||
module.exports = router;
|
||||
21
server/routers/edateam-legacy/index.ts
Normal file
21
server/routers/edateam-legacy/index.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { Router } from 'express';
|
||||
|
||||
import recipeData from './json/recipe-data/success.json';
|
||||
import userpageData from './json/userpage-data/success.json';
|
||||
import homepageData from './json/homepage-data/success.json';
|
||||
|
||||
const router = Router();
|
||||
|
||||
router.get('/recipe-data', (request, response) => {
|
||||
response.send(recipeData)
|
||||
})
|
||||
|
||||
router.get('/userpage-data', (req, res)=>{
|
||||
res.send(userpageData)
|
||||
})
|
||||
|
||||
router.get('/homepage-data', (req, res)=>{
|
||||
res.send(homepageData)
|
||||
})
|
||||
|
||||
export default router;
|
||||
@@ -1,22 +0,0 @@
|
||||
const Router = require('express').Router;
|
||||
|
||||
const router = Router()
|
||||
|
||||
const timer = (_req, _res, next) => {
|
||||
setTimeout(() => next(), 500)
|
||||
}
|
||||
|
||||
router.use(timer)
|
||||
|
||||
router.get(
|
||||
'/trips',
|
||||
(req, res) =>
|
||||
res.send(require(`./json/trips-success.json`))
|
||||
)
|
||||
|
||||
router.get('/cars/:id', (req, res) => {
|
||||
res.send(require(`./json/cars-success.json`))
|
||||
|
||||
})
|
||||
|
||||
module.exports = router
|
||||
@@ -1,28 +0,0 @@
|
||||
{
|
||||
"success": true,
|
||||
"errors": [],
|
||||
"warnings":[],
|
||||
"body": [
|
||||
{
|
||||
"id": 1,
|
||||
"car": "Mersedes",
|
||||
"driver": "Иванов Иван Иванович",
|
||||
"telephone": "+7 9600376666",
|
||||
"upgradeNum": "Челябинск"
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"car": "Mersedes",
|
||||
"driver": "Иванов Иван Иванович",
|
||||
"telephone": "+7 9600376666",
|
||||
"upgradeNum": "Челябинск"
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"car": "Mersedes",
|
||||
"driver": "Иванов Иван Иванович",
|
||||
"telephone": "+7 9600376666",
|
||||
"upgradeNum": "Челябинск"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,807 +0,0 @@
|
||||
{
|
||||
"success": true,
|
||||
"errors": [],
|
||||
"warnings":[],
|
||||
"body": [
|
||||
{
|
||||
"key": 0,
|
||||
"trip": "Владисвосток-Москва",
|
||||
"count": 99,
|
||||
"date_start": "Mon, 29 Jun 2020 14:13:16 GMT",
|
||||
"date_end": "Wed, 15 Jul 2020 09:45:23 GMT",
|
||||
"created_at": "Thu, 01 Dec 2022 15:55:14 GMT"
|
||||
},
|
||||
{
|
||||
"key": 1,
|
||||
"trip": "Казань-Москва",
|
||||
"count": 85,
|
||||
"date_start": "Mon, 17 Oct 2022 09:46:21 GMT",
|
||||
"date_end": "Fri, 28 Oct 2022 16:30:12 GMT",
|
||||
"created_at": "Wed, 14 Jun 2023 03:06:52 GMT"
|
||||
},
|
||||
{
|
||||
"key": 2,
|
||||
"trip": "Казань-Сызрань",
|
||||
"count": 48,
|
||||
"date_start": "Thu, 01 Sep 2022 04:28:45 GMT",
|
||||
"date_end": "Mon, 12 Sep 2022 11:15:33 GMT",
|
||||
"created_at": "Mon, 07 Oct 2024 01:37:16 GMT"
|
||||
},
|
||||
{
|
||||
"key": 3,
|
||||
"trip": "Владивосток-Владимир",
|
||||
"count": 72,
|
||||
"date_start": "Wed, 26 Jun 2024 16:22:43 GMT",
|
||||
"date_end": "Tue, 09 Jul 2024 13:45:28 GMT",
|
||||
"created_at": "Sun, 11 Dec 2022 16:12:07 GMT"
|
||||
},
|
||||
{
|
||||
"key": 4,
|
||||
"trip": "Владивосток-Вологда",
|
||||
"count": 29,
|
||||
"date_start": "Mon, 29 Jun 2020 17:37:33 GMT",
|
||||
"date_end": "Thu, 16 Jul 2020 08:22:15 GMT",
|
||||
"created_at": "Thu, 26 Mar 2020 01:18:31 GMT"
|
||||
},
|
||||
{
|
||||
"key": 5,
|
||||
"trip": "Москва-Вологда",
|
||||
"count": 78,
|
||||
"date_start": "Fri, 05 May 2023 03:42:20 GMT",
|
||||
"date_end": "Wed, 17 May 2023 19:30:45 GMT",
|
||||
"created_at": "Tue, 21 Sep 2021 13:54:18 GMT"
|
||||
},
|
||||
{
|
||||
"key": 6,
|
||||
"trip": "Казань-Вологда",
|
||||
"count": 36,
|
||||
"date_start": "Sun, 02 Oct 2022 14:35:34 GMT",
|
||||
"date_end": "Sat, 15 Oct 2022 10:20:18 GMT",
|
||||
"created_at": "Sun, 11 Sep 2022 12:27:29 GMT"
|
||||
},
|
||||
{
|
||||
"key": 7,
|
||||
"trip": "Казань-Тольятти",
|
||||
"count": 7,
|
||||
"date_start": "Tue, 16 Feb 2021 16:17:32 GMT",
|
||||
"date_end": "Mon, 01 Mar 2021 14:45:22 GMT",
|
||||
"created_at": "Tue, 02 Mar 2021 02:15:25 GMT"
|
||||
},
|
||||
{
|
||||
"key": 8,
|
||||
"trip": "Чита-Тольятти",
|
||||
"count": 29,
|
||||
"date_start": "Mon, 16 Sep 2024 17:48:59 GMT",
|
||||
"date_end": "Sun, 29 Sep 2024 09:30:15 GMT",
|
||||
"created_at": "Thu, 02 Jul 2020 17:12:02 GMT"
|
||||
},
|
||||
{
|
||||
"key": 9,
|
||||
"trip": "Чита-Тюмень",
|
||||
"count": 94,
|
||||
"date_start": "Wed, 04 May 2022 01:36:51 GMT",
|
||||
"date_end": "Tue, 17 May 2022 18:20:33 GMT",
|
||||
"created_at": "Mon, 28 Oct 2024 04:00:12 GMT"
|
||||
},
|
||||
{
|
||||
"key": 10,
|
||||
"trip": "Москва-Тюмень",
|
||||
"count": 12,
|
||||
"date_start": "Thu, 24 Oct 2024 05:48:10 GMT",
|
||||
"date_end": "Wed, 06 Nov 2024 16:25:40 GMT",
|
||||
"created_at": "Sun, 30 Aug 2020 05:14:05 GMT"
|
||||
},
|
||||
{
|
||||
"key": 11,
|
||||
"trip": "Казань-Тольятти",
|
||||
"count": 49,
|
||||
"date_start": "Mon, 22 Apr 2024 15:59:19 GMT",
|
||||
"date_end": "Sun, 05 May 2024 12:30:45 GMT",
|
||||
"created_at": "Tue, 22 Dec 2020 09:51:24 GMT"
|
||||
},
|
||||
{
|
||||
"key": 12,
|
||||
"trip": "Владивосток-Владимир",
|
||||
"count": 40,
|
||||
"date_start": "Thu, 30 May 2024 01:48:52 GMT",
|
||||
"date_end": "Wed, 12 Jun 2024 20:15:33 GMT",
|
||||
"created_at": "Sat, 28 Jan 2023 14:11:25 GMT"
|
||||
},
|
||||
{
|
||||
"key": 13,
|
||||
"trip": "Самара-Тюмень",
|
||||
"count": 47,
|
||||
"date_start": "Sun, 22 Nov 2020 13:11:01 GMT",
|
||||
"date_end": "Sat, 05 Dec 2020 09:45:28 GMT",
|
||||
"created_at": "Mon, 17 Jan 2022 16:24:57 GMT"
|
||||
},
|
||||
{
|
||||
"key": 14,
|
||||
"trip": "Казань-Вологда",
|
||||
"count": 32,
|
||||
"date_start": "Thu, 08 Dec 2022 03:45:34 GMT",
|
||||
"date_end": "Wed, 21 Dec 2022 15:20:18 GMT",
|
||||
"created_at": "Wed, 05 Jun 2024 08:59:22 GMT"
|
||||
},
|
||||
{
|
||||
"key": 15,
|
||||
"trip": "Самара-Казань",
|
||||
"count": 61,
|
||||
"date_start": "Sat, 02 May 2020 19:18:10 GMT",
|
||||
"date_end": "Fri, 15 May 2020 16:30:45 GMT",
|
||||
"created_at": "Sat, 12 Aug 2023 01:13:15 GMT"
|
||||
},
|
||||
{
|
||||
"key": 16,
|
||||
"trip": "Тюмень-Казань",
|
||||
"count": 99,
|
||||
"date_start": "Tue, 22 Dec 2020 08:05:09 GMT",
|
||||
"date_end": "Mon, 04 Jan 2021 14:25:33 GMT",
|
||||
"created_at": "Sun, 15 Jan 2023 09:27:02 GMT"
|
||||
},
|
||||
{
|
||||
"key": 17,
|
||||
"trip": "Казань-Тольятти",
|
||||
"count": 29,
|
||||
"date_start": "Thu, 15 Dec 2022 07:04:50 GMT",
|
||||
"date_end": "Wed, 28 Dec 2022 11:40:15 GMT",
|
||||
"created_at": "Wed, 22 Mar 2023 23:11:56 GMT"
|
||||
},
|
||||
{
|
||||
"key": 18,
|
||||
"trip": "Тюмень-Вологда",
|
||||
"count": 72,
|
||||
"date_start": "Wed, 28 Dec 2022 11:36:57 GMT",
|
||||
"date_end": "Tue, 10 Jan 2023 18:20:45 GMT",
|
||||
"created_at": "Sun, 06 Mar 2022 12:22:23 GMT"
|
||||
},
|
||||
{
|
||||
"key": 19,
|
||||
"trip": "Тюмень-Ижевск",
|
||||
"count": 44,
|
||||
"date_start": "Tue, 30 Jun 2020 05:32:53 GMT",
|
||||
"date_end": "Mon, 13 Jul 2020 12:15:30 GMT",
|
||||
"created_at": "Mon, 22 Apr 2024 11:49:06 GMT"
|
||||
},
|
||||
{
|
||||
"key": 20,
|
||||
"trip": "Самара-Тюмень",
|
||||
"count": 86,
|
||||
"date_start": "Sat, 29 Aug 2020 02:28:28 GMT",
|
||||
"date_end": "Fri, 11 Sep 2020 09:40:18 GMT",
|
||||
"created_at": "Sat, 23 Mar 2024 02:57:58 GMT"
|
||||
},
|
||||
{
|
||||
"key": 21,
|
||||
"trip": "Москва-Вологда",
|
||||
"count": 67,
|
||||
"date_start": "Wed, 28 Aug 2024 14:19:06 GMT",
|
||||
"date_end": "Tue, 10 Sep 2024 21:30:45 GMT",
|
||||
"created_at": "Fri, 29 Apr 2022 20:59:06 GMT"
|
||||
},
|
||||
{
|
||||
"key": 22,
|
||||
"trip": "Чита-Тольятти",
|
||||
"count": 5,
|
||||
"date_start": "Thu, 07 Oct 2021 03:54:24 GMT",
|
||||
"date_end": "Wed, 20 Oct 2021 10:25:33 GMT",
|
||||
"created_at": "Mon, 01 Mar 2021 20:24:58 GMT"
|
||||
},
|
||||
{
|
||||
"key": 23,
|
||||
"trip": "Москва-Ижевск",
|
||||
"count": 52,
|
||||
"date_start": "Sun, 09 May 2021 05:00:06 GMT",
|
||||
"date_end": "Sat, 22 May 2021 11:45:20 GMT",
|
||||
"created_at": "Sun, 25 Aug 2024 07:43:58 GMT"
|
||||
},
|
||||
{
|
||||
"key": 24,
|
||||
"trip": "Москва-Пермь",
|
||||
"count": 56,
|
||||
"date_start": "Thu, 22 Apr 2021 01:45:23 GMT",
|
||||
"date_end": "Wed, 05 May 2021 08:30:15 GMT",
|
||||
"created_at": "Tue, 14 Apr 2020 14:34:44 GMT"
|
||||
},
|
||||
{
|
||||
"key": 25,
|
||||
"trip": "Владивосток-Владимир",
|
||||
"count": 84,
|
||||
"date_start": "Sun, 30 May 2021 06:20:13 GMT",
|
||||
"date_end": "Sat, 12 Jun 2021 13:15:40 GMT",
|
||||
"created_at": "Sat, 02 Oct 2021 16:16:48 GMT"
|
||||
},
|
||||
{
|
||||
"key": 26,
|
||||
"trip": "Тюмень-Ижевск",
|
||||
"count": 64,
|
||||
"date_start": "Mon, 18 Mar 2024 03:50:05 GMT",
|
||||
"date_end": "Sun, 31 Mar 2024 10:35:25 GMT",
|
||||
"created_at": "Thu, 06 Aug 2020 10:22:30 GMT"
|
||||
},
|
||||
{
|
||||
"key": 27,
|
||||
"trip": "Самара-Тюмень",
|
||||
"count": 52,
|
||||
"date_start": "Sun, 20 Mar 2022 06:26:26 GMT",
|
||||
"date_end": "Sat, 02 Apr 2022 13:20:15 GMT",
|
||||
"created_at": "Wed, 15 May 2024 16:25:33 GMT"
|
||||
},
|
||||
{
|
||||
"key": 28,
|
||||
"trip": "Самара-Казань",
|
||||
"count": 69,
|
||||
"date_start": "Wed, 16 Sep 2020 23:18:39 GMT",
|
||||
"date_end": "Tue, 29 Sep 2020 06:05:30 GMT",
|
||||
"created_at": "Sun, 27 Aug 2023 10:13:51 GMT"
|
||||
},
|
||||
{
|
||||
"key": 29,
|
||||
"trip": "Владивосток-Вологда",
|
||||
"count": 39,
|
||||
"date_start": "Sat, 13 Jul 2024 16:41:31 GMT",
|
||||
"date_end": "Fri, 26 Jul 2024 23:30:18 GMT",
|
||||
"created_at": "Sun, 04 Apr 2021 10:16:17 GMT"
|
||||
},
|
||||
{
|
||||
"key": 30,
|
||||
"trip": "Москва-Тюмень",
|
||||
"count": 15,
|
||||
"date_start": "Mon, 12 Feb 2024 21:46:30 GMT",
|
||||
"date_end": "Sun, 25 Feb 2024 04:35:22 GMT",
|
||||
"created_at": "Mon, 25 Oct 2021 21:06:29 GMT"
|
||||
},
|
||||
{
|
||||
"key": 31,
|
||||
"trip": "Москва-Тюмень",
|
||||
"count": 98,
|
||||
"date_start": "Tue, 01 Oct 2024 12:54:27 GMT",
|
||||
"date_end": "Mon, 14 Oct 2024 19:40:15 GMT",
|
||||
"created_at": "Wed, 09 Mar 2022 03:46:32 GMT"
|
||||
},
|
||||
{
|
||||
"key": 32,
|
||||
"trip": "N",
|
||||
"count": 44,
|
||||
"date_start": "Wed, 08 Apr 2020 13:09:07 GMT",
|
||||
"date_end": "Tue, 21 Apr 2020 20:00:33 GMT",
|
||||
"created_at": "Wed, 01 Jan 2025 19:48:47 GMT"
|
||||
},
|
||||
{
|
||||
"key": 33,
|
||||
"trip": "Q",
|
||||
"count": 23,
|
||||
"date_start": "Wed, 09 Feb 2022 00:13:32 GMT",
|
||||
"date_end": "Tue, 22 Feb 2022 07:05:20 GMT",
|
||||
"created_at": "Sun, 19 Mar 2023 13:20:24 GMT"
|
||||
},
|
||||
{
|
||||
"key": 34,
|
||||
"trip": "Чита-Тюмень",
|
||||
"count": 67,
|
||||
"date_start": "Mon, 21 Oct 2024 14:57:27 GMT",
|
||||
"date_end": "Sun, 03 Nov 2024 21:45:15 GMT",
|
||||
"created_at": "Tue, 15 Nov 2022 17:33:15 GMT"
|
||||
},
|
||||
{
|
||||
"key": 35,
|
||||
"trip": "S",
|
||||
"count": 28,
|
||||
"date_start": "Wed, 18 Mar 2020 10:03:48 GMT",
|
||||
"date_end": "Tue, 31 Mar 2020 16:50:33 GMT",
|
||||
"created_at": "Thu, 23 May 2024 20:59:48 GMT"
|
||||
},
|
||||
{
|
||||
"key": 36,
|
||||
"trip": "Казань-Вологда",
|
||||
"count": 82,
|
||||
"date_start": "Sat, 24 Apr 2021 08:35:13 GMT",
|
||||
"date_end": "Fri, 07 May 2021 15:25:40 GMT",
|
||||
"created_at": "Tue, 24 Oct 2023 03:16:38 GMT"
|
||||
},
|
||||
{
|
||||
"key": 37,
|
||||
"trip": "Казань-Тольятти",
|
||||
"count": 43,
|
||||
"date_start": "Wed, 05 Oct 2022 20:10:30 GMT",
|
||||
"date_end": "Tue, 18 Oct 2022 03:00:18 GMT",
|
||||
"created_at": "Tue, 11 Jun 2024 22:41:02 GMT"
|
||||
},
|
||||
{
|
||||
"key": 38,
|
||||
"trip": "Москва-Тюмень",
|
||||
"count": 56,
|
||||
"date_start": "Thu, 06 Jan 2022 02:47:53 GMT",
|
||||
"date_end": "Wed, 19 Jan 2022 09:35:25 GMT",
|
||||
"created_at": "Mon, 06 Sep 2021 05:40:10 GMT"
|
||||
},
|
||||
{
|
||||
"key": 39,
|
||||
"trip": "Чита-Тольятти",
|
||||
"count": 84,
|
||||
"date_start": "Wed, 05 Jan 2022 06:42:08 GMT",
|
||||
"date_end": "Tue, 18 Jan 2022 13:30:40 GMT",
|
||||
"created_at": "Mon, 13 Apr 2020 02:45:40 GMT"
|
||||
},
|
||||
{
|
||||
"key": 40,
|
||||
"trip": "Самара-Казань",
|
||||
"count": 51,
|
||||
"date_start": "Mon, 12 Feb 2024 09:03:02 GMT",
|
||||
"date_end": "Sun, 25 Feb 2024 15:50:33 GMT",
|
||||
"created_at": "Tue, 14 May 2024 13:03:26 GMT"
|
||||
},
|
||||
{
|
||||
"key": 41,
|
||||
"trip": "Москва-Пермь",
|
||||
"count": 58,
|
||||
"date_start": "Fri, 19 May 2023 23:10:06 GMT",
|
||||
"date_end": "Thu, 01 Jun 2023 06:00:20 GMT",
|
||||
"created_at": "Sun, 05 Dec 2021 07:35:56 GMT"
|
||||
},
|
||||
{
|
||||
"key": 42,
|
||||
"trip": "Владивосток-Вологда",
|
||||
"count": 46,
|
||||
"date_start": "Thu, 18 Mar 2021 14:42:08 GMT",
|
||||
"date_end": "Wed, 31 Mar 2021 21:30:45 GMT",
|
||||
"created_at": "Fri, 02 Jul 2021 01:45:38 GMT"
|
||||
},
|
||||
{
|
||||
"key": 43,
|
||||
"trip": "Тюмень-Вологда",
|
||||
"count": 92,
|
||||
"date_start": "Sat, 01 Feb 2025 10:11:00 GMT",
|
||||
"date_end": "Fri, 14 Feb 2025 17:00:33 GMT",
|
||||
"created_at": "Mon, 05 Jul 2021 06:14:03 GMT"
|
||||
},
|
||||
{
|
||||
"key": 44,
|
||||
"trip": "E",
|
||||
"count": 96,
|
||||
"date_start": "Mon, 24 Feb 2020 19:09:06 GMT",
|
||||
"date_end": "Sun, 08 Mar 2020 02:00:20 GMT",
|
||||
"created_at": "Sun, 12 Apr 2020 04:59:22 GMT"
|
||||
},
|
||||
{
|
||||
"key": 45,
|
||||
"trip": "E",
|
||||
"count": 3,
|
||||
"date_start": "Sun, 18 Jun 2023 07:26:40 GMT",
|
||||
"date_end": "Sat, 01 Jul 2023 14:15:25 GMT",
|
||||
"created_at": "Sun, 18 Aug 2024 06:19:01 GMT"
|
||||
},
|
||||
{
|
||||
"key": 46,
|
||||
"trip": "E",
|
||||
"count": 63,
|
||||
"date_start": "Sat, 10 Apr 2021 13:23:55 GMT",
|
||||
"date_end": "Fri, 23 Apr 2021 20:10:40 GMT",
|
||||
"created_at": "Thu, 02 Apr 2020 16:26:15 GMT"
|
||||
},
|
||||
{
|
||||
"key": 47,
|
||||
"trip": "Москва-Тюмень",
|
||||
"count": 16,
|
||||
"date_start": "Wed, 23 Aug 2023 00:01:58 GMT",
|
||||
"date_end": "Tue, 05 Sep 2023 06:50:33 GMT",
|
||||
"created_at": "Fri, 16 Feb 2024 01:39:30 GMT"
|
||||
},
|
||||
{
|
||||
"key": 48,
|
||||
"trip": "Владивосток-Владимир",
|
||||
"count": 36,
|
||||
"date_start": "Tue, 25 Apr 2023 23:09:56 GMT",
|
||||
"date_end": "Mon, 08 May 2023 06:00:20 GMT",
|
||||
"created_at": "Sat, 17 Dec 2022 13:52:04 GMT"
|
||||
},
|
||||
{
|
||||
"key": 49,
|
||||
"trip": "Самара-Казань",
|
||||
"count": 62,
|
||||
"date_start": "Fri, 04 Oct 2024 11:01:56 GMT",
|
||||
"date_end": "Thu, 17 Oct 2024 17:50:33 GMT",
|
||||
"created_at": "Thu, 15 Feb 2024 02:39:03 GMT"
|
||||
},
|
||||
{
|
||||
"key": 50,
|
||||
"trip": "Москва-Тюмень",
|
||||
"count": 65,
|
||||
"date_start": "Thu, 18 Jun 2020 22:40:19 GMT",
|
||||
"date_end": "Wed, 01 Jul 2020 05:30:15 GMT",
|
||||
"created_at": "Mon, 28 Jun 2021 12:55:18 GMT"
|
||||
},
|
||||
{
|
||||
"key": 51,
|
||||
"trip": "Тюмень-Вологда",
|
||||
"count": 17,
|
||||
"date_start": "Tue, 08 Sep 2020 08:53:55 GMT",
|
||||
"date_end": "Mon, 21 Sep 2020 15:40:30 GMT",
|
||||
"created_at": "Thu, 19 Mar 2020 00:27:17 GMT"
|
||||
},
|
||||
{
|
||||
"key": 52,
|
||||
"trip": "Казань-Москва",
|
||||
"count": 28,
|
||||
"date_start": "Wed, 07 Sep 2022 20:41:37 GMT",
|
||||
"date_end": "Tue, 20 Sep 2022 03:30:25 GMT",
|
||||
"created_at": "Wed, 20 Apr 2022 04:36:43 GMT"
|
||||
},
|
||||
{
|
||||
"key": 53,
|
||||
"trip": "U",
|
||||
"count": 36,
|
||||
"date_start": "Mon, 22 May 2023 20:35:52 GMT",
|
||||
"date_end": "Sun, 04 Jun 2023 03:25:40 GMT",
|
||||
"created_at": "Tue, 20 Oct 2020 13:11:01 GMT"
|
||||
},
|
||||
{
|
||||
"key": 54,
|
||||
"trip": "S",
|
||||
"count": 57,
|
||||
"date_start": "Mon, 01 Jan 2024 12:53:55 GMT",
|
||||
"date_end": "Sun, 14 Jan 2024 19:40:30 GMT",
|
||||
"created_at": "Fri, 31 Jan 2025 10:15:04 GMT"
|
||||
},
|
||||
{
|
||||
"key": 55,
|
||||
"trip": "Казань-Сызрань",
|
||||
"count": 27,
|
||||
"date_start": "Thu, 03 Nov 2022 13:51:00 GMT",
|
||||
"date_end": "Wed, 16 Nov 2022 20:40:15 GMT",
|
||||
"created_at": "Wed, 03 Jun 2020 07:10:46 GMT"
|
||||
},
|
||||
{
|
||||
"key": 56,
|
||||
"trip": "N",
|
||||
"count": 58,
|
||||
"date_start": "Tue, 03 Nov 2020 10:27:49 GMT",
|
||||
"date_end": "Mon, 16 Nov 2020 17:15:33 GMT",
|
||||
"created_at": "Sat, 01 May 2021 08:40:29 GMT"
|
||||
},
|
||||
{
|
||||
"key": 57,
|
||||
"trip": "Владисвосток-Москва",
|
||||
"count": 29,
|
||||
"date_start": "Sun, 05 Jan 2020 00:10:45 GMT",
|
||||
"date_end": "Sat, 18 Jan 2020 07:00:20 GMT",
|
||||
"created_at": "Thu, 29 Jul 2021 08:04:38 GMT"
|
||||
},
|
||||
{
|
||||
"key": 58,
|
||||
"trip": "Казань-Тольятти",
|
||||
"count": 22,
|
||||
"date_start": "Wed, 05 Apr 2023 14:15:27 GMT",
|
||||
"date_end": "Tue, 18 Apr 2023 21:05:15 GMT",
|
||||
"created_at": "Fri, 06 Dec 2024 03:20:18 GMT"
|
||||
},
|
||||
{
|
||||
"key": 59,
|
||||
"trip": "Москва-Пермь",
|
||||
"count": 8,
|
||||
"date_start": "Fri, 18 Nov 2022 21:56:47 GMT",
|
||||
"date_end": "Thu, 01 Dec 2022 04:45:30 GMT",
|
||||
"created_at": "Tue, 07 Jul 2020 16:18:05 GMT"
|
||||
},
|
||||
{
|
||||
"key": 60,
|
||||
"trip": "Владисвосток-Москва",
|
||||
"count": 29,
|
||||
"date_start": "Sat, 06 Apr 2024 10:22:54 GMT",
|
||||
"date_end": "Fri, 19 Apr 2024 17:10:40 GMT",
|
||||
"created_at": "Wed, 21 Oct 2020 18:04:55 GMT"
|
||||
},
|
||||
{
|
||||
"key": 61,
|
||||
"trip": "Q",
|
||||
"count": 55,
|
||||
"date_start": "Mon, 28 Aug 2023 18:16:49 GMT",
|
||||
"date_end": "Sun, 10 Sep 2023 01:05:33 GMT",
|
||||
"created_at": "Mon, 29 Jul 2024 02:42:50 GMT"
|
||||
},
|
||||
{
|
||||
"key": 62,
|
||||
"trip": "Казань-Москва",
|
||||
"count": 64,
|
||||
"date_start": "Mon, 11 Dec 2023 13:50:59 GMT",
|
||||
"date_end": "Sun, 24 Dec 2023 20:40:25 GMT",
|
||||
"created_at": "Wed, 13 Jan 2021 18:49:04 GMT"
|
||||
},
|
||||
{
|
||||
"key": 63,
|
||||
"trip": "N",
|
||||
"count": 36,
|
||||
"date_start": "Tue, 29 Sep 2020 17:47:49 GMT",
|
||||
"date_end": "Mon, 12 Oct 2020 00:35:30 GMT",
|
||||
"created_at": "Sat, 05 Dec 2020 01:30:25 GMT"
|
||||
},
|
||||
{
|
||||
"key": 64,
|
||||
"trip": "Z",
|
||||
"count": 36,
|
||||
"date_start": "Tue, 26 May 2020 16:40:52 GMT",
|
||||
"date_end": "Mon, 08 Jun 2020 23:30:25 GMT",
|
||||
"created_at": "Thu, 25 Feb 2021 23:11:39 GMT"
|
||||
},
|
||||
{
|
||||
"key": 65,
|
||||
"trip": "Чита-Тольятти",
|
||||
"count": 61,
|
||||
"date_start": "Thu, 21 Sep 2023 19:54:17 GMT",
|
||||
"date_end": "Wed, 04 Oct 2023 02:45:15 GMT",
|
||||
"created_at": "Thu, 30 Mar 2023 15:33:19 GMT"
|
||||
},
|
||||
{
|
||||
"key": 66,
|
||||
"trip": "Владивосток-Вологда",
|
||||
"count": 42,
|
||||
"date_start": "Tue, 14 Apr 2020 15:50:34 GMT",
|
||||
"date_end": "Mon, 27 Apr 2020 22:40:20 GMT",
|
||||
"created_at": "Thu, 15 Apr 2021 02:35:05 GMT"
|
||||
},
|
||||
{
|
||||
"key": 67,
|
||||
"trip": "U",
|
||||
"count": 75,
|
||||
"date_start": "Thu, 10 Aug 2023 00:45:04 GMT",
|
||||
"date_end": "Wed, 23 Aug 2023 07:35:30 GMT",
|
||||
"created_at": "Wed, 09 Jun 2021 13:29:50 GMT"
|
||||
},
|
||||
{
|
||||
"key": 68,
|
||||
"trip": "Тюмень-Ижевск",
|
||||
"count": 73,
|
||||
"date_start": "Sat, 19 Mar 2022 00:00:59 GMT",
|
||||
"date_end": "Fri, 01 Apr 2022 06:50:40 GMT",
|
||||
"created_at": "Tue, 18 Apr 2023 11:39:45 GMT"
|
||||
},
|
||||
{
|
||||
"key": 69,
|
||||
"trip": "Казань-Тольятти",
|
||||
"count": 27,
|
||||
"date_start": "Thu, 15 Aug 2024 04:16:12 GMT",
|
||||
"date_end": "Wed, 28 Aug 2024 11:05:25 GMT",
|
||||
"created_at": "Wed, 02 Jun 2021 10:41:59 GMT"
|
||||
},
|
||||
{
|
||||
"key": 70,
|
||||
"trip": "Казань-Тольятти",
|
||||
"count": 85,
|
||||
"date_start": "Sun, 27 Sep 2020 23:18:20 GMT",
|
||||
"date_end": "Sat, 10 Oct 2020 06:05:15 GMT",
|
||||
"created_at": "Tue, 13 Apr 2021 06:32:10 GMT"
|
||||
},
|
||||
{
|
||||
"key": 71,
|
||||
"trip": "Москва-Тюмень",
|
||||
"count": 85,
|
||||
"date_start": "Mon, 23 Sep 2024 22:49:15 GMT",
|
||||
"date_end": "Sun, 06 Oct 2024 05:40:30 GMT",
|
||||
"created_at": "Sat, 09 Dec 2023 04:25:00 GMT"
|
||||
},
|
||||
{
|
||||
"key": 72,
|
||||
"trip": "Владисвосток-Москва",
|
||||
"count": 54,
|
||||
"date_start": "Fri, 05 Mar 2021 19:03:42 GMT",
|
||||
"date_end": "Thu, 18 Mar 2021 01:50:25 GMT",
|
||||
"created_at": "Sat, 07 Dec 2024 02:38:40 GMT"
|
||||
},
|
||||
{
|
||||
"key": 73,
|
||||
"trip": "Москва-Пермь",
|
||||
"count": 93,
|
||||
"date_start": "Wed, 16 Feb 2022 08:53:29 GMT",
|
||||
"date_end": "Tue, 01 Mar 2022 15:40:15 GMT",
|
||||
"created_at": "Mon, 18 Jan 2021 12:32:30 GMT"
|
||||
},
|
||||
{
|
||||
"key": 74,
|
||||
"trip": "Казань-Тольятти",
|
||||
"count": 11,
|
||||
"date_start": "Wed, 27 Jan 2021 14:00:04 GMT",
|
||||
"date_end": "Tue, 09 Feb 2021 20:50:30 GMT",
|
||||
"created_at": "Wed, 22 Apr 2020 14:49:30 GMT"
|
||||
},
|
||||
{
|
||||
"key": 75,
|
||||
"trip": "Казань-Москва",
|
||||
"count": 92,
|
||||
"date_start": "Wed, 23 Aug 2023 11:38:30 GMT",
|
||||
"date_end": "Tue, 05 Sep 2023 18:25:15 GMT",
|
||||
"created_at": "Sat, 21 May 2022 15:04:36 GMT"
|
||||
},
|
||||
{
|
||||
"key": 76,
|
||||
"trip": "Москва-Пермь",
|
||||
"count": 7,
|
||||
"date_start": "Tue, 31 May 2022 05:01:43 GMT",
|
||||
"date_end": "Mon, 13 Jun 2022 11:50:30 GMT",
|
||||
"created_at": "Tue, 22 Oct 2024 05:05:05 GMT"
|
||||
},
|
||||
{
|
||||
"key": 77,
|
||||
"trip": "Самара-Тюмень",
|
||||
"count": 71,
|
||||
"date_start": "Thu, 25 Jan 2024 10:34:25 GMT",
|
||||
"date_end": "Wed, 07 Feb 2024 17:20:15 GMT",
|
||||
"created_at": "Tue, 10 Mar 2020 22:40:26 GMT"
|
||||
},
|
||||
{
|
||||
"key": 78,
|
||||
"trip": "Москва-Пермь",
|
||||
"count": 70,
|
||||
"date_start": "Thu, 05 Jan 2023 11:57:02 GMT",
|
||||
"date_end": "Wed, 18 Jan 2023 18:45:30 GMT",
|
||||
"created_at": "Wed, 27 Sep 2023 08:44:01 GMT"
|
||||
},
|
||||
{
|
||||
"key": 79,
|
||||
"trip": "Тюмень-Казань",
|
||||
"count": 68,
|
||||
"date_start": "Fri, 18 Mar 2022 04:00:41 GMT",
|
||||
"date_end": "Thu, 31 Mar 2022 10:50:25 GMT",
|
||||
"created_at": "Tue, 25 Apr 2023 02:53:24 GMT"
|
||||
},
|
||||
{
|
||||
"key": 80,
|
||||
"trip": "Москва-Владивосток",
|
||||
"count": 32,
|
||||
"date_start": "Thu, 09 Mar 2023 20:34:19 GMT",
|
||||
"date_end": "Wed, 22 Mar 2023 03:25:15 GMT",
|
||||
"created_at": "Sun, 05 May 2024 13:34:05 GMT"
|
||||
},
|
||||
{
|
||||
"key": 81,
|
||||
"trip": "Москва-Владивосток",
|
||||
"count": 32,
|
||||
"date_start": "Fri, 09 Dec 2022 00:39:37 GMT",
|
||||
"date_end": "Thu, 22 Dec 2022 07:30:25 GMT",
|
||||
"created_at": "Thu, 03 Oct 2024 18:33:04 GMT"
|
||||
},
|
||||
{
|
||||
"key": 82,
|
||||
"trip": "E",
|
||||
"count": 57,
|
||||
"date_start": "Mon, 21 Mar 2022 09:02:45 GMT",
|
||||
"date_end": "Sun, 03 Apr 2022 15:50:30 GMT",
|
||||
"created_at": "Sun, 16 Oct 2022 07:03:06 GMT"
|
||||
},
|
||||
{
|
||||
"key": 83,
|
||||
"trip": "P",
|
||||
"count": 5,
|
||||
"date_start": "Tue, 05 Mar 2024 13:06:40 GMT",
|
||||
"date_end": "Mon, 18 Mar 2024 19:55:25 GMT",
|
||||
"created_at": "Sun, 18 Apr 2021 02:55:13 GMT"
|
||||
},
|
||||
{
|
||||
"key": 84,
|
||||
"trip": "Казань-Сызрань",
|
||||
"count": 42,
|
||||
"date_start": "Sun, 11 Aug 2024 02:05:46 GMT",
|
||||
"date_end": "Sat, 24 Aug 2024 08:55:30 GMT",
|
||||
"created_at": "Sun, 18 Oct 2020 18:39:33 GMT"
|
||||
},
|
||||
{
|
||||
"key": 85,
|
||||
"trip": "Казань-Тольятти",
|
||||
"count": 48,
|
||||
"date_start": "Fri, 07 Aug 2020 09:21:46 GMT",
|
||||
"date_end": "Thu, 20 Aug 2020 16:10:30 GMT",
|
||||
"created_at": "Wed, 22 Jul 2020 21:59:15 GMT"
|
||||
},
|
||||
{
|
||||
"key": 86,
|
||||
"trip": "Самара-Казань",
|
||||
"count": 43,
|
||||
"date_start": "Thu, 03 Dec 2020 21:51:58 GMT",
|
||||
"date_end": "Wed, 16 Dec 2020 04:40:25 GMT",
|
||||
"created_at": "Mon, 07 Nov 2022 18:42:21 GMT"
|
||||
},
|
||||
{
|
||||
"key": 87,
|
||||
"trip": "Москва-Тюмень",
|
||||
"count": 50,
|
||||
"date_start": "Mon, 20 Apr 2020 17:12:02 GMT",
|
||||
"date_end": "Sun, 03 May 2020 00:00:30 GMT",
|
||||
"created_at": "Fri, 10 Feb 2023 02:41:47 GMT"
|
||||
},
|
||||
{
|
||||
"key": 88,
|
||||
"trip": "Москва-Вологда",
|
||||
"count": 83,
|
||||
"date_start": "Mon, 22 Jan 2024 07:15:58 GMT",
|
||||
"date_end": "Sun, 04 Feb 2024 14:05:25 GMT",
|
||||
"created_at": "Mon, 27 Jan 2020 21:25:21 GMT"
|
||||
},
|
||||
{
|
||||
"key": 89,
|
||||
"trip": "Владивосток-Владимир",
|
||||
"count": 27,
|
||||
"date_start": "Wed, 08 Nov 2023 00:45:46 GMT",
|
||||
"date_end": "Tue, 21 Nov 2023 07:35:30 GMT",
|
||||
"created_at": "Sun, 03 Apr 2022 08:12:20 GMT"
|
||||
},
|
||||
{
|
||||
"key": 90,
|
||||
"trip": "S",
|
||||
"count": 75,
|
||||
"date_start": "Fri, 06 Aug 2021 21:40:09 GMT",
|
||||
"date_end": "Thu, 19 Aug 2021 04:30:25 GMT",
|
||||
"created_at": "Mon, 27 Dec 2021 10:19:20 GMT"
|
||||
},
|
||||
{
|
||||
"key": 91,
|
||||
"trip": "U",
|
||||
"count": 30,
|
||||
"date_start": "Sun, 08 Mar 2020 17:12:41 GMT",
|
||||
"date_end": "Sat, 21 Mar 2020 00:00:30 GMT",
|
||||
"created_at": "Mon, 22 Aug 2022 15:00:22 GMT"
|
||||
},
|
||||
{
|
||||
"key": 92,
|
||||
"trip": "Москва-Ижевск",
|
||||
"count": 46,
|
||||
"date_start": "Sat, 23 May 2020 10:41:01 GMT",
|
||||
"date_end": "Fri, 05 Jun 2020 17:30:25 GMT",
|
||||
"created_at": "Wed, 06 Jul 2022 06:39:05 GMT"
|
||||
},
|
||||
{
|
||||
"key": 93,
|
||||
"trip": "U",
|
||||
"count": 16,
|
||||
"date_start": "Fri, 26 May 2023 09:51:29 GMT",
|
||||
"date_end": "Thu, 08 Jun 2023 16:40:15 GMT",
|
||||
"created_at": "Sat, 26 Feb 2022 08:38:00 GMT"
|
||||
},
|
||||
{
|
||||
"key": 94,
|
||||
"trip": "Q",
|
||||
"count": 4,
|
||||
"date_start": "Wed, 08 Jul 2020 11:02:39 GMT",
|
||||
"date_end": "Tue, 21 Jul 2020 17:50:25 GMT",
|
||||
"created_at": "Fri, 24 Dec 2021 23:43:01 GMT"
|
||||
},
|
||||
{
|
||||
"key": 95,
|
||||
"trip": "Москва-Вологда",
|
||||
"count": 70,
|
||||
"date_start": "Mon, 10 Oct 2022 00:06:40 GMT",
|
||||
"date_end": "Sun, 23 Oct 2022 06:55:30 GMT",
|
||||
"created_at": "Sun, 28 Jul 2024 09:47:35 GMT"
|
||||
},
|
||||
{
|
||||
"key": 96,
|
||||
"trip": "N",
|
||||
"count": 80,
|
||||
"date_start": "Thu, 03 Oct 2024 02:27:30 GMT",
|
||||
"date_end": "Wed, 16 Oct 2024 09:15:25 GMT",
|
||||
"created_at": "Sun, 12 Feb 2023 12:07:51 GMT"
|
||||
},
|
||||
{
|
||||
"key": 97,
|
||||
"trip": "Тюмень-Вологда",
|
||||
"count": 40,
|
||||
"date_start": "Sat, 23 Mar 2024 21:42:40 GMT",
|
||||
"date_end": "Fri, 05 Apr 2024 04:30:30 GMT",
|
||||
"created_at": "Fri, 17 Mar 2023 15:47:32 GMT"
|
||||
},
|
||||
{
|
||||
"key": 98,
|
||||
"trip": "Москва-Пермь",
|
||||
"count": 38,
|
||||
"date_start": "Thu, 14 Jul 2022 14:03:39 GMT",
|
||||
"date_end": "Wed, 27 Jul 2022 20:50:25 GMT",
|
||||
"created_at": "Sat, 30 Nov 2024 07:09:52 GMT"
|
||||
},
|
||||
{
|
||||
"key": 99,
|
||||
"trip": "Тюмень-Казань",
|
||||
"count": 74,
|
||||
"date_start": "Thu, 19 Mar 2020 04:40:50 GMT",
|
||||
"date_end": "Wed, 01 Apr 2020 11:30:30 GMT",
|
||||
"created_at": "Thu, 20 Aug 2020 13:58:54 GMT"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
const router = require('express').Router();
|
||||
|
||||
router.use('/customer', require('./dashboard-customer'))
|
||||
router.use('/performer', require('./dashboard-performer'))
|
||||
router.use('/auth', require('./auth'))
|
||||
router.use('/landing', require('./landing'))
|
||||
|
||||
1
server/routers/kfu-m-24-1/back-new/.env
Normal file
1
server/routers/kfu-m-24-1/back-new/.env
Normal file
@@ -0,0 +1 @@
|
||||
GIGACHAT_API_KEY=NzgzNTkxMjMtNDQ0Ny00ODFhLTkwMjgtODYxZjUzYjI0ZWQxOjA5NDEwMzEwLTM5YjItNDUzOS1hYWYzLWE4ZDA1MDExNmQ4Nw==
|
||||
2
server/routers/kfu-m-24-1/back-new/.gitignore
vendored
Normal file
2
server/routers/kfu-m-24-1/back-new/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
node_modules/
|
||||
.env
|
||||
21
server/routers/kfu-m-24-1/back-new/README.md
Normal file
21
server/routers/kfu-m-24-1/back-new/README.md
Normal file
@@ -0,0 +1,21 @@
|
||||
# back-new
|
||||
|
||||
非Python实现的后端(Node.js + Express)
|
||||
|
||||
## 启动方法
|
||||
|
||||
1. 安装依赖:
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
2. 启动服务:
|
||||
```bash
|
||||
npm start
|
||||
```
|
||||
|
||||
默认端口:`3002`
|
||||
|
||||
## 支持接口
|
||||
- POST `/api/auth/login` 用户登录
|
||||
- POST `/api/auth/register` 用户注册
|
||||
- GET `/gigachat/prompt?prompt=xxx` 生成图片(返回模拟图片链接)
|
||||
24
server/routers/kfu-m-24-1/back-new/app.js
Normal file
24
server/routers/kfu-m-24-1/back-new/app.js
Normal file
@@ -0,0 +1,24 @@
|
||||
const express = require('express');
|
||||
const cors = require('cors');
|
||||
const featuresConfig = require('./features.config');
|
||||
const imageRoutes = require('./features/image/image.routes');
|
||||
|
||||
const app = express();
|
||||
app.use(cors());
|
||||
app.use(express.json());
|
||||
|
||||
if (featuresConfig.auth) {
|
||||
app.use('/api/auth', require('./features/auth/auth.routes'));
|
||||
}
|
||||
if (featuresConfig.user) {
|
||||
app.use('/api/user', require('./features/user/user.routes'));
|
||||
}
|
||||
if (featuresConfig.image) {
|
||||
app.use('/gigachat', imageRoutes);
|
||||
}
|
||||
|
||||
app.get('/api/', (req, res) => {
|
||||
res.json({ message: 'API root' });
|
||||
});
|
||||
|
||||
module.exports = app;
|
||||
5
server/routers/kfu-m-24-1/back-new/features.config.js
Normal file
5
server/routers/kfu-m-24-1/back-new/features.config.js
Normal file
@@ -0,0 +1,5 @@
|
||||
module.exports = {
|
||||
auth: true,
|
||||
user: true,
|
||||
image: true, // 关闭为 false
|
||||
};
|
||||
@@ -0,0 +1,95 @@
|
||||
const usersDb = require('../../shared/usersDb');
|
||||
const makeLinks = require('../../shared/hateoas');
|
||||
|
||||
exports.login = (req, res) => {
|
||||
const { username, password, email } = req.body;
|
||||
const user = usersDb.findUser(username, email, password);
|
||||
if (user) {
|
||||
res.json({
|
||||
data: {
|
||||
user: {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
email: user.email,
|
||||
firstName: user.firstName,
|
||||
lastName: user.lastName
|
||||
},
|
||||
token: 'token-' + user.id,
|
||||
message: 'Login successful'
|
||||
},
|
||||
_links: makeLinks('/api/auth', {
|
||||
self: '/login',
|
||||
profile: '/profile/',
|
||||
logout: '/logout'
|
||||
}),
|
||||
_meta: {}
|
||||
});
|
||||
} else {
|
||||
res.status(401).json({ error: 'Invalid credentials' });
|
||||
}
|
||||
};
|
||||
|
||||
exports.register = (req, res) => {
|
||||
const { username, password, email, firstName, lastName } = req.body;
|
||||
if (usersDb.exists(username, email)) {
|
||||
return res.status(409).json({ error: 'User already exists' });
|
||||
}
|
||||
const newUser = usersDb.addUser({ username, password, email, firstName, lastName });
|
||||
res.json({
|
||||
data: {
|
||||
user: {
|
||||
id: newUser.id,
|
||||
username,
|
||||
email,
|
||||
firstName,
|
||||
lastName
|
||||
},
|
||||
token: 'token-' + newUser.id,
|
||||
message: 'Register successful'
|
||||
},
|
||||
_links: makeLinks('/api/auth', {
|
||||
self: '/register',
|
||||
login: '/login',
|
||||
profile: '/profile/'
|
||||
}),
|
||||
_meta: {}
|
||||
});
|
||||
};
|
||||
|
||||
exports.profile = (req, res) => {
|
||||
const auth = req.headers.authorization;
|
||||
if (!auth || !auth.startsWith('Bearer ')) {
|
||||
return res.status(401).json({ error: 'No token provided' });
|
||||
}
|
||||
const token = auth.replace('Bearer ', '');
|
||||
const id = parseInt(token.replace('token-', ''));
|
||||
const user = usersDb.findById(id);
|
||||
if (!user) {
|
||||
return res.status(401).json({ error: 'Invalid token' });
|
||||
}
|
||||
res.json({
|
||||
data: {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
email: user.email,
|
||||
firstName: user.firstName,
|
||||
lastName: user.lastName
|
||||
},
|
||||
_links: makeLinks('/api/auth', {
|
||||
self: '/profile/',
|
||||
logout: '/logout'
|
||||
}),
|
||||
_meta: {}
|
||||
});
|
||||
};
|
||||
|
||||
exports.logout = (req, res) => {
|
||||
res.json({
|
||||
message: 'Logout successful',
|
||||
_links: makeLinks('/api/auth', {
|
||||
self: '/logout',
|
||||
login: '/login'
|
||||
}),
|
||||
_meta: {}
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,10 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const ctrl = require('./auth.controller');
|
||||
|
||||
router.post('/login', ctrl.login);
|
||||
router.post('/register', ctrl.register);
|
||||
router.get('/profile/', ctrl.profile);
|
||||
router.post('/logout', ctrl.logout);
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,82 @@
|
||||
const axios = require('axios');
|
||||
const makeLinks = require('../../shared/hateoas');
|
||||
const path = require('path');
|
||||
const qs = require('qs');
|
||||
const { v4: uuidv4 } = require('uuid');
|
||||
require('dotenv').config({ path: path.resolve(__dirname, '../../.env') });
|
||||
process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0';
|
||||
|
||||
exports.generate = async (req, res) => {
|
||||
const { prompt } = req.query;
|
||||
if (!prompt) {
|
||||
return res.status(400).json({ error: 'Prompt parameter is required' });
|
||||
}
|
||||
try {
|
||||
const apiKey = process.env.GIGACHAT_API_KEY;
|
||||
const tokenResp = await axios.post(
|
||||
'https://ngw.devices.sberbank.ru:9443/api/v2/oauth',
|
||||
{
|
||||
'scope':' GIGACHAT_API_PERS',
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
'Accept': 'application/json',
|
||||
'Authorization': `Basic ${apiKey}`,
|
||||
'RqUID':'6f0b1291-c7f3-43c6-bb2e-9f3efb2dc98e'
|
||||
},
|
||||
}
|
||||
);
|
||||
const accessToken = tokenResp.data.access_token;
|
||||
const chatResp = await axios.post(
|
||||
'https://gigachat.devices.sberbank.ru/api/v1/chat/completions',
|
||||
{
|
||||
model: "GigaChat",
|
||||
messages: [
|
||||
{ role: "system", content: "Ты — Василий Кандинский" },
|
||||
{ role: "user", content: prompt }
|
||||
],
|
||||
stream: false,
|
||||
function_call: 'auto'
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
'Authorization': `Bearer ${accessToken}`,
|
||||
'Content-Type': 'application/json',
|
||||
'RqUID': uuidv4(),
|
||||
}
|
||||
}
|
||||
);
|
||||
const content = chatResp.data.choices[0].message.content;
|
||||
// eslint-disable-next-line no-useless-escape
|
||||
const match = content.match(/<img src=\"(.*?)\"/);
|
||||
if (!match) {
|
||||
return res.status(500).json({ error: 'No image generated' });
|
||||
}
|
||||
const imageId = match[1];
|
||||
const imageResp = await axios.get(
|
||||
`https://gigachat.devices.sberbank.ru/api/v1/files/${imageId}/content`,
|
||||
{
|
||||
headers: {
|
||||
'Authorization': `Bearer ${accessToken}`,
|
||||
'RqUID': uuidv4(),
|
||||
},
|
||||
responseType: 'arraybuffer'
|
||||
}
|
||||
);
|
||||
res.set('Content-Type', 'image/jpeg');
|
||||
res.set('X-HATEOAS', JSON.stringify(makeLinks('/gigachat', { self: '/prompt' })));
|
||||
res.send(imageResp.data);
|
||||
} catch (err) {
|
||||
if (err.response) {
|
||||
console.error('AI生成图片出错:');
|
||||
console.error('status:', err.response.status);
|
||||
console.error('headers:', err.response.headers);
|
||||
console.error('data:', err.response.data);
|
||||
console.error('config:', err.config);
|
||||
} else {
|
||||
console.error('AI生成图片出错:', err.message);
|
||||
}
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,7 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const ctrl = require('./image.controller');
|
||||
|
||||
router.get('/prompt', ctrl.generate);
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,12 @@
|
||||
const usersDb = require('../../shared/usersDb');
|
||||
const makeLinks = require('../../shared/hateoas');
|
||||
|
||||
exports.list = (req, res) => {
|
||||
res.json({
|
||||
data: usersDb.getAll(),
|
||||
_links: makeLinks('/api/user', {
|
||||
self: '/list',
|
||||
}),
|
||||
_meta: {}
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,7 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const ctrl = require('./user.controller');
|
||||
|
||||
router.get('/list', ctrl.list);
|
||||
|
||||
module.exports = router;
|
||||
5455
server/routers/kfu-m-24-1/back-new/package-lock.json
generated
Normal file
5455
server/routers/kfu-m-24-1/back-new/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
21
server/routers/kfu-m-24-1/back-new/package.json
Normal file
21
server/routers/kfu-m-24-1/back-new/package.json
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"name": "back-new",
|
||||
"version": "1.0.0",
|
||||
"description": "非Python实现的后端,兼容前端接口",
|
||||
"main": "server.js",
|
||||
"scripts": {
|
||||
"start": "node server.js",
|
||||
"test": "jest"
|
||||
},
|
||||
"dependencies": {
|
||||
"axios": "^1.10.0",
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^17.0.0",
|
||||
"express": "^4.21.2",
|
||||
"qs": "^6.14.0",
|
||||
"uuid": "^11.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"jest": "^30.0.3"
|
||||
}
|
||||
}
|
||||
5
server/routers/kfu-m-24-1/back-new/server.js
Normal file
5
server/routers/kfu-m-24-1/back-new/server.js
Normal file
@@ -0,0 +1,5 @@
|
||||
const app = require('./app');
|
||||
const PORT = process.env.PORT || 3002;
|
||||
app.listen(PORT, () => {
|
||||
console.log(`Mock backend running on https://dev.bro.js.ru/ms/back-new/${PORT}`);
|
||||
});
|
||||
8
server/routers/kfu-m-24-1/back-new/shared/hateoas.js
Normal file
8
server/routers/kfu-m-24-1/back-new/shared/hateoas.js
Normal file
@@ -0,0 +1,8 @@
|
||||
function makeLinks(base, links) {
|
||||
const result = {};
|
||||
for (const [rel, path] of Object.entries(links)) {
|
||||
result[rel] = { href: base + path };
|
||||
}
|
||||
return result;
|
||||
}
|
||||
module.exports = makeLinks;
|
||||
20
server/routers/kfu-m-24-1/back-new/shared/usersDb.js
Normal file
20
server/routers/kfu-m-24-1/back-new/shared/usersDb.js
Normal file
@@ -0,0 +1,20 @@
|
||||
let users = [
|
||||
{ id: 1, username: 'test', password: '123456', email: 'test@example.com', firstName: 'Test', lastName: 'User' }
|
||||
];
|
||||
let nextId = 2;
|
||||
|
||||
exports.findUser = (username, email, password) =>
|
||||
users.find(u => (u.username === username || u.email === email) && u.password === password);
|
||||
|
||||
exports.findById = (id) => users.find(u => u.id === id);
|
||||
|
||||
exports.addUser = ({ username, password, email, firstName, lastName }) => {
|
||||
const newUser = { id: nextId++, username, password, email, firstName, lastName };
|
||||
users.push(newUser);
|
||||
return newUser;
|
||||
};
|
||||
|
||||
exports.exists = (username, email) =>
|
||||
users.some(u => u.username === username || u.email === email);
|
||||
|
||||
exports.getAll = () => users;
|
||||
113
server/routers/procurement/index.js
Normal file
113
server/routers/procurement/index.js
Normal file
@@ -0,0 +1,113 @@
|
||||
const express = require('express');
|
||||
const cors = require('cors');
|
||||
const dotenv = require('dotenv');
|
||||
const fs = require('fs');
|
||||
|
||||
// Импортировать mongoose из общего модуля (подключение происходит в server/utils/mongoose.ts)
|
||||
const mongoose = require('../../utils/mongoose');
|
||||
|
||||
// Загрузить переменные окружения
|
||||
dotenv.config();
|
||||
|
||||
// Включить логирование при разработке: установите DEV=true в .env или при запуске
|
||||
// export DEV=true && npm start (для Linux/Mac)
|
||||
// set DEV=true && npm start (для Windows)
|
||||
// По умолчанию логи отключены. Все console.log функции отключаются если DEV !== 'true'
|
||||
if (process.env.DEV === 'true') {
|
||||
console.log('ℹ️ DEBUG MODE ENABLED - All logs are visible');
|
||||
}
|
||||
|
||||
// Импортировать маршруты - прямые пути без path.join и __dirname
|
||||
const authRoutes = require('./routes/auth');
|
||||
const companiesRoutes = require('./routes/companies');
|
||||
const messagesRoutes = require('./routes/messages');
|
||||
const searchRoutes = require('./routes/search');
|
||||
const buyRoutes = require('./routes/buy');
|
||||
const experienceRoutes = require('./routes/experience');
|
||||
const productsRoutes = require('./routes/products');
|
||||
const reviewsRoutes = require('./routes/reviews');
|
||||
const buyProductsRoutes = require('./routes/buyProducts');
|
||||
const requestsRoutes = require('./routes/requests');
|
||||
const homeRoutes = require('./routes/home');
|
||||
const activityRoutes = require('./routes/activity');
|
||||
|
||||
const app = express();
|
||||
|
||||
// Проверить подключение к MongoDB (подключение происходит в server/utils/mongoose.ts)
|
||||
const dbConnected = mongoose.connection.readyState === 1;
|
||||
|
||||
// Middleware
|
||||
app.use(cors());
|
||||
app.use(express.json({ charset: 'utf-8' }));
|
||||
app.use(express.urlencoded({ extended: true, charset: 'utf-8' }));
|
||||
|
||||
// Set UTF-8 encoding for all responses
|
||||
app.use((req, res, next) => {
|
||||
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
||||
next();
|
||||
});
|
||||
|
||||
// CORS headers
|
||||
app.use((req, res, next) => {
|
||||
res.setHeader('Access-Control-Allow-Origin', '*');
|
||||
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
|
||||
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization');
|
||||
if (req.method === 'OPTIONS') {
|
||||
res.sendStatus(200);
|
||||
} else {
|
||||
next();
|
||||
}
|
||||
});
|
||||
|
||||
// Задержка для имитации сети (опционально)
|
||||
const delay = (ms = 300) => (req, res, next) => setTimeout(next, ms);
|
||||
app.use(delay());
|
||||
|
||||
// Статика для загруженных файлов
|
||||
const uploadsRoot = 'server/remote-assets/uploads';
|
||||
if (!fs.existsSync(uploadsRoot)) {
|
||||
fs.mkdirSync(uploadsRoot, { recursive: true });
|
||||
}
|
||||
app.use('/uploads', express.static(uploadsRoot));
|
||||
|
||||
// Health check endpoint
|
||||
app.get('/health', (req, res) => {
|
||||
res.json({
|
||||
status: 'ok',
|
||||
api: 'running',
|
||||
database: dbConnected ? 'mongodb' : 'mock',
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
});
|
||||
|
||||
// Маршруты
|
||||
app.use('/auth', authRoutes);
|
||||
app.use('/companies', companiesRoutes);
|
||||
app.use('/messages', messagesRoutes);
|
||||
app.use('/search', searchRoutes);
|
||||
app.use('/buy', buyRoutes);
|
||||
app.use('/buy-products', buyProductsRoutes);
|
||||
app.use('/experience', experienceRoutes);
|
||||
app.use('/products', productsRoutes);
|
||||
app.use('/reviews', reviewsRoutes);
|
||||
app.use('/requests', requestsRoutes);
|
||||
app.use('/home', homeRoutes);
|
||||
app.use('/activities', activityRoutes);
|
||||
|
||||
// Обработка ошибок
|
||||
app.use((err, req, res, next) => {
|
||||
console.error('API Error:', err);
|
||||
res.status(err.status || 500).json({
|
||||
error: err.message || 'Internal server error'
|
||||
});
|
||||
});
|
||||
|
||||
// 404 handler
|
||||
app.use((req, res) => {
|
||||
res.status(404).json({
|
||||
error: 'Not found'
|
||||
});
|
||||
});
|
||||
|
||||
// Экспортировать для использования в brojs
|
||||
module.exports = app;
|
||||
42
server/routers/procurement/middleware/auth.js
Normal file
42
server/routers/procurement/middleware/auth.js
Normal file
@@ -0,0 +1,42 @@
|
||||
const jwt = require('jsonwebtoken');
|
||||
|
||||
const log = (message, data = '') => {
|
||||
if (process.env.DEV === 'true') {
|
||||
if (data) {
|
||||
console.log(message, data);
|
||||
} else {
|
||||
console.log(message);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const verifyToken = (req, res, next) => {
|
||||
const token = req.headers.authorization?.replace('Bearer ', '');
|
||||
|
||||
if (!token) {
|
||||
return res.status(401).json({ error: 'No token provided' });
|
||||
}
|
||||
|
||||
try {
|
||||
const decoded = jwt.verify(token, process.env.JWT_SECRET || 'your-secret-key');
|
||||
req.userId = decoded.userId;
|
||||
req.companyId = decoded.companyId;
|
||||
req.user = decoded;
|
||||
log('[Auth] Token verified - userId:', decoded.userId, 'companyId:', decoded.companyId);
|
||||
next();
|
||||
} catch (error) {
|
||||
console.error('[Auth] Token verification failed:', error.message);
|
||||
return res.status(401).json({ error: 'Invalid token' });
|
||||
}
|
||||
};
|
||||
|
||||
const generateToken = (userId, companyId, firstName = '', lastName = '', companyName = '') => {
|
||||
log('[Auth] Generating token for userId:', userId, 'companyId:', companyId);
|
||||
return jwt.sign(
|
||||
{ userId, companyId, firstName, lastName, companyName },
|
||||
process.env.JWT_SECRET || 'your-secret-key',
|
||||
{ expiresIn: '7d' }
|
||||
);
|
||||
};
|
||||
|
||||
module.exports = { verifyToken, generateToken };
|
||||
61
server/routers/procurement/models/Activity.js
Normal file
61
server/routers/procurement/models/Activity.js
Normal file
@@ -0,0 +1,61 @@
|
||||
const mongoose = require('mongoose');
|
||||
|
||||
const activitySchema = new mongoose.Schema({
|
||||
companyId: {
|
||||
type: String,
|
||||
required: true,
|
||||
index: true
|
||||
},
|
||||
userId: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
type: {
|
||||
type: String,
|
||||
enum: [
|
||||
'message_received',
|
||||
'message_sent',
|
||||
'request_received',
|
||||
'request_sent',
|
||||
'request_response',
|
||||
'product_accepted',
|
||||
'review_received',
|
||||
'profile_updated',
|
||||
'product_added',
|
||||
'buy_product_added'
|
||||
],
|
||||
required: true
|
||||
},
|
||||
title: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
description: {
|
||||
type: String
|
||||
},
|
||||
relatedCompanyId: {
|
||||
type: String
|
||||
},
|
||||
relatedCompanyName: {
|
||||
type: String
|
||||
},
|
||||
metadata: {
|
||||
type: mongoose.Schema.Types.Mixed
|
||||
},
|
||||
read: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
createdAt: {
|
||||
type: Date,
|
||||
default: Date.now,
|
||||
index: true
|
||||
}
|
||||
});
|
||||
|
||||
// Индексы для оптимизации
|
||||
activitySchema.index({ companyId: 1, createdAt: -1 });
|
||||
activitySchema.index({ companyId: 1, read: 1, createdAt: -1 });
|
||||
|
||||
module.exports = mongoose.model('Activity', activitySchema);
|
||||
|
||||
43
server/routers/procurement/models/BuyDocument.js
Normal file
43
server/routers/procurement/models/BuyDocument.js
Normal file
@@ -0,0 +1,43 @@
|
||||
const mongoose = require('mongoose');
|
||||
|
||||
const buyDocumentSchema = new mongoose.Schema({
|
||||
id: {
|
||||
type: String,
|
||||
required: true,
|
||||
unique: true,
|
||||
index: true
|
||||
},
|
||||
ownerCompanyId: {
|
||||
type: String,
|
||||
required: true,
|
||||
index: true
|
||||
},
|
||||
name: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
type: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
size: {
|
||||
type: Number,
|
||||
required: true
|
||||
},
|
||||
filePath: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
acceptedBy: {
|
||||
type: [String],
|
||||
default: []
|
||||
},
|
||||
createdAt: {
|
||||
type: Date,
|
||||
default: Date.now,
|
||||
index: true
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = mongoose.model('BuyDocument', buyDocumentSchema);
|
||||
|
||||
87
server/routers/procurement/models/BuyProduct.js
Normal file
87
server/routers/procurement/models/BuyProduct.js
Normal file
@@ -0,0 +1,87 @@
|
||||
const mongoose = require('mongoose');
|
||||
|
||||
// Явно определяем схему для файлов
|
||||
const fileSchema = new mongoose.Schema({
|
||||
id: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
name: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
url: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
type: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
size: {
|
||||
type: Number,
|
||||
required: true
|
||||
},
|
||||
storagePath: String,
|
||||
uploadedAt: {
|
||||
type: Date,
|
||||
default: Date.now
|
||||
}
|
||||
}, { _id: false });
|
||||
|
||||
const buyProductSchema = new mongoose.Schema({
|
||||
companyId: {
|
||||
type: String,
|
||||
required: true,
|
||||
index: true
|
||||
},
|
||||
name: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
description: {
|
||||
type: String,
|
||||
required: true,
|
||||
minlength: 10,
|
||||
maxlength: 1000
|
||||
},
|
||||
quantity: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
unit: {
|
||||
type: String,
|
||||
default: 'шт'
|
||||
},
|
||||
files: [fileSchema],
|
||||
acceptedBy: [{
|
||||
companyId: {
|
||||
type: mongoose.Schema.Types.ObjectId,
|
||||
ref: 'Company'
|
||||
},
|
||||
acceptedAt: {
|
||||
type: Date,
|
||||
default: Date.now
|
||||
}
|
||||
}],
|
||||
status: {
|
||||
type: String,
|
||||
enum: ['draft', 'published'],
|
||||
default: 'published'
|
||||
},
|
||||
createdAt: {
|
||||
type: Date,
|
||||
default: Date.now,
|
||||
index: true
|
||||
},
|
||||
updatedAt: {
|
||||
type: Date,
|
||||
default: Date.now
|
||||
}
|
||||
});
|
||||
|
||||
// Индексы для оптимизации поиска
|
||||
buyProductSchema.index({ companyId: 1, createdAt: -1 });
|
||||
buyProductSchema.index({ name: 'text', description: 'text' });
|
||||
|
||||
module.exports = mongoose.model('BuyProduct', buyProductSchema);
|
||||
76
server/routers/procurement/models/Company.js
Normal file
76
server/routers/procurement/models/Company.js
Normal file
@@ -0,0 +1,76 @@
|
||||
const mongoose = require('mongoose');
|
||||
|
||||
const companySchema = new mongoose.Schema({
|
||||
fullName: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
shortName: String,
|
||||
inn: {
|
||||
type: String,
|
||||
sparse: true
|
||||
},
|
||||
ogrn: String,
|
||||
legalForm: String,
|
||||
industry: String,
|
||||
companySize: String,
|
||||
website: String,
|
||||
phone: String,
|
||||
email: String,
|
||||
slogan: String,
|
||||
description: String,
|
||||
foundedYear: Number,
|
||||
employeeCount: String,
|
||||
revenue: String,
|
||||
legalAddress: String,
|
||||
actualAddress: String,
|
||||
bankDetails: String,
|
||||
logo: String,
|
||||
rating: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
min: 0,
|
||||
max: 5
|
||||
},
|
||||
reviews: {
|
||||
type: Number,
|
||||
default: 0
|
||||
},
|
||||
ownerId: {
|
||||
type: mongoose.Schema.Types.ObjectId,
|
||||
ref: 'User'
|
||||
},
|
||||
platformGoals: [String],
|
||||
productsOffered: String,
|
||||
productsNeeded: String,
|
||||
partnerIndustries: [String],
|
||||
partnerGeography: [String],
|
||||
verified: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
metrics: {
|
||||
type: {
|
||||
profileViews: { type: Number, default: 0 }
|
||||
},
|
||||
default: {}
|
||||
},
|
||||
createdAt: {
|
||||
type: Date,
|
||||
default: Date.now
|
||||
},
|
||||
updatedAt: {
|
||||
type: Date,
|
||||
default: Date.now
|
||||
}
|
||||
}, {
|
||||
collection: 'companies',
|
||||
minimize: false
|
||||
});
|
||||
|
||||
// Индексы для поиска
|
||||
companySchema.index({ fullName: 'text', shortName: 'text', description: 'text' });
|
||||
companySchema.index({ industry: 1 });
|
||||
companySchema.index({ rating: -1 });
|
||||
|
||||
module.exports = mongoose.model('Company', companySchema);
|
||||
46
server/routers/procurement/models/Experience.js
Normal file
46
server/routers/procurement/models/Experience.js
Normal file
@@ -0,0 +1,46 @@
|
||||
const mongoose = require('mongoose');
|
||||
|
||||
const experienceSchema = new mongoose.Schema({
|
||||
companyId: {
|
||||
type: mongoose.Schema.Types.ObjectId,
|
||||
ref: 'Company',
|
||||
required: true,
|
||||
index: true
|
||||
},
|
||||
confirmed: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
customer: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
subject: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
volume: {
|
||||
type: String
|
||||
},
|
||||
contact: {
|
||||
type: String
|
||||
},
|
||||
comment: {
|
||||
type: String
|
||||
},
|
||||
createdAt: {
|
||||
type: Date,
|
||||
default: Date.now,
|
||||
index: true
|
||||
},
|
||||
updatedAt: {
|
||||
type: Date,
|
||||
default: Date.now
|
||||
}
|
||||
});
|
||||
|
||||
// Индексы для оптимизации поиска
|
||||
experienceSchema.index({ companyId: 1, createdAt: -1 });
|
||||
|
||||
module.exports = mongoose.model('Experience', experienceSchema);
|
||||
|
||||
37
server/routers/procurement/models/Message.js
Normal file
37
server/routers/procurement/models/Message.js
Normal file
@@ -0,0 +1,37 @@
|
||||
const mongoose = require('mongoose');
|
||||
|
||||
const messageSchema = new mongoose.Schema({
|
||||
threadId: {
|
||||
type: String,
|
||||
required: true,
|
||||
index: true
|
||||
},
|
||||
senderCompanyId: {
|
||||
type: mongoose.Schema.Types.ObjectId,
|
||||
ref: 'Company',
|
||||
required: true
|
||||
},
|
||||
recipientCompanyId: {
|
||||
type: mongoose.Schema.Types.ObjectId,
|
||||
ref: 'Company',
|
||||
required: true
|
||||
},
|
||||
text: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
read: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
timestamp: {
|
||||
type: Date,
|
||||
default: Date.now,
|
||||
index: true
|
||||
}
|
||||
});
|
||||
|
||||
// Индекс для быстрого поиска сообщений потока
|
||||
messageSchema.index({ threadId: 1, timestamp: -1 });
|
||||
|
||||
module.exports = mongoose.model('Message', messageSchema);
|
||||
57
server/routers/procurement/models/Product.js
Normal file
57
server/routers/procurement/models/Product.js
Normal file
@@ -0,0 +1,57 @@
|
||||
const mongoose = require('mongoose');
|
||||
|
||||
const productSchema = new mongoose.Schema({
|
||||
name: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
category: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
description: {
|
||||
type: String,
|
||||
required: true,
|
||||
minlength: 20,
|
||||
maxlength: 500
|
||||
},
|
||||
type: {
|
||||
type: String,
|
||||
enum: ['sell', 'buy'],
|
||||
required: true
|
||||
},
|
||||
productUrl: String,
|
||||
companyId: {
|
||||
type: String,
|
||||
required: true,
|
||||
index: true
|
||||
},
|
||||
price: String,
|
||||
unit: String,
|
||||
minOrder: String,
|
||||
createdAt: {
|
||||
type: Date,
|
||||
default: Date.now,
|
||||
index: true
|
||||
},
|
||||
updatedAt: {
|
||||
type: Date,
|
||||
default: Date.now
|
||||
}
|
||||
});
|
||||
|
||||
// Индекс для поиска
|
||||
productSchema.index({ companyId: 1, type: 1 });
|
||||
productSchema.index({ name: 'text', description: 'text' });
|
||||
|
||||
// Transform _id to id in JSON output
|
||||
productSchema.set('toJSON', {
|
||||
transform: (doc, ret) => {
|
||||
ret.id = ret._id;
|
||||
delete ret._id;
|
||||
delete ret.__v;
|
||||
return ret;
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = mongoose.model('Product', productSchema);
|
||||
82
server/routers/procurement/models/Request.js
Normal file
82
server/routers/procurement/models/Request.js
Normal file
@@ -0,0 +1,82 @@
|
||||
const mongoose = require('mongoose');
|
||||
|
||||
const requestSchema = new mongoose.Schema({
|
||||
senderCompanyId: {
|
||||
type: String,
|
||||
required: true,
|
||||
index: true
|
||||
},
|
||||
recipientCompanyId: {
|
||||
type: String,
|
||||
required: true,
|
||||
index: true
|
||||
},
|
||||
subject: {
|
||||
type: String,
|
||||
required: false,
|
||||
trim: true,
|
||||
default: ''
|
||||
},
|
||||
text: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
files: [{
|
||||
id: { type: String },
|
||||
name: { type: String },
|
||||
url: { type: String },
|
||||
type: { type: String },
|
||||
size: { type: Number },
|
||||
storagePath: { type: String },
|
||||
uploadedAt: {
|
||||
type: Date,
|
||||
default: Date.now
|
||||
}
|
||||
}],
|
||||
productId: {
|
||||
type: String,
|
||||
ref: 'BuyProduct'
|
||||
},
|
||||
status: {
|
||||
type: String,
|
||||
enum: ['pending', 'accepted', 'rejected'],
|
||||
default: 'pending'
|
||||
},
|
||||
response: {
|
||||
type: String,
|
||||
default: null
|
||||
},
|
||||
responseFiles: [{
|
||||
id: { type: String },
|
||||
name: { type: String },
|
||||
url: { type: String },
|
||||
type: { type: String },
|
||||
size: { type: Number },
|
||||
storagePath: { type: String },
|
||||
uploadedAt: {
|
||||
type: Date,
|
||||
default: Date.now
|
||||
}
|
||||
}],
|
||||
respondedAt: {
|
||||
type: Date,
|
||||
default: null
|
||||
},
|
||||
createdAt: {
|
||||
type: Date,
|
||||
default: Date.now,
|
||||
index: true
|
||||
},
|
||||
updatedAt: {
|
||||
type: Date,
|
||||
default: Date.now
|
||||
}
|
||||
});
|
||||
|
||||
// Индексы для оптимизации поиска
|
||||
requestSchema.index({ senderCompanyId: 1, createdAt: -1 });
|
||||
requestSchema.index({ recipientCompanyId: 1, createdAt: -1 });
|
||||
requestSchema.index({ senderCompanyId: 1, recipientCompanyId: 1 });
|
||||
requestSchema.index({ subject: 1, createdAt: -1 });
|
||||
|
||||
module.exports = mongoose.model('Request', requestSchema);
|
||||
58
server/routers/procurement/models/Review.js
Normal file
58
server/routers/procurement/models/Review.js
Normal file
@@ -0,0 +1,58 @@
|
||||
const mongoose = require('mongoose');
|
||||
|
||||
const reviewSchema = new mongoose.Schema({
|
||||
companyId: {
|
||||
type: mongoose.Schema.Types.ObjectId,
|
||||
ref: 'Company',
|
||||
required: true,
|
||||
index: true
|
||||
},
|
||||
authorCompanyId: {
|
||||
type: mongoose.Schema.Types.ObjectId,
|
||||
ref: 'Company',
|
||||
required: true
|
||||
},
|
||||
authorName: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
authorCompany: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
rating: {
|
||||
type: Number,
|
||||
required: true,
|
||||
min: 1,
|
||||
max: 5
|
||||
},
|
||||
comment: {
|
||||
type: String,
|
||||
required: true,
|
||||
minlength: 10,
|
||||
maxlength: 1000
|
||||
},
|
||||
date: {
|
||||
type: Date,
|
||||
default: Date.now
|
||||
},
|
||||
verified: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
createdAt: {
|
||||
type: Date,
|
||||
default: Date.now,
|
||||
index: true
|
||||
},
|
||||
updatedAt: {
|
||||
type: Date,
|
||||
default: Date.now
|
||||
}
|
||||
});
|
||||
|
||||
// Индексы для оптимизации поиска
|
||||
reviewSchema.index({ companyId: 1, createdAt: -1 });
|
||||
reviewSchema.index({ authorCompanyId: 1 });
|
||||
|
||||
module.exports = mongoose.model('Review', reviewSchema);
|
||||
73
server/routers/procurement/models/User.js
Normal file
73
server/routers/procurement/models/User.js
Normal file
@@ -0,0 +1,73 @@
|
||||
const mongoose = require('mongoose');
|
||||
const bcrypt = require('bcryptjs');
|
||||
|
||||
const userSchema = new mongoose.Schema({
|
||||
email: {
|
||||
type: String,
|
||||
required: true,
|
||||
unique: true,
|
||||
lowercase: true,
|
||||
trim: true,
|
||||
match: /^[^\s@]+@[^\s@]+\.[^\s@]+$/
|
||||
},
|
||||
password: {
|
||||
type: String,
|
||||
required: true,
|
||||
minlength: 8
|
||||
},
|
||||
firstName: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
lastName: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
position: String,
|
||||
phone: String,
|
||||
companyId: {
|
||||
type: mongoose.Schema.Types.ObjectId,
|
||||
ref: 'Company'
|
||||
},
|
||||
createdAt: {
|
||||
type: Date,
|
||||
default: Date.now
|
||||
},
|
||||
updatedAt: {
|
||||
type: Date,
|
||||
default: Date.now
|
||||
}
|
||||
}, {
|
||||
collection: 'users',
|
||||
minimize: false,
|
||||
toObject: { versionKey: false }
|
||||
});
|
||||
|
||||
userSchema.set('toObject', { virtuals: false, versionKey: false });
|
||||
|
||||
// Хешировать пароль перед сохранением
|
||||
userSchema.pre('save', async function(next) {
|
||||
if (!this.isModified('password')) return next();
|
||||
|
||||
try {
|
||||
const salt = await bcrypt.genSalt(10);
|
||||
this.password = await bcrypt.hash(this.password, salt);
|
||||
next();
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// Метод для сравнения паролей
|
||||
userSchema.methods.comparePassword = async function(candidatePassword) {
|
||||
return await bcrypt.compare(candidatePassword, this.password);
|
||||
};
|
||||
|
||||
// Скрыть пароль при преобразовании в JSON
|
||||
userSchema.methods.toJSON = function() {
|
||||
const obj = this.toObject();
|
||||
delete obj.password;
|
||||
return obj;
|
||||
};
|
||||
|
||||
module.exports = mongoose.model('User', userSchema);
|
||||
240
server/routers/procurement/routes/__tests__/buyProducts.test.js
Normal file
240
server/routers/procurement/routes/__tests__/buyProducts.test.js
Normal file
@@ -0,0 +1,240 @@
|
||||
const express = require('express')
|
||||
const mongoose = require('mongoose')
|
||||
const request = require('supertest')
|
||||
const { describe, it, beforeAll, expect } = require('@jest/globals')
|
||||
|
||||
// Mock auth middleware
|
||||
const mockAuthMiddleware = (req, res, next) => {
|
||||
req.user = {
|
||||
companyId: 'test-company-id',
|
||||
id: 'test-user-id',
|
||||
}
|
||||
next()
|
||||
}
|
||||
|
||||
describe('Buy Products Routes', () => {
|
||||
let app
|
||||
let router
|
||||
|
||||
beforeAll(() => {
|
||||
app = express()
|
||||
app.use(express.json())
|
||||
|
||||
// Create a test router with mock middleware
|
||||
router = express.Router()
|
||||
|
||||
// Mock endpoints for testing structure
|
||||
router.get('/company/:companyId', mockAuthMiddleware, (req, res) => {
|
||||
res.json([])
|
||||
})
|
||||
|
||||
router.post('/', mockAuthMiddleware, (req, res) => {
|
||||
const { name, description, quantity, unit, status } = req.body
|
||||
|
||||
if (!name || !description || !quantity) {
|
||||
return res.status(400).json({
|
||||
error: 'name, description, and quantity are required',
|
||||
})
|
||||
}
|
||||
|
||||
if (description.trim().length < 10) {
|
||||
return res.status(400).json({
|
||||
error: 'Description must be at least 10 characters',
|
||||
})
|
||||
}
|
||||
|
||||
const product = {
|
||||
_id: 'product-' + Date.now(),
|
||||
companyId: req.user.companyId,
|
||||
name: name.trim(),
|
||||
description: description.trim(),
|
||||
quantity: quantity.trim(),
|
||||
unit: unit || 'шт',
|
||||
status: status || 'published',
|
||||
files: [],
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
}
|
||||
|
||||
res.status(201).json(product)
|
||||
})
|
||||
|
||||
app.use('/buy-products', router)
|
||||
})
|
||||
|
||||
describe('GET /buy-products/company/:companyId', () => {
|
||||
it('should return products list for a company', async () => {
|
||||
const res = await request(app)
|
||||
.get('/buy-products/company/test-company-id')
|
||||
.expect(200)
|
||||
|
||||
expect(Array.isArray(res.body)).toBe(true)
|
||||
})
|
||||
|
||||
it('should require authentication', async () => {
|
||||
// This test would fail without proper auth middleware
|
||||
const res = await request(app)
|
||||
.get('/buy-products/company/test-company-id')
|
||||
|
||||
expect(res.status).toBeLessThan(500)
|
||||
})
|
||||
})
|
||||
|
||||
describe('POST /buy-products', () => {
|
||||
it('should create a new product with valid data', async () => {
|
||||
const productData = {
|
||||
name: 'Test Product',
|
||||
description: 'This is a test product description',
|
||||
quantity: '10',
|
||||
unit: 'шт',
|
||||
}
|
||||
|
||||
const res = await request(app)
|
||||
.post('/buy-products')
|
||||
.send(productData)
|
||||
.expect(201)
|
||||
|
||||
expect(res.body).toHaveProperty('_id')
|
||||
expect(res.body.name).toBe('Test Product')
|
||||
expect(res.body.description).toBe(productData.description)
|
||||
expect(res.body.status).toBe('published')
|
||||
})
|
||||
|
||||
it('should reject product without name', async () => {
|
||||
const productData = {
|
||||
description: 'This is a test product description',
|
||||
quantity: '10',
|
||||
}
|
||||
|
||||
const res = await request(app)
|
||||
.post('/buy-products')
|
||||
.send(productData)
|
||||
.expect(400)
|
||||
|
||||
expect(res.body.error).toContain('required')
|
||||
})
|
||||
|
||||
it('should reject product without description', async () => {
|
||||
const productData = {
|
||||
name: 'Test Product',
|
||||
quantity: '10',
|
||||
}
|
||||
|
||||
const res = await request(app)
|
||||
.post('/buy-products')
|
||||
.send(productData)
|
||||
.expect(400)
|
||||
|
||||
expect(res.body.error).toContain('required')
|
||||
})
|
||||
|
||||
it('should reject product without quantity', async () => {
|
||||
const productData = {
|
||||
name: 'Test Product',
|
||||
description: 'This is a test product description',
|
||||
}
|
||||
|
||||
const res = await request(app)
|
||||
.post('/buy-products')
|
||||
.send(productData)
|
||||
.expect(400)
|
||||
|
||||
expect(res.body.error).toContain('required')
|
||||
})
|
||||
|
||||
it('should reject product with description less than 10 characters', async () => {
|
||||
const productData = {
|
||||
name: 'Test Product',
|
||||
description: 'short',
|
||||
quantity: '10',
|
||||
}
|
||||
|
||||
const res = await request(app)
|
||||
.post('/buy-products')
|
||||
.send(productData)
|
||||
.expect(400)
|
||||
|
||||
expect(res.body.error).toContain('10 characters')
|
||||
})
|
||||
|
||||
it('should set default unit to "шт" if not provided', async () => {
|
||||
const productData = {
|
||||
name: 'Test Product',
|
||||
description: 'This is a test product description',
|
||||
quantity: '10',
|
||||
}
|
||||
|
||||
const res = await request(app)
|
||||
.post('/buy-products')
|
||||
.send(productData)
|
||||
.expect(201)
|
||||
|
||||
expect(res.body.unit).toBe('шт')
|
||||
})
|
||||
|
||||
it('should use provided unit', async () => {
|
||||
const productData = {
|
||||
name: 'Test Product',
|
||||
description: 'This is a test product description',
|
||||
quantity: '10',
|
||||
unit: 'кг',
|
||||
}
|
||||
|
||||
const res = await request(app)
|
||||
.post('/buy-products')
|
||||
.send(productData)
|
||||
.expect(201)
|
||||
|
||||
expect(res.body.unit).toBe('кг')
|
||||
})
|
||||
|
||||
it('should set status to "published" by default', async () => {
|
||||
const productData = {
|
||||
name: 'Test Product',
|
||||
description: 'This is a test product description',
|
||||
quantity: '10',
|
||||
}
|
||||
|
||||
const res = await request(app)
|
||||
.post('/buy-products')
|
||||
.send(productData)
|
||||
.expect(201)
|
||||
|
||||
expect(res.body.status).toBe('published')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Data validation', () => {
|
||||
it('should trim whitespace from product data', async () => {
|
||||
const productData = {
|
||||
name: ' Test Product ',
|
||||
description: ' This is a test product description ',
|
||||
quantity: ' 10 ',
|
||||
}
|
||||
|
||||
const res = await request(app)
|
||||
.post('/buy-products')
|
||||
.send(productData)
|
||||
.expect(201)
|
||||
|
||||
expect(res.body.name).toBe('Test Product')
|
||||
expect(res.body.description).toBe('This is a test product description')
|
||||
expect(res.body.quantity).toBe('10')
|
||||
})
|
||||
|
||||
it('should include companyId from auth token', async () => {
|
||||
const productData = {
|
||||
name: 'Test Product',
|
||||
description: 'This is a test product description',
|
||||
quantity: '10',
|
||||
}
|
||||
|
||||
const res = await request(app)
|
||||
.post('/buy-products')
|
||||
.send(productData)
|
||||
.expect(201)
|
||||
|
||||
expect(res.body.companyId).toBe('test-company-id')
|
||||
})
|
||||
})
|
||||
})
|
||||
101
server/routers/procurement/routes/activity.js
Normal file
101
server/routers/procurement/routes/activity.js
Normal file
@@ -0,0 +1,101 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const { verifyToken } = require('../middleware/auth');
|
||||
const Activity = require('../models/Activity');
|
||||
const User = require('../models/User');
|
||||
|
||||
// Получить последние активности компании
|
||||
router.get('/', verifyToken, async (req, res) => {
|
||||
try {
|
||||
const userId = req.userId;
|
||||
const user = await User.findById(userId);
|
||||
|
||||
if (!user || !user.companyId) {
|
||||
return res.json({ activities: [] });
|
||||
}
|
||||
|
||||
const companyId = user.companyId.toString();
|
||||
const limit = parseInt(req.query.limit) || 10;
|
||||
|
||||
const activities = await Activity.find({ companyId })
|
||||
.sort({ createdAt: -1 })
|
||||
.limit(limit)
|
||||
.lean();
|
||||
|
||||
res.json({ activities });
|
||||
} catch (error) {
|
||||
console.error('Error getting activities:', error);
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Отметить активность как прочитанную
|
||||
router.patch('/:id/read', verifyToken, async (req, res) => {
|
||||
try {
|
||||
const userId = req.userId;
|
||||
const user = await User.findById(userId);
|
||||
|
||||
if (!user || !user.companyId) {
|
||||
return res.status(403).json({ error: 'Access denied' });
|
||||
}
|
||||
|
||||
const companyId = user.companyId.toString();
|
||||
const activityId = req.params.id;
|
||||
|
||||
const activity = await Activity.findOne({
|
||||
_id: activityId,
|
||||
companyId
|
||||
});
|
||||
|
||||
if (!activity) {
|
||||
return res.status(404).json({ error: 'Activity not found' });
|
||||
}
|
||||
|
||||
activity.read = true;
|
||||
await activity.save();
|
||||
|
||||
res.json({ success: true, activity });
|
||||
} catch (error) {
|
||||
console.error('Error updating activity:', error);
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Отметить все активности как прочитанные
|
||||
router.post('/mark-all-read', verifyToken, async (req, res) => {
|
||||
try {
|
||||
const userId = req.userId;
|
||||
const user = await User.findById(userId);
|
||||
|
||||
if (!user || !user.companyId) {
|
||||
return res.status(403).json({ error: 'Access denied' });
|
||||
}
|
||||
|
||||
const companyId = user.companyId.toString();
|
||||
|
||||
await Activity.updateMany(
|
||||
{ companyId, read: false },
|
||||
{ $set: { read: true } }
|
||||
);
|
||||
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error('Error marking all as read:', error);
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Создать активность (вспомогательная функция)
|
||||
router.createActivity = async (data) => {
|
||||
try {
|
||||
const activity = new Activity(data);
|
||||
await activity.save();
|
||||
return activity;
|
||||
} catch (error) {
|
||||
console.error('Error creating activity:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = router;
|
||||
|
||||
517
server/routers/procurement/routes/auth.js
Normal file
517
server/routers/procurement/routes/auth.js
Normal file
@@ -0,0 +1,517 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const { generateToken, verifyToken } = require('../middleware/auth');
|
||||
const User = require('../models/User');
|
||||
const Company = require('../models/Company');
|
||||
const Request = require('../models/Request');
|
||||
const BuyProduct = require('../models/BuyProduct');
|
||||
const Message = require('../models/Message');
|
||||
const Review = require('../models/Review');
|
||||
const mongoose = require('../../../utils/mongoose');
|
||||
const { Types } = mongoose;
|
||||
|
||||
const PRESET_COMPANY_ID = new Types.ObjectId('68fe2ccda3526c303ca06796');
|
||||
const PRESET_USER_EMAIL = 'admin@test-company.ru';
|
||||
|
||||
const changePasswordFlow = async (userId, currentPassword, newPassword) => {
|
||||
if (!currentPassword || !newPassword) {
|
||||
return { status: 400, body: { error: 'Current password and new password are required' } };
|
||||
}
|
||||
|
||||
if (typeof newPassword !== 'string' || newPassword.trim().length < 8) {
|
||||
return { status: 400, body: { error: 'New password must be at least 8 characters long' } };
|
||||
}
|
||||
|
||||
const user = await User.findById(userId);
|
||||
|
||||
if (!user) {
|
||||
return { status: 404, body: { error: 'User not found' } };
|
||||
}
|
||||
|
||||
const isMatch = await user.comparePassword(currentPassword);
|
||||
|
||||
if (!isMatch) {
|
||||
return { status: 400, body: { error: 'Current password is incorrect' } };
|
||||
}
|
||||
|
||||
user.password = newPassword;
|
||||
user.updatedAt = new Date();
|
||||
await user.save();
|
||||
|
||||
return { status: 200, body: { message: 'Password updated successfully' } };
|
||||
};
|
||||
|
||||
const deleteAccountFlow = async (userId, password) => {
|
||||
if (!password) {
|
||||
return { status: 400, body: { error: 'Password is required to delete account' } };
|
||||
}
|
||||
|
||||
const user = await User.findById(userId);
|
||||
|
||||
if (!user) {
|
||||
return { status: 404, body: { error: 'User not found' } };
|
||||
}
|
||||
|
||||
const validPassword = await user.comparePassword(password);
|
||||
|
||||
if (!validPassword) {
|
||||
return { status: 400, body: { error: 'Password is incorrect' } };
|
||||
}
|
||||
|
||||
const companyId = user.companyId ? user.companyId.toString() : null;
|
||||
const companyObjectId = companyId && Types.ObjectId.isValid(companyId) ? new Types.ObjectId(companyId) : null;
|
||||
|
||||
const cleanupTasks = [];
|
||||
|
||||
if (companyId) {
|
||||
cleanupTasks.push(Request.deleteMany({
|
||||
$or: [{ senderCompanyId: companyId }, { recipientCompanyId: companyId }],
|
||||
}));
|
||||
|
||||
cleanupTasks.push(BuyProduct.deleteMany({ companyId }));
|
||||
|
||||
if (companyObjectId) {
|
||||
cleanupTasks.push(Message.deleteMany({
|
||||
$or: [
|
||||
{ senderCompanyId: companyObjectId },
|
||||
{ recipientCompanyId: companyObjectId },
|
||||
],
|
||||
}));
|
||||
|
||||
cleanupTasks.push(Review.deleteMany({
|
||||
$or: [
|
||||
{ companyId: companyObjectId },
|
||||
{ authorCompanyId: companyObjectId },
|
||||
],
|
||||
}));
|
||||
}
|
||||
|
||||
cleanupTasks.push(Company.findByIdAndDelete(companyId));
|
||||
}
|
||||
|
||||
cleanupTasks.push(User.findByIdAndDelete(user._id));
|
||||
|
||||
await Promise.all(cleanupTasks);
|
||||
|
||||
return { status: 200, body: { message: 'Account deleted successfully' } };
|
||||
};
|
||||
|
||||
// Функция для логирования с проверкой DEV переменной
|
||||
const log = (message, data = '') => {
|
||||
if (process.env.DEV === 'true') {
|
||||
if (data) {
|
||||
console.log(message, data);
|
||||
} else {
|
||||
console.log(message);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const waitForDatabaseConnection = async () => {
|
||||
const isAuthFailure = (error) => {
|
||||
if (!error) return false;
|
||||
if (error.code === 13 || error.code === 18) return true;
|
||||
return /auth/i.test(String(error.message || ''));
|
||||
};
|
||||
|
||||
const verifyAuth = async () => {
|
||||
try {
|
||||
await mongoose.connection.db.admin().command({ listDatabases: 1 });
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (isAuthFailure(error)) {
|
||||
return false;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
for (let attempt = 0; attempt < 3; attempt++) {
|
||||
if (mongoose.connection.readyState === 1) {
|
||||
const authed = await verifyAuth();
|
||||
if (authed) {
|
||||
return;
|
||||
}
|
||||
await mongoose.connection.close().catch(() => {});
|
||||
}
|
||||
|
||||
try {
|
||||
// eslint-disable-next-line no-undef
|
||||
const connection = await connectDB();
|
||||
if (!connection) {
|
||||
break;
|
||||
}
|
||||
|
||||
const authed = await verifyAuth();
|
||||
if (authed) {
|
||||
return;
|
||||
}
|
||||
|
||||
await mongoose.connection.close().catch(() => {});
|
||||
} catch (error) {
|
||||
if (!isAuthFailure(error)) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error('Unable to authenticate with MongoDB');
|
||||
};
|
||||
|
||||
// Инициализация тестового пользователя
|
||||
const initializeTestUser = async () => {
|
||||
try {
|
||||
await waitForDatabaseConnection();
|
||||
|
||||
let company = await Company.findById(PRESET_COMPANY_ID);
|
||||
if (!company) {
|
||||
company = await Company.create({
|
||||
_id: PRESET_COMPANY_ID,
|
||||
fullName: 'ООО "Тестовая Компания"',
|
||||
shortName: 'ООО "Тест"',
|
||||
inn: '7707083893',
|
||||
ogrn: '1027700132195',
|
||||
legalForm: 'ООО',
|
||||
industry: 'Производство',
|
||||
companySize: '50-100',
|
||||
partnerGeography: ['moscow', 'russia_all'],
|
||||
website: 'https://test-company.ru',
|
||||
verified: true,
|
||||
rating: 4.5,
|
||||
description: 'Ведущая компания в области производства',
|
||||
slogan: 'Качество и инновация'
|
||||
});
|
||||
log('✅ Test company initialized');
|
||||
} else {
|
||||
await Company.updateOne(
|
||||
{ _id: PRESET_COMPANY_ID },
|
||||
{
|
||||
$set: {
|
||||
fullName: 'ООО "Тестовая Компания"',
|
||||
shortName: 'ООО "Тест"',
|
||||
industry: 'Производство',
|
||||
companySize: '50-100',
|
||||
partnerGeography: ['moscow', 'russia_all'],
|
||||
website: 'https://test-company.ru',
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
let existingUser = await User.findOne({ email: PRESET_USER_EMAIL });
|
||||
if (!existingUser) {
|
||||
existingUser = await User.create({
|
||||
email: PRESET_USER_EMAIL,
|
||||
password: 'SecurePass123!',
|
||||
firstName: 'Иван',
|
||||
lastName: 'Петров',
|
||||
position: 'Генеральный директор',
|
||||
companyId: PRESET_COMPANY_ID
|
||||
});
|
||||
log('✅ Test user initialized');
|
||||
} else if (!existingUser.companyId || existingUser.companyId.toString() !== PRESET_COMPANY_ID.toString()) {
|
||||
existingUser.companyId = PRESET_COMPANY_ID;
|
||||
existingUser.updatedAt = new Date();
|
||||
await existingUser.save();
|
||||
log('ℹ️ Test user company reference was fixed');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error initializing test data:', error.message);
|
||||
if (error?.code === 13 || /auth/i.test(error?.message || '')) {
|
||||
try {
|
||||
// eslint-disable-next-line no-undef
|
||||
await connectDB();
|
||||
} catch (connectError) {
|
||||
if (process.env.DEV === 'true') {
|
||||
console.error('Failed to re-connect after auth error:', connectError.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
initializeTestUser();
|
||||
|
||||
// Регистрация
|
||||
router.post('/register', async (req, res) => {
|
||||
try {
|
||||
await waitForDatabaseConnection();
|
||||
|
||||
const { email, password, firstName, lastName, position, phone, fullName, inn, ogrn, legalForm, industry, companySize, website } = req.body;
|
||||
|
||||
// Проверка обязательных полей
|
||||
if (!email || !password || !firstName || !lastName || !fullName) {
|
||||
return res.status(400).json({ error: 'Missing required fields' });
|
||||
}
|
||||
|
||||
// Проверка существования пользователя
|
||||
const existingUser = await User.findOne({ email });
|
||||
if (existingUser) {
|
||||
return res.status(409).json({ error: 'User already exists' });
|
||||
}
|
||||
|
||||
// Создать компанию
|
||||
let company;
|
||||
try {
|
||||
company = new Company({
|
||||
fullName,
|
||||
shortName: fullName.substring(0, 20),
|
||||
inn,
|
||||
ogrn,
|
||||
legalForm,
|
||||
industry,
|
||||
companySize,
|
||||
website,
|
||||
verified: false,
|
||||
rating: 0,
|
||||
description: '',
|
||||
slogan: '',
|
||||
partnerGeography: ['moscow', 'russia_all']
|
||||
});
|
||||
const savedCompany = await company.save();
|
||||
company = savedCompany;
|
||||
log('✅ Company saved:', company._id, 'Result:', savedCompany ? 'Success' : 'Failed');
|
||||
} catch (err) {
|
||||
console.error('Company save error:', err);
|
||||
return res.status(400).json({ error: 'Failed to create company: ' + err.message });
|
||||
}
|
||||
|
||||
// Создать пользователя
|
||||
try {
|
||||
const newUser = await User.create({
|
||||
email,
|
||||
password,
|
||||
firstName,
|
||||
lastName,
|
||||
position: position || '',
|
||||
phone: phone || '',
|
||||
companyId: company._id
|
||||
});
|
||||
|
||||
log('✅ User created:', newUser._id);
|
||||
|
||||
const token = generateToken(newUser._id.toString(), newUser.companyId.toString(), newUser.firstName, newUser.lastName, company.fullName);
|
||||
return res.status(201).json({
|
||||
tokens: {
|
||||
accessToken: token,
|
||||
refreshToken: token
|
||||
},
|
||||
user: {
|
||||
id: newUser._id.toString(),
|
||||
email: newUser.email,
|
||||
firstName: newUser.firstName,
|
||||
lastName: newUser.lastName,
|
||||
position: newUser.position,
|
||||
companyId: newUser.companyId.toString()
|
||||
},
|
||||
company: {
|
||||
id: company._id.toString(),
|
||||
name: company.fullName,
|
||||
inn: company.inn
|
||||
}
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('User creation error:', err);
|
||||
return res.status(400).json({ error: 'Failed to create user: ' + err.message });
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Registration error:', error);
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Вход
|
||||
router.post('/login', async (req, res) => {
|
||||
try {
|
||||
if (process.env.DEV === 'true') {
|
||||
console.log('[Auth] /login called');
|
||||
}
|
||||
await waitForDatabaseConnection();
|
||||
if (process.env.DEV === 'true') {
|
||||
console.log('[Auth] DB ready, running login query');
|
||||
}
|
||||
|
||||
const { email, password } = req.body;
|
||||
|
||||
if (!email || !password) {
|
||||
return res.status(400).json({ error: 'Email and password required' });
|
||||
}
|
||||
|
||||
const user = await User.findOne({ email });
|
||||
if (!user) {
|
||||
return res.status(401).json({ error: 'Invalid credentials' });
|
||||
}
|
||||
|
||||
const isMatch = await user.comparePassword(password);
|
||||
if (!isMatch) {
|
||||
return res.status(401).json({ error: 'Invalid credentials' });
|
||||
}
|
||||
|
||||
if (
|
||||
user.email === PRESET_USER_EMAIL &&
|
||||
(!user.companyId || user.companyId.toString() !== PRESET_COMPANY_ID.toString())
|
||||
) {
|
||||
await User.updateOne(
|
||||
{ _id: user._id },
|
||||
{ $set: { companyId: PRESET_COMPANY_ID, updatedAt: new Date() } }
|
||||
);
|
||||
user.companyId = PRESET_COMPANY_ID;
|
||||
}
|
||||
|
||||
// Получить компанию до использования в generateToken
|
||||
let companyData = null;
|
||||
try {
|
||||
companyData = user.companyId ? await Company.findById(user.companyId) : null;
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch company:', err.message);
|
||||
}
|
||||
|
||||
if (user.email === PRESET_USER_EMAIL) {
|
||||
try {
|
||||
companyData = await Company.findByIdAndUpdate(
|
||||
PRESET_COMPANY_ID,
|
||||
{
|
||||
$set: {
|
||||
fullName: 'ООО "Тестовая Компания"',
|
||||
shortName: 'ООО "Тест"',
|
||||
inn: '7707083893',
|
||||
ogrn: '1027700132195',
|
||||
legalForm: 'ООО',
|
||||
industry: 'Производство',
|
||||
companySize: '50-100',
|
||||
partnerGeography: ['moscow', 'russia_all'],
|
||||
website: 'https://test-company.ru',
|
||||
verified: true,
|
||||
rating: 4.5,
|
||||
description: 'Ведущая компания в области производства',
|
||||
slogan: 'Качество и инновация',
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
},
|
||||
{ upsert: true, new: true, setDefaultsOnInsert: true }
|
||||
);
|
||||
} catch (err) {
|
||||
console.error('Failed to ensure preset company:', err.message);
|
||||
}
|
||||
}
|
||||
|
||||
const token = generateToken(user._id.toString(), user.companyId.toString(), user.firstName, user.lastName, companyData?.fullName || 'Company');
|
||||
log('✅ Token generated for user:', user._id);
|
||||
|
||||
res.json({
|
||||
tokens: {
|
||||
accessToken: token,
|
||||
refreshToken: token
|
||||
},
|
||||
user: {
|
||||
id: user._id.toString(),
|
||||
email: user.email,
|
||||
firstName: user.firstName,
|
||||
lastName: user.lastName,
|
||||
position: user.position,
|
||||
companyId: user.companyId.toString()
|
||||
},
|
||||
company: companyData ? {
|
||||
id: companyData._id.toString(),
|
||||
name: companyData.fullName,
|
||||
inn: companyData.inn
|
||||
} : null
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Login error:', error);
|
||||
res.status(500).json({ error: `LOGIN_ERROR: ${error.message}` });
|
||||
}
|
||||
});
|
||||
|
||||
// Смена пароля
|
||||
router.post('/change-password', verifyToken, async (req, res) => {
|
||||
try {
|
||||
const { currentPassword, newPassword } = req.body || {};
|
||||
const result = await changePasswordFlow(req.userId, currentPassword, newPassword);
|
||||
res.status(result.status).json(result.body);
|
||||
} catch (error) {
|
||||
console.error('Change password error:', error);
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Удаление аккаунта
|
||||
router.delete('/account', verifyToken, async (req, res) => {
|
||||
try {
|
||||
const { password } = req.body || {};
|
||||
const result = await deleteAccountFlow(req.userId, password);
|
||||
res.status(result.status).json(result.body);
|
||||
} catch (error) {
|
||||
console.error('Delete account error:', error);
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Обновить профиль / универсальные действия
|
||||
router.patch('/profile', verifyToken, async (req, res) => {
|
||||
try {
|
||||
const rawAction = req.body?.action || req.query?.action || req.body?.type;
|
||||
const payload = req.body?.payload || req.body || {};
|
||||
const action = typeof rawAction === 'string' ? rawAction : '';
|
||||
|
||||
if (action === 'changePassword') {
|
||||
const result = await changePasswordFlow(req.userId, payload.currentPassword, payload.newPassword);
|
||||
return res.status(result.status).json(result.body);
|
||||
}
|
||||
|
||||
if (action === 'deleteAccount') {
|
||||
const result = await deleteAccountFlow(req.userId, payload.password);
|
||||
return res.status(result.status).json(result.body);
|
||||
}
|
||||
|
||||
if (action === 'updateProfile') {
|
||||
await waitForDatabaseConnection();
|
||||
|
||||
const { firstName, lastName, position, phone } = payload;
|
||||
|
||||
if (!firstName && !lastName && !position && !phone) {
|
||||
return res.status(400).json({ error: 'At least one field must be provided' });
|
||||
}
|
||||
|
||||
const user = await User.findById(req.userId);
|
||||
if (!user) {
|
||||
return res.status(404).json({ error: 'User not found' });
|
||||
}
|
||||
|
||||
if (firstName) user.firstName = firstName;
|
||||
if (lastName) user.lastName = lastName;
|
||||
if (position !== undefined) user.position = position;
|
||||
if (phone !== undefined) user.phone = phone;
|
||||
user.updatedAt = new Date();
|
||||
|
||||
await user.save();
|
||||
|
||||
const company = user.companyId ? await Company.findById(user.companyId) : null;
|
||||
|
||||
return res.json({
|
||||
message: 'Profile updated successfully',
|
||||
user: {
|
||||
id: user._id.toString(),
|
||||
email: user.email,
|
||||
firstName: user.firstName,
|
||||
lastName: user.lastName,
|
||||
position: user.position,
|
||||
phone: user.phone,
|
||||
companyId: user.companyId?.toString()
|
||||
},
|
||||
company: company ? {
|
||||
id: company._id.toString(),
|
||||
name: company.fullName,
|
||||
inn: company.inn
|
||||
} : null
|
||||
});
|
||||
}
|
||||
|
||||
res.json({ message: 'Profile endpoint' });
|
||||
} catch (error) {
|
||||
console.error('Profile update error:', error);
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
221
server/routers/procurement/routes/buy.js
Normal file
221
server/routers/procurement/routes/buy.js
Normal file
@@ -0,0 +1,221 @@
|
||||
const express = require('express')
|
||||
const fs = require('fs')
|
||||
const path = require('path')
|
||||
const router = express.Router()
|
||||
const BuyDocument = require('../models/BuyDocument')
|
||||
|
||||
// Create remote-assets/docs directory if it doesn't exist
|
||||
const docsDir = 'server/routers/remote-assets/docs'
|
||||
if (!fs.existsSync(docsDir)) {
|
||||
fs.mkdirSync(docsDir, { recursive: true })
|
||||
}
|
||||
|
||||
function generateId() {
|
||||
return `${Date.now().toString(36)}${Math.random().toString(36).slice(2, 8)}`
|
||||
}
|
||||
|
||||
// GET /buy/docs?ownerCompanyId=...
|
||||
router.get('/docs', async (req, res) => {
|
||||
try {
|
||||
const { ownerCompanyId } = req.query
|
||||
console.log('[BUY API] GET /docs', { ownerCompanyId })
|
||||
|
||||
let query = {}
|
||||
if (ownerCompanyId) {
|
||||
query.ownerCompanyId = ownerCompanyId
|
||||
}
|
||||
|
||||
const docs = await BuyDocument.find(query).sort({ createdAt: -1 })
|
||||
|
||||
const result = docs.map(doc => ({
|
||||
...doc.toObject(),
|
||||
url: `/api/buy/docs/${doc.id}/file`
|
||||
}))
|
||||
|
||||
res.json(result)
|
||||
} catch (error) {
|
||||
console.error('[BUY API] Error fetching docs:', error)
|
||||
res.status(500).json({ error: 'Failed to fetch documents' })
|
||||
}
|
||||
})
|
||||
|
||||
// POST /buy/docs
|
||||
router.post('/docs', async (req, res) => {
|
||||
try {
|
||||
const { ownerCompanyId, name, type, fileData } = req.body || {}
|
||||
console.log('[BUY API] POST /docs', { ownerCompanyId, name, type })
|
||||
|
||||
if (!ownerCompanyId || !name || !type) {
|
||||
return res.status(400).json({ error: 'ownerCompanyId, name and type are required' })
|
||||
}
|
||||
|
||||
if (!fileData) {
|
||||
return res.status(400).json({ error: 'fileData is required' })
|
||||
}
|
||||
|
||||
const id = generateId()
|
||||
|
||||
// Save file to disk
|
||||
const binaryData = Buffer.from(fileData, 'base64')
|
||||
const filePath = `${docsDir}/${id}.${type}`
|
||||
fs.writeFileSync(filePath, binaryData)
|
||||
console.log(`[BUY API] File saved to ${filePath}, size: ${binaryData.length} bytes`)
|
||||
|
||||
const size = binaryData.length
|
||||
|
||||
const doc = await BuyDocument.create({
|
||||
id,
|
||||
ownerCompanyId,
|
||||
name,
|
||||
type,
|
||||
size,
|
||||
filePath,
|
||||
acceptedBy: []
|
||||
})
|
||||
|
||||
console.log('[BUY API] Document created:', id)
|
||||
|
||||
res.status(201).json({
|
||||
...doc.toObject(),
|
||||
url: `/api/buy/docs/${doc.id}/file`
|
||||
})
|
||||
} catch (e) {
|
||||
console.error(`[BUY API] Error saving file: ${e.message}`)
|
||||
res.status(500).json({ error: 'Failed to save file' })
|
||||
}
|
||||
})
|
||||
|
||||
router.post('/docs/:id/accept', async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params
|
||||
const { companyId } = req.body || {}
|
||||
console.log('[BUY API] POST /docs/:id/accept', { id, companyId })
|
||||
|
||||
if (!companyId) {
|
||||
return res.status(400).json({ error: 'companyId is required' })
|
||||
}
|
||||
|
||||
const doc = await BuyDocument.findOne({ id })
|
||||
if (!doc) {
|
||||
console.log('[BUY API] Document not found:', id)
|
||||
return res.status(404).json({ error: 'Document not found' })
|
||||
}
|
||||
|
||||
if (!doc.acceptedBy.includes(companyId)) {
|
||||
doc.acceptedBy.push(companyId)
|
||||
await doc.save()
|
||||
}
|
||||
|
||||
res.json({ id: doc.id, acceptedBy: doc.acceptedBy })
|
||||
} catch (error) {
|
||||
console.error('[BUY API] Error accepting document:', error)
|
||||
res.status(500).json({ error: 'Failed to accept document' })
|
||||
}
|
||||
})
|
||||
|
||||
router.get('/docs/:id/delete', async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params
|
||||
console.log('[BUY API] GET /docs/:id/delete', { id })
|
||||
|
||||
const doc = await BuyDocument.findOne({ id })
|
||||
if (!doc) {
|
||||
console.log('[BUY API] Document not found for deletion:', id)
|
||||
return res.status(404).json({ error: 'Document not found' })
|
||||
}
|
||||
|
||||
// Delete file from disk
|
||||
if (doc.filePath && fs.existsSync(doc.filePath)) {
|
||||
try {
|
||||
fs.unlinkSync(doc.filePath)
|
||||
console.log(`[BUY API] File deleted: ${doc.filePath}`)
|
||||
} catch (e) {
|
||||
console.error(`[BUY API] Error deleting file: ${e.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
await BuyDocument.deleteOne({ id })
|
||||
|
||||
console.log('[BUY API] Document deleted via GET:', id)
|
||||
res.json({ id: doc.id, success: true })
|
||||
} catch (error) {
|
||||
console.error('[BUY API] Error deleting document:', error)
|
||||
res.status(500).json({ error: 'Failed to delete document' })
|
||||
}
|
||||
})
|
||||
|
||||
router.delete('/docs/:id', async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params
|
||||
console.log('[BUY API] DELETE /docs/:id', { id })
|
||||
|
||||
const doc = await BuyDocument.findOne({ id })
|
||||
if (!doc) {
|
||||
console.log('[BUY API] Document not found for deletion:', id)
|
||||
return res.status(404).json({ error: 'Document not found' })
|
||||
}
|
||||
|
||||
// Delete file from disk
|
||||
if (doc.filePath && fs.existsSync(doc.filePath)) {
|
||||
try {
|
||||
fs.unlinkSync(doc.filePath)
|
||||
console.log(`[BUY API] File deleted: ${doc.filePath}`)
|
||||
} catch (e) {
|
||||
console.error(`[BUY API] Error deleting file: ${e.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
await BuyDocument.deleteOne({ id })
|
||||
|
||||
console.log('[BUY API] Document deleted:', id)
|
||||
res.json({ id: doc.id, success: true })
|
||||
} catch (error) {
|
||||
console.error('[BUY API] Error deleting document:', error)
|
||||
res.status(500).json({ error: 'Failed to delete document' })
|
||||
}
|
||||
})
|
||||
|
||||
// GET /buy/docs/:id/file - Serve the file
|
||||
router.get('/docs/:id/file', async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params
|
||||
console.log('[BUY API] GET /docs/:id/file', { id })
|
||||
|
||||
const doc = await BuyDocument.findOne({ id })
|
||||
if (!doc) {
|
||||
console.log('[BUY API] Document not found:', id)
|
||||
return res.status(404).json({ error: 'Document not found' })
|
||||
}
|
||||
|
||||
const filePath = `${docsDir}/${id}.${doc.type}`
|
||||
if (!fs.existsSync(filePath)) {
|
||||
console.log('[BUY API] File not found on disk:', filePath)
|
||||
return res.status(404).json({ error: 'File not found on disk' })
|
||||
}
|
||||
|
||||
const fileBuffer = fs.readFileSync(filePath)
|
||||
|
||||
const mimeTypes = {
|
||||
'xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
'docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
'pdf': 'application/pdf'
|
||||
}
|
||||
|
||||
const mimeType = mimeTypes[doc.type] || 'application/octet-stream'
|
||||
// eslint-disable-next-line no-useless-escape
|
||||
const sanitizedName = doc.name.replace(/[^\w\s\-\.]/g, '_')
|
||||
|
||||
res.setHeader('Content-Type', mimeType)
|
||||
const encodedFilename = encodeURIComponent(`${doc.name}.${doc.type}`)
|
||||
res.setHeader('Content-Disposition', `attachment; filename="${sanitizedName}.${doc.type}"; filename*=UTF-8''${encodedFilename}`)
|
||||
res.setHeader('Content-Length', fileBuffer.length)
|
||||
|
||||
console.log(`[BUY API] Serving file ${id} from ${filePath} (${fileBuffer.length} bytes)`)
|
||||
res.send(fileBuffer)
|
||||
} catch (e) {
|
||||
console.error(`[BUY API] Error serving file: ${e.message}`)
|
||||
res.status(500).json({ error: 'Error serving file' })
|
||||
}
|
||||
})
|
||||
|
||||
module.exports = router
|
||||
503
server/routers/procurement/routes/buyProducts.js
Normal file
503
server/routers/procurement/routes/buyProducts.js
Normal file
@@ -0,0 +1,503 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const { verifyToken } = require('../middleware/auth');
|
||||
const BuyProduct = require('../models/BuyProduct');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const multer = require('multer');
|
||||
const UPLOADS_ROOT = 'server/routers/remote-assets/uploads/buy-products';
|
||||
const ensureDirectory = (dirPath) => {
|
||||
if (!fs.existsSync(dirPath)) {
|
||||
fs.mkdirSync(dirPath, { recursive: true });
|
||||
}
|
||||
};
|
||||
|
||||
ensureDirectory(UPLOADS_ROOT);
|
||||
|
||||
const MAX_FILE_SIZE = 15 * 1024 * 1024; // 15MB
|
||||
const ALLOWED_MIME_TYPES = new Set([
|
||||
'application/pdf',
|
||||
'application/msword',
|
||||
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
'application/vnd.ms-excel',
|
||||
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
'text/csv',
|
||||
]);
|
||||
|
||||
const storage = multer.diskStorage({
|
||||
destination: (req, file, cb) => {
|
||||
const productId = req.params.id || 'common';
|
||||
const productDir = `${UPLOADS_ROOT}/${productId}`;
|
||||
ensureDirectory(productDir);
|
||||
cb(null, productDir);
|
||||
},
|
||||
filename: (req, file, cb) => {
|
||||
// Исправляем кодировку имени файла из Latin1 в UTF-8
|
||||
const fixedName = Buffer.from(file.originalname, 'latin1').toString('utf8');
|
||||
const originalExtension = path.extname(fixedName) || '';
|
||||
const baseName = path
|
||||
.basename(fixedName, originalExtension)
|
||||
// eslint-disable-next-line no-control-regex
|
||||
.replace(/[<>:"/\\|?*\x00-\x1F]+/g, '_'); // Убираем только недопустимые символы Windows, оставляем кириллицу
|
||||
cb(null, `${Date.now()}_${baseName}${originalExtension}`);
|
||||
},
|
||||
});
|
||||
|
||||
const upload = multer({
|
||||
storage,
|
||||
limits: {
|
||||
fileSize: MAX_FILE_SIZE,
|
||||
},
|
||||
fileFilter: (req, file, cb) => {
|
||||
if (ALLOWED_MIME_TYPES.has(file.mimetype)) {
|
||||
cb(null, true);
|
||||
return;
|
||||
}
|
||||
|
||||
req.fileValidationError = 'UNSUPPORTED_FILE_TYPE';
|
||||
cb(null, false);
|
||||
},
|
||||
});
|
||||
|
||||
const handleSingleFileUpload = (req, res, next) => {
|
||||
upload.single('file')(req, res, (err) => {
|
||||
if (err) {
|
||||
console.error('[BuyProducts] Multer error:', err.message);
|
||||
if (err.code === 'LIMIT_FILE_SIZE') {
|
||||
return res.status(400).json({ error: 'File is too large. Maximum size is 15MB.' });
|
||||
}
|
||||
return res.status(400).json({ error: err.message });
|
||||
}
|
||||
next();
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
// Функция для логирования с проверкой DEV переменной
|
||||
const log = (message, data = '') => {
|
||||
if (process.env.DEV === 'true') {
|
||||
if (data) {
|
||||
console.log(message, data);
|
||||
} else {
|
||||
console.log(message);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// GET /buy-products/company/:companyId - получить товары компании
|
||||
router.get('/company/:companyId', verifyToken, async (req, res) => {
|
||||
try {
|
||||
const { companyId } = req.params;
|
||||
|
||||
log('[BuyProducts] Fetching products for company:', companyId);
|
||||
const products = await BuyProduct.find({ companyId })
|
||||
.sort({ createdAt: -1 })
|
||||
.exec();
|
||||
|
||||
log('[BuyProducts] Found', products.length, 'products for company', companyId);
|
||||
log('[BuyProducts] Products:', products);
|
||||
|
||||
res.json(products);
|
||||
} catch (error) {
|
||||
console.error('[BuyProducts] Error fetching products:', error.message);
|
||||
console.error('[BuyProducts] Error stack:', error.stack);
|
||||
res.status(500).json({
|
||||
error: 'Internal server error',
|
||||
message: error.message,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// POST /buy-products - создать новый товар
|
||||
router.post('/', verifyToken, async (req, res) => {
|
||||
try {
|
||||
const { name, description, quantity, unit, status } = req.body;
|
||||
|
||||
log('[BuyProducts] Creating new product:', { name, description, quantity, companyId: req.companyId });
|
||||
|
||||
if (!name || !description || !quantity) {
|
||||
return res.status(400).json({
|
||||
error: 'name, description, and quantity are required',
|
||||
});
|
||||
}
|
||||
|
||||
if (description.trim().length < 10) {
|
||||
return res.status(400).json({
|
||||
error: 'Description must be at least 10 characters',
|
||||
});
|
||||
}
|
||||
|
||||
const newProduct = new BuyProduct({
|
||||
companyId: req.companyId,
|
||||
name: name.trim(),
|
||||
description: description.trim(),
|
||||
quantity: quantity.trim(),
|
||||
unit: unit || 'шт',
|
||||
status: status || 'published',
|
||||
files: [],
|
||||
});
|
||||
|
||||
log('[BuyProducts] Attempting to save product to DB...');
|
||||
const savedProduct = await newProduct.save();
|
||||
|
||||
log('[BuyProducts] New product created successfully:', savedProduct._id);
|
||||
log('[BuyProducts] Product data:', savedProduct);
|
||||
|
||||
res.status(201).json(savedProduct);
|
||||
} catch (error) {
|
||||
console.error('[BuyProducts] Error creating product:', error.message);
|
||||
console.error('[BuyProducts] Error stack:', error.stack);
|
||||
res.status(500).json({
|
||||
error: 'Internal server error',
|
||||
message: error.message,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// PUT /buy-products/:id - обновить товар
|
||||
router.put('/:id', verifyToken, async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const { name, description, quantity, unit, status } = req.body;
|
||||
|
||||
const product = await BuyProduct.findById(id);
|
||||
|
||||
if (!product) {
|
||||
return res.status(404).json({ error: 'Product not found' });
|
||||
}
|
||||
|
||||
// Проверить, что товар принадлежит текущей компании
|
||||
if (product.companyId !== req.companyId) {
|
||||
return res.status(403).json({ error: 'Not authorized' });
|
||||
}
|
||||
|
||||
// Обновить поля
|
||||
if (name) product.name = name.trim();
|
||||
if (description) product.description = description.trim();
|
||||
if (quantity) product.quantity = quantity.trim();
|
||||
if (unit) product.unit = unit;
|
||||
if (status) product.status = status;
|
||||
product.updatedAt = new Date();
|
||||
|
||||
const updatedProduct = await product.save();
|
||||
|
||||
log('[BuyProducts] Product updated:', id);
|
||||
|
||||
res.json(updatedProduct);
|
||||
} catch (error) {
|
||||
console.error('[BuyProducts] Error:', error.message);
|
||||
res.status(500).json({
|
||||
error: 'Internal server error',
|
||||
message: error.message,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// DELETE /buy-products/:id - удалить товар
|
||||
router.delete('/:id', verifyToken, async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
|
||||
const product = await BuyProduct.findById(id);
|
||||
|
||||
if (!product) {
|
||||
return res.status(404).json({ error: 'Product not found' });
|
||||
}
|
||||
|
||||
if (product.companyId.toString() !== req.companyId.toString()) {
|
||||
return res.status(403).json({ error: 'Not authorized' });
|
||||
}
|
||||
|
||||
await BuyProduct.findByIdAndDelete(id);
|
||||
|
||||
log('[BuyProducts] Product deleted:', id);
|
||||
|
||||
res.json({ message: 'Product deleted successfully' });
|
||||
} catch (error) {
|
||||
console.error('[BuyProducts] Error:', error.message);
|
||||
res.status(500).json({
|
||||
error: 'Internal server error',
|
||||
message: error.message,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// POST /buy-products/:id/files - добавить файл к товару
|
||||
router.post('/:id/files', verifyToken, handleSingleFileUpload, async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const product = await BuyProduct.findById(id);
|
||||
|
||||
if (!product) {
|
||||
return res.status(404).json({ error: 'Product not found' });
|
||||
}
|
||||
|
||||
// Только владелец товара может добавить файл
|
||||
const productCompanyId = product.companyId?.toString() || product.companyId;
|
||||
const requestCompanyId = req.companyId?.toString() || req.companyId;
|
||||
|
||||
console.log('[BuyProducts] Comparing company IDs:', {
|
||||
productCompanyId,
|
||||
requestCompanyId,
|
||||
match: productCompanyId === requestCompanyId
|
||||
});
|
||||
|
||||
if (productCompanyId !== requestCompanyId) {
|
||||
return res.status(403).json({ error: 'Not authorized' });
|
||||
}
|
||||
|
||||
if (req.fileValidationError) {
|
||||
return res.status(400).json({ error: 'Unsupported file type. Use PDF, DOC, DOCX, XLS, XLSX or CSV.' });
|
||||
}
|
||||
|
||||
if (!req.file) {
|
||||
return res.status(400).json({ error: 'File is required' });
|
||||
}
|
||||
|
||||
// Исправляем кодировку имени файла из Latin1 в UTF-8
|
||||
const fixedFileName = Buffer.from(req.file.originalname, 'latin1').toString('utf8');
|
||||
|
||||
// Извлекаем timestamp из имени файла, созданного multer (формат: {timestamp}_{name}.ext)
|
||||
const fileTimestamp = req.file.filename.split('_')[0];
|
||||
|
||||
// storagePath относительно UPLOADS_ROOT (который уже включает 'buy-products')
|
||||
const relativePath = `${id}/${req.file.filename}`;
|
||||
const file = {
|
||||
id: `file-${fileTimestamp}`, // Используем тот же timestamp, что и в имени файла
|
||||
name: fixedFileName,
|
||||
url: `/uploads/buy-products/${relativePath}`,
|
||||
type: req.file.mimetype,
|
||||
size: req.file.size,
|
||||
uploadedAt: new Date(),
|
||||
storagePath: relativePath,
|
||||
};
|
||||
|
||||
console.log('[BuyProducts] Adding file to product:', {
|
||||
productId: id,
|
||||
fileName: file.name,
|
||||
fileSize: file.size,
|
||||
filePath: relativePath
|
||||
});
|
||||
|
||||
console.log('[BuyProducts] File object:', JSON.stringify(file, null, 2));
|
||||
|
||||
// Используем findByIdAndUpdate вместо save() для избежания проблем с валидацией
|
||||
let updatedProduct;
|
||||
try {
|
||||
console.log('[BuyProducts] Calling findByIdAndUpdate with id:', id);
|
||||
updatedProduct = await BuyProduct.findByIdAndUpdate(
|
||||
id,
|
||||
{
|
||||
$push: { files: file },
|
||||
$set: { updatedAt: new Date() }
|
||||
},
|
||||
{ new: true, runValidators: false }
|
||||
);
|
||||
console.log('[BuyProducts] findByIdAndUpdate completed');
|
||||
} catch (updateError) {
|
||||
console.error('[BuyProducts] findByIdAndUpdate error:', {
|
||||
message: updateError.message,
|
||||
name: updateError.name,
|
||||
code: updateError.code
|
||||
});
|
||||
throw updateError;
|
||||
}
|
||||
|
||||
if (!updatedProduct) {
|
||||
throw new Error('Failed to update product with file');
|
||||
}
|
||||
|
||||
console.log('[BuyProducts] File added successfully to product:', id);
|
||||
|
||||
log('[BuyProducts] File added to product:', id, file.name);
|
||||
|
||||
res.json(updatedProduct);
|
||||
} catch (error) {
|
||||
console.error('[BuyProducts] Error adding file:', error.message);
|
||||
console.error('[BuyProducts] Error stack:', error.stack);
|
||||
console.error('[BuyProducts] Error name:', error.name);
|
||||
if (error.errors) {
|
||||
console.error('[BuyProducts] Validation errors:', JSON.stringify(error.errors, null, 2));
|
||||
}
|
||||
res.status(500).json({
|
||||
error: 'Internal server error',
|
||||
message: error.message,
|
||||
details: error.errors || {},
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// DELETE /buy-products/:id/files/:fileId - удалить файл
|
||||
router.delete('/:id/files/:fileId', verifyToken, async (req, res) => {
|
||||
try {
|
||||
const { id, fileId } = req.params;
|
||||
|
||||
const product = await BuyProduct.findById(id);
|
||||
|
||||
if (!product) {
|
||||
return res.status(404).json({ error: 'Product not found' });
|
||||
}
|
||||
|
||||
if (product.companyId.toString() !== req.companyId.toString()) {
|
||||
return res.status(403).json({ error: 'Not authorized' });
|
||||
}
|
||||
|
||||
const fileToRemove = product.files.find((f) => f.id === fileId);
|
||||
if (!fileToRemove) {
|
||||
return res.status(404).json({ error: 'File not found' });
|
||||
}
|
||||
|
||||
product.files = product.files.filter(f => f.id !== fileId);
|
||||
await product.save();
|
||||
|
||||
const storedPath = fileToRemove.storagePath || fileToRemove.url.replace(/^\/uploads\//, '');
|
||||
const absolutePath = `server/routers/remote-assets/uploads/${storedPath}`;
|
||||
|
||||
fs.promises.unlink(absolutePath).catch((unlinkError) => {
|
||||
if (unlinkError && unlinkError.code !== 'ENOENT') {
|
||||
console.error('[BuyProducts] Failed to remove file from disk:', unlinkError.message);
|
||||
}
|
||||
});
|
||||
|
||||
log('[BuyProducts] File deleted from product:', id, fileId);
|
||||
|
||||
res.json(product);
|
||||
} catch (error) {
|
||||
console.error('[BuyProducts] Error deleting file:', error.message);
|
||||
res.status(500).json({
|
||||
error: 'Internal server error',
|
||||
message: error.message,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// POST /buy-products/:id/accept - акцептировать товар
|
||||
router.post('/:id/accept', verifyToken, async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const companyId = req.companyId;
|
||||
|
||||
const product = await BuyProduct.findById(id);
|
||||
|
||||
if (!product) {
|
||||
return res.status(404).json({ error: 'Product not found' });
|
||||
}
|
||||
|
||||
// Не можем акцептировать собственный товар
|
||||
if (product.companyId.toString() === companyId.toString()) {
|
||||
return res.status(403).json({ error: 'Cannot accept own product' });
|
||||
}
|
||||
|
||||
// Проверить, не акцептировал ли уже
|
||||
const alreadyAccepted = product.acceptedBy.some(
|
||||
a => a.companyId.toString() === companyId.toString()
|
||||
);
|
||||
|
||||
if (alreadyAccepted) {
|
||||
return res.status(400).json({ error: 'Already accepted' });
|
||||
}
|
||||
|
||||
product.acceptedBy.push({
|
||||
companyId,
|
||||
acceptedAt: new Date()
|
||||
});
|
||||
|
||||
await product.save();
|
||||
|
||||
log('[BuyProducts] Product accepted by company:', companyId);
|
||||
|
||||
res.json(product);
|
||||
} catch (error) {
|
||||
console.error('[BuyProducts] Error accepting product:', error.message);
|
||||
res.status(500).json({
|
||||
error: 'Internal server error',
|
||||
message: error.message,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// GET /buy-products/:id/acceptances - получить компании которые акцептовали
|
||||
router.get('/:id/acceptances', verifyToken, async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
|
||||
const product = await BuyProduct.findById(id).populate('acceptedBy.companyId', 'shortName fullName');
|
||||
|
||||
if (!product) {
|
||||
return res.status(404).json({ error: 'Product not found' });
|
||||
}
|
||||
|
||||
log('[BuyProducts] Returned acceptances for product:', id);
|
||||
|
||||
res.json(product.acceptedBy);
|
||||
} catch (error) {
|
||||
console.error('[BuyProducts] Error fetching acceptances:', error.message);
|
||||
res.status(500).json({
|
||||
error: 'Internal server error',
|
||||
message: error.message,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// GET /buy-products/download/:id/:fileId - скачать файл
|
||||
router.get('/download/:id/:fileId', verifyToken, async (req, res) => {
|
||||
try {
|
||||
console.log('[BuyProducts] Download request received:', {
|
||||
productId: req.params.id,
|
||||
fileId: req.params.fileId,
|
||||
userId: req.userId,
|
||||
companyId: req.companyId,
|
||||
headers: req.headers.authorization
|
||||
});
|
||||
|
||||
const { id, fileId } = req.params;
|
||||
const product = await BuyProduct.findById(id);
|
||||
|
||||
if (!product) {
|
||||
return res.status(404).json({ error: 'Product not found' });
|
||||
}
|
||||
|
||||
const file = product.files.find((f) => f.id === fileId);
|
||||
if (!file) {
|
||||
return res.status(404).json({ error: 'File not found' });
|
||||
}
|
||||
|
||||
// Создаем абсолютный путь к файлу
|
||||
const filePath = path.resolve(UPLOADS_ROOT, file.storagePath);
|
||||
|
||||
console.log('[BuyProducts] Trying to download file:', {
|
||||
fileId: file.id,
|
||||
fileName: file.name,
|
||||
storagePath: file.storagePath,
|
||||
absolutePath: filePath,
|
||||
exists: fs.existsSync(filePath)
|
||||
});
|
||||
|
||||
// Проверяем существование файла
|
||||
if (!fs.existsSync(filePath)) {
|
||||
console.error('[BuyProducts] File not found on disk:', filePath);
|
||||
return res.status(404).json({ error: 'File not found on disk' });
|
||||
}
|
||||
|
||||
// Устанавливаем правильные заголовки для скачивания с поддержкой кириллицы
|
||||
const encodedFileName = encodeURIComponent(file.name);
|
||||
res.setHeader('Content-Type', file.type || 'application/octet-stream');
|
||||
res.setHeader('Content-Disposition', `attachment; filename*=UTF-8''${encodedFileName}`);
|
||||
res.setHeader('Content-Length', file.size);
|
||||
|
||||
// Отправляем файл
|
||||
res.sendFile(filePath, (err) => {
|
||||
if (err) {
|
||||
console.error('[BuyProducts] Error sending file:', err.message);
|
||||
if (!res.headersSent) {
|
||||
res.status(500).json({ error: 'Error downloading file' });
|
||||
}
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[BuyProducts] Error downloading file:', error.message);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
336
server/routers/procurement/routes/companies.js
Normal file
336
server/routers/procurement/routes/companies.js
Normal file
@@ -0,0 +1,336 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const { verifyToken } = require('../middleware/auth');
|
||||
const Company = require('../models/Company');
|
||||
const Experience = require('../models/Experience');
|
||||
const Request = require('../models/Request');
|
||||
const Message = require('../models/Message');
|
||||
const mongoose = require('../../../utils/mongoose');
|
||||
const { Types } = mongoose;
|
||||
|
||||
// GET /my/info - получить мою компанию (требует авторизации) - ДОЛЖНО быть ПЕРЕД /:id
|
||||
router.get('/my/info', verifyToken, async (req, res) => {
|
||||
try {
|
||||
const userId = req.userId;
|
||||
const user = await require('../models/User').findById(userId);
|
||||
|
||||
if (!user || !user.companyId) {
|
||||
return res.status(404).json({ error: 'Company not found' });
|
||||
}
|
||||
|
||||
const company = await Company.findById(user.companyId);
|
||||
|
||||
if (!company) {
|
||||
return res.status(404).json({ error: 'Company not found' });
|
||||
}
|
||||
|
||||
res.json({
|
||||
...company.toObject(),
|
||||
id: company._id
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Get my company error:', error);
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// GET /my/stats - получить статистику компании - ДОЛЖНО быть ПЕРЕД /:id
|
||||
router.get('/my/stats', verifyToken, async (req, res) => {
|
||||
try {
|
||||
const userId = req.userId;
|
||||
const User = require('../models/User');
|
||||
const user = await User.findById(userId);
|
||||
|
||||
if (!user) {
|
||||
return res.status(404).json({ error: 'User not found' });
|
||||
}
|
||||
|
||||
let companyId = user.companyId;
|
||||
|
||||
if (!companyId) {
|
||||
const fallbackCompany = await Company.create({
|
||||
fullName: 'Компания пользователя',
|
||||
shortName: 'Компания пользователя',
|
||||
verified: false,
|
||||
partnerGeography: [],
|
||||
});
|
||||
|
||||
user.companyId = fallbackCompany._id;
|
||||
user.updatedAt = new Date();
|
||||
await user.save();
|
||||
companyId = fallbackCompany._id;
|
||||
}
|
||||
|
||||
let company = await Company.findById(companyId);
|
||||
|
||||
if (!company) {
|
||||
company = await Company.create({
|
||||
_id: companyId,
|
||||
fullName: 'Компания пользователя',
|
||||
verified: false,
|
||||
partnerGeography: [],
|
||||
});
|
||||
}
|
||||
|
||||
const companyIdString = company._id.toString();
|
||||
const companyObjectId = Types.ObjectId.isValid(companyIdString)
|
||||
? new Types.ObjectId(companyIdString)
|
||||
: null;
|
||||
|
||||
const [sentRequests, receivedRequests, unreadMessages] = await Promise.all([
|
||||
Request.countDocuments({ senderCompanyId: companyIdString }),
|
||||
Request.countDocuments({ recipientCompanyId: companyIdString }),
|
||||
companyObjectId
|
||||
? Message.countDocuments({ recipientCompanyId: companyObjectId, read: false })
|
||||
: Promise.resolve(0),
|
||||
]);
|
||||
|
||||
// Подсчитываем просмотры профиля из запросов к профилю компании
|
||||
const profileViews = company?.metrics?.profileViews || 0;
|
||||
|
||||
// Получаем статистику за последнюю неделю для изменений
|
||||
const weekAgo = new Date();
|
||||
weekAgo.setDate(weekAgo.getDate() - 7);
|
||||
|
||||
const sentRequestsLastWeek = await Request.countDocuments({
|
||||
senderCompanyId: companyIdString,
|
||||
createdAt: { $gte: weekAgo }
|
||||
});
|
||||
|
||||
const receivedRequestsLastWeek = await Request.countDocuments({
|
||||
recipientCompanyId: companyIdString,
|
||||
createdAt: { $gte: weekAgo }
|
||||
});
|
||||
|
||||
const stats = {
|
||||
profileViews: profileViews,
|
||||
profileViewsChange: 0, // Можно добавить отслеживание просмотров, если нужно
|
||||
sentRequests,
|
||||
sentRequestsChange: sentRequestsLastWeek,
|
||||
receivedRequests,
|
||||
receivedRequestsChange: receivedRequestsLastWeek,
|
||||
newMessages: unreadMessages,
|
||||
rating: Number.isFinite(company?.rating) ? Number(company.rating) : 0,
|
||||
};
|
||||
|
||||
res.json(stats);
|
||||
} catch (error) {
|
||||
console.error('Get company stats error:', error);
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// GET /:id/experience - получить опыт компании
|
||||
router.get('/:id/experience', verifyToken, async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
|
||||
if (!Types.ObjectId.isValid(id)) {
|
||||
return res.status(400).json({ error: 'Invalid company ID' });
|
||||
}
|
||||
|
||||
const experience = await Experience.find({ companyId: new Types.ObjectId(id) })
|
||||
.sort({ createdAt: -1 });
|
||||
|
||||
res.json(experience.map(exp => ({
|
||||
...exp.toObject(),
|
||||
id: exp._id
|
||||
})));
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// POST /:id/experience - добавить опыт компании
|
||||
router.post('/:id/experience', verifyToken, async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const { confirmed, customer, subject, volume, contact, comment } = req.body;
|
||||
|
||||
if (!Types.ObjectId.isValid(id)) {
|
||||
return res.status(400).json({ error: 'Invalid company ID' });
|
||||
}
|
||||
|
||||
const newExp = await Experience.create({
|
||||
companyId: new Types.ObjectId(id),
|
||||
confirmed: confirmed || false,
|
||||
customer: customer || '',
|
||||
subject: subject || '',
|
||||
volume: volume || '',
|
||||
contact: contact || '',
|
||||
comment: comment || ''
|
||||
});
|
||||
|
||||
res.status(201).json({
|
||||
...newExp.toObject(),
|
||||
id: newExp._id
|
||||
});
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// PUT /:id/experience/:expId - обновить опыт
|
||||
router.put('/:id/experience/:expId', verifyToken, async (req, res) => {
|
||||
try {
|
||||
const { id, expId } = req.params;
|
||||
|
||||
if (!Types.ObjectId.isValid(id) || !Types.ObjectId.isValid(expId)) {
|
||||
return res.status(400).json({ error: 'Invalid IDs' });
|
||||
}
|
||||
|
||||
const experience = await Experience.findByIdAndUpdate(
|
||||
new Types.ObjectId(expId),
|
||||
{
|
||||
...req.body,
|
||||
updatedAt: new Date()
|
||||
},
|
||||
{ new: true }
|
||||
);
|
||||
|
||||
if (!experience || experience.companyId.toString() !== id) {
|
||||
return res.status(404).json({ error: 'Experience not found' });
|
||||
}
|
||||
|
||||
res.json({
|
||||
...experience.toObject(),
|
||||
id: experience._id
|
||||
});
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// DELETE /:id/experience/:expId - удалить опыт
|
||||
router.delete('/:id/experience/:expId', verifyToken, async (req, res) => {
|
||||
try {
|
||||
const { id, expId } = req.params;
|
||||
|
||||
if (!Types.ObjectId.isValid(id) || !Types.ObjectId.isValid(expId)) {
|
||||
return res.status(400).json({ error: 'Invalid IDs' });
|
||||
}
|
||||
|
||||
const experience = await Experience.findById(new Types.ObjectId(expId));
|
||||
|
||||
if (!experience || experience.companyId.toString() !== id) {
|
||||
return res.status(404).json({ error: 'Experience not found' });
|
||||
}
|
||||
|
||||
await Experience.findByIdAndDelete(new Types.ObjectId(expId));
|
||||
res.json({ message: 'Experience deleted' });
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Получить компанию по ID (ДОЛЖНО быть ПОСЛЕ специфичных маршрутов)
|
||||
router.get('/:id', async (req, res) => {
|
||||
try {
|
||||
const company = await Company.findById(req.params.id);
|
||||
|
||||
if (!company) {
|
||||
if (!Types.ObjectId.isValid(req.params.id)) {
|
||||
return res.status(404).json({ error: 'Company not found' });
|
||||
}
|
||||
|
||||
const placeholder = await Company.create({
|
||||
_id: new Types.ObjectId(req.params.id),
|
||||
fullName: 'Новая компания',
|
||||
shortName: 'Новая компания',
|
||||
verified: false,
|
||||
partnerGeography: [],
|
||||
industry: '',
|
||||
companySize: '',
|
||||
});
|
||||
|
||||
return res.json({
|
||||
...placeholder.toObject(),
|
||||
id: placeholder._id,
|
||||
});
|
||||
}
|
||||
|
||||
// Отслеживаем просмотр профиля (если это не владелец компании)
|
||||
const userId = req.userId;
|
||||
if (userId) {
|
||||
const User = require('../models/User');
|
||||
const user = await User.findById(userId);
|
||||
if (user && user.companyId && user.companyId.toString() !== company._id.toString()) {
|
||||
// Инкрементируем просмотры профиля
|
||||
if (!company.metrics) {
|
||||
company.metrics = {};
|
||||
}
|
||||
if (!company.metrics.profileViews) {
|
||||
company.metrics.profileViews = 0;
|
||||
}
|
||||
company.metrics.profileViews = (company.metrics.profileViews || 0) + 1;
|
||||
await company.save();
|
||||
}
|
||||
}
|
||||
|
||||
res.json({
|
||||
...company.toObject(),
|
||||
id: company._id
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Get company error:', error);
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Обновить компанию (требует авторизации)
|
||||
const updateCompanyHandler = async (req, res) => {
|
||||
try {
|
||||
const company = await Company.findByIdAndUpdate(
|
||||
req.params.id,
|
||||
{ ...req.body, updatedAt: new Date() },
|
||||
{ new: true }
|
||||
);
|
||||
|
||||
if (!company) {
|
||||
return res.status(404).json({ error: 'Company not found' });
|
||||
}
|
||||
|
||||
res.json({
|
||||
...company.toObject(),
|
||||
id: company._id
|
||||
});
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
};
|
||||
|
||||
router.put('/:id', verifyToken, updateCompanyHandler);
|
||||
router.patch('/:id', verifyToken, updateCompanyHandler);
|
||||
|
||||
// Поиск с AI анализом
|
||||
router.post('/ai-search', async (req, res) => {
|
||||
try {
|
||||
const { query } = req.body;
|
||||
|
||||
if (!query) {
|
||||
return res.status(400).json({ error: 'Query required' });
|
||||
}
|
||||
|
||||
const q = query.toLowerCase();
|
||||
const result = await Company.find({
|
||||
$or: [
|
||||
{ fullName: { $regex: q, $options: 'i' } },
|
||||
{ shortName: { $regex: q, $options: 'i' } },
|
||||
{ industry: { $regex: q, $options: 'i' } }
|
||||
]
|
||||
});
|
||||
|
||||
res.json({
|
||||
companies: result.map(c => ({
|
||||
...c.toObject(),
|
||||
id: c._id
|
||||
})),
|
||||
total: result.length,
|
||||
aiSuggestion: `Found ${result.length} companies matching "${query}"`
|
||||
});
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
134
server/routers/procurement/routes/experience.js
Normal file
134
server/routers/procurement/routes/experience.js
Normal file
@@ -0,0 +1,134 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const { verifyToken } = require('../middleware/auth');
|
||||
const Experience = require('../models/Experience');
|
||||
const mongoose = require('../../../utils/mongoose');
|
||||
const { Types } = mongoose;
|
||||
|
||||
// GET /experience - Получить список опыта работы компании
|
||||
router.get('/', verifyToken, async (req, res) => {
|
||||
try {
|
||||
const { companyId } = req.query;
|
||||
|
||||
if (!companyId) {
|
||||
return res.status(400).json({ error: 'companyId is required' });
|
||||
}
|
||||
|
||||
if (!Types.ObjectId.isValid(companyId)) {
|
||||
return res.status(400).json({ error: 'Invalid company ID' });
|
||||
}
|
||||
|
||||
const companyExperiences = await Experience.find({
|
||||
companyId: new Types.ObjectId(companyId)
|
||||
}).sort({ createdAt: -1 });
|
||||
|
||||
res.json(companyExperiences.map(exp => ({
|
||||
...exp.toObject(),
|
||||
id: exp._id
|
||||
})));
|
||||
} catch (error) {
|
||||
console.error('Get experience error:', error);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
// POST /experience - Создать запись опыта работы
|
||||
router.post('/', verifyToken, async (req, res) => {
|
||||
try {
|
||||
const { companyId, data } = req.body;
|
||||
|
||||
if (!companyId || !data) {
|
||||
return res.status(400).json({ error: 'companyId and data are required' });
|
||||
}
|
||||
|
||||
if (!Types.ObjectId.isValid(companyId)) {
|
||||
return res.status(400).json({ error: 'Invalid company ID' });
|
||||
}
|
||||
|
||||
const { confirmed, customer, subject, volume, contact, comment } = data;
|
||||
|
||||
if (!customer || !subject) {
|
||||
return res.status(400).json({ error: 'customer and subject are required' });
|
||||
}
|
||||
|
||||
const newExperience = await Experience.create({
|
||||
companyId: new Types.ObjectId(companyId),
|
||||
confirmed: confirmed || false,
|
||||
customer,
|
||||
subject,
|
||||
volume: volume || '',
|
||||
contact: contact || '',
|
||||
comment: comment || ''
|
||||
});
|
||||
|
||||
res.status(201).json({
|
||||
...newExperience.toObject(),
|
||||
id: newExperience._id
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Create experience error:', error);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
// PUT /experience/:id - Обновить запись опыта работы
|
||||
router.put('/:id', verifyToken, async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const { data } = req.body;
|
||||
|
||||
if (!data) {
|
||||
return res.status(400).json({ error: 'data is required' });
|
||||
}
|
||||
|
||||
if (!Types.ObjectId.isValid(id)) {
|
||||
return res.status(400).json({ error: 'Invalid experience ID' });
|
||||
}
|
||||
|
||||
const updatedExperience = await Experience.findByIdAndUpdate(
|
||||
new Types.ObjectId(id),
|
||||
{
|
||||
...data,
|
||||
updatedAt: new Date()
|
||||
},
|
||||
{ new: true }
|
||||
);
|
||||
|
||||
if (!updatedExperience) {
|
||||
return res.status(404).json({ error: 'Experience not found' });
|
||||
}
|
||||
|
||||
res.json({
|
||||
...updatedExperience.toObject(),
|
||||
id: updatedExperience._id
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Update experience error:', error);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
// DELETE /experience/:id - Удалить запись опыта работы
|
||||
router.delete('/:id', verifyToken, async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
|
||||
if (!Types.ObjectId.isValid(id)) {
|
||||
return res.status(400).json({ error: 'Invalid experience ID' });
|
||||
}
|
||||
|
||||
const deletedExperience = await Experience.findByIdAndDelete(new Types.ObjectId(id));
|
||||
|
||||
if (!deletedExperience) {
|
||||
return res.status(404).json({ error: 'Experience not found' });
|
||||
}
|
||||
|
||||
res.json({ message: 'Experience deleted successfully' });
|
||||
} catch (error) {
|
||||
console.error('Delete experience error:', error);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
|
||||
137
server/routers/procurement/routes/home.js
Normal file
137
server/routers/procurement/routes/home.js
Normal file
@@ -0,0 +1,137 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const { verifyToken } = require('../middleware/auth');
|
||||
const BuyProduct = require('../models/BuyProduct');
|
||||
const Request = require('../models/Request');
|
||||
|
||||
// Получить агрегированные данные для главной страницы
|
||||
router.get('/aggregates', verifyToken, async (req, res) => {
|
||||
try {
|
||||
const userId = req.userId;
|
||||
const User = require('../models/User');
|
||||
const user = await User.findById(userId);
|
||||
|
||||
if (!user || !user.companyId) {
|
||||
return res.json({
|
||||
docsCount: 0,
|
||||
acceptsCount: 0,
|
||||
requestsCount: 0
|
||||
});
|
||||
}
|
||||
|
||||
const companyId = user.companyId.toString();
|
||||
|
||||
// Получить все BuyProduct для подсчета файлов и акцептов
|
||||
const buyProducts = await BuyProduct.find({ companyId });
|
||||
|
||||
// Подсчет документов - сумма всех файлов во всех BuyProduct
|
||||
const docsCount = buyProducts.reduce((total, product) => {
|
||||
return total + (product.files ? product.files.length : 0);
|
||||
}, 0);
|
||||
|
||||
// Подсчет акцептов - сумма всех acceptedBy во всех BuyProduct
|
||||
const acceptsCount = buyProducts.reduce((total, product) => {
|
||||
return total + (product.acceptedBy ? product.acceptedBy.length : 0);
|
||||
}, 0);
|
||||
|
||||
// Подсчет исходящих запросов (только отправленные этой компанией)
|
||||
const requestsCount = await Request.countDocuments({
|
||||
senderCompanyId: companyId
|
||||
});
|
||||
|
||||
res.json({
|
||||
docsCount,
|
||||
acceptsCount,
|
||||
requestsCount
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error getting aggregates:', error);
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Получить статистику компании
|
||||
router.get('/stats', verifyToken, async (req, res) => {
|
||||
try {
|
||||
const userId = req.userId;
|
||||
const User = require('../models/User');
|
||||
const Company = require('../models/Company');
|
||||
const user = await User.findById(userId);
|
||||
|
||||
if (!user || !user.companyId) {
|
||||
return res.json({
|
||||
profileViews: 0,
|
||||
profileViewsChange: 0,
|
||||
sentRequests: 0,
|
||||
sentRequestsChange: 0,
|
||||
receivedRequests: 0,
|
||||
receivedRequestsChange: 0,
|
||||
newMessages: 0,
|
||||
rating: 0
|
||||
});
|
||||
}
|
||||
|
||||
const companyId = user.companyId.toString();
|
||||
const company = await Company.findById(user.companyId);
|
||||
|
||||
const sentRequests = await Request.countDocuments({ senderCompanyId: companyId });
|
||||
const receivedRequests = await Request.countDocuments({ recipientCompanyId: companyId });
|
||||
|
||||
res.json({
|
||||
profileViews: company?.metrics?.profileViews || 0,
|
||||
profileViewsChange: 0,
|
||||
sentRequests,
|
||||
sentRequestsChange: 0,
|
||||
receivedRequests,
|
||||
receivedRequestsChange: 0,
|
||||
newMessages: 0,
|
||||
rating: company?.rating || 0
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error getting stats:', error);
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Получить рекомендации партнеров (AI)
|
||||
router.get('/recommendations', verifyToken, async (req, res) => {
|
||||
try {
|
||||
const userId = req.userId;
|
||||
const User = require('../models/User');
|
||||
const Company = require('../models/Company');
|
||||
const user = await User.findById(userId);
|
||||
|
||||
if (!user || !user.companyId) {
|
||||
return res.json({
|
||||
recommendations: [],
|
||||
message: 'No recommendations available'
|
||||
});
|
||||
}
|
||||
|
||||
// Получить компании кроме текущей
|
||||
const companies = await Company.find({
|
||||
_id: { $ne: user.companyId }
|
||||
})
|
||||
.sort({ rating: -1 })
|
||||
.limit(5);
|
||||
|
||||
const recommendations = companies.map(company => ({
|
||||
id: company._id.toString(),
|
||||
name: company.fullName || company.shortName,
|
||||
industry: company.industry,
|
||||
logo: company.logo,
|
||||
matchScore: company.rating ? Math.min(100, Math.round(company.rating * 20)) : 50,
|
||||
reason: 'Matches your industry'
|
||||
}));
|
||||
|
||||
res.json({
|
||||
recommendations,
|
||||
message: recommendations.length > 0 ? 'Recommendations available' : 'No recommendations available'
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error getting recommendations:', error);
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
263
server/routers/procurement/routes/messages.js
Normal file
263
server/routers/procurement/routes/messages.js
Normal file
@@ -0,0 +1,263 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const { verifyToken } = require('../middleware/auth');
|
||||
const Message = require('../models/Message');
|
||||
const mongoose = require('../../../utils/mongoose');
|
||||
const { ObjectId } = mongoose.Types;
|
||||
|
||||
// Функция для логирования с проверкой DEV переменной
|
||||
const log = (message, data = '') => {
|
||||
if (process.env.DEV === 'true') {
|
||||
if (data) {
|
||||
console.log(message, data);
|
||||
} else {
|
||||
console.log(message);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// GET /messages/threads - получить все потоки для компании
|
||||
router.get('/threads', verifyToken, async (req, res) => {
|
||||
try {
|
||||
const companyId = req.companyId;
|
||||
|
||||
log('[Messages] Fetching threads for companyId:', companyId, 'type:', typeof companyId);
|
||||
|
||||
// Преобразовать в ObjectId если это строка
|
||||
let companyObjectId = companyId;
|
||||
let companyIdString = companyId.toString ? companyId.toString() : companyId;
|
||||
|
||||
try {
|
||||
if (typeof companyId === 'string' && ObjectId.isValid(companyId)) {
|
||||
companyObjectId = new ObjectId(companyId);
|
||||
}
|
||||
} catch (e) {
|
||||
log('[Messages] Could not convert to ObjectId:', e.message);
|
||||
}
|
||||
|
||||
log('[Messages] Using companyObjectId:', companyObjectId, 'companyIdString:', companyIdString);
|
||||
|
||||
// Получить все сообщения где текущая компания отправитель или получатель
|
||||
// Поддерживаем оба формата - ObjectId и строки
|
||||
const allMessages = await Message.find({
|
||||
$or: [
|
||||
{ senderCompanyId: companyObjectId },
|
||||
{ senderCompanyId: companyIdString },
|
||||
{ recipientCompanyId: companyObjectId },
|
||||
{ recipientCompanyId: companyIdString },
|
||||
// Также ищем по threadId который может содержать ID компании
|
||||
{ threadId: { $regex: companyIdString } }
|
||||
]
|
||||
})
|
||||
.sort({ timestamp: -1 })
|
||||
.limit(500);
|
||||
|
||||
log('[Messages] Found', allMessages.length, 'messages for company');
|
||||
|
||||
if (allMessages.length === 0) {
|
||||
log('[Messages] No messages found');
|
||||
res.json([]);
|
||||
return;
|
||||
}
|
||||
|
||||
// Группируем по потокам и берем последнее сообщение каждого потока
|
||||
const threadsMap = new Map();
|
||||
allMessages.forEach(msg => {
|
||||
const threadId = msg.threadId;
|
||||
if (!threadsMap.has(threadId)) {
|
||||
threadsMap.set(threadId, {
|
||||
threadId,
|
||||
lastMessage: msg.text,
|
||||
lastMessageAt: msg.timestamp,
|
||||
senderCompanyId: msg.senderCompanyId,
|
||||
recipientCompanyId: msg.recipientCompanyId
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const threads = Array.from(threadsMap.values()).sort((a, b) =>
|
||||
new Date(b.lastMessageAt) - new Date(a.lastMessageAt)
|
||||
);
|
||||
|
||||
log('[Messages] Returned', threads.length, 'unique threads');
|
||||
|
||||
res.json(threads);
|
||||
} catch (error) {
|
||||
console.error('[Messages] Error fetching threads:', error.message, error.stack);
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// GET /messages/:threadId - получить сообщения потока
|
||||
router.get('/:threadId', verifyToken, async (req, res) => {
|
||||
try {
|
||||
const { threadId } = req.params;
|
||||
const companyId = req.companyId;
|
||||
|
||||
// Получить все сообщения потока
|
||||
const threadMessages = await Message.find({ threadId })
|
||||
.sort({ timestamp: 1 })
|
||||
.exec();
|
||||
|
||||
// Отметить сообщения как прочитанные для текущей компании
|
||||
await Message.updateMany(
|
||||
{ threadId, recipientCompanyId: companyId, read: false },
|
||||
{ read: true }
|
||||
);
|
||||
|
||||
log('[Messages] Returned', threadMessages.length, 'messages for thread', threadId);
|
||||
|
||||
res.json(threadMessages);
|
||||
} catch (error) {
|
||||
console.error('[Messages] Error fetching messages:', error.message);
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// POST /messages/:threadId - добавить сообщение в поток
|
||||
router.post('/:threadId', verifyToken, async (req, res) => {
|
||||
try {
|
||||
const { threadId } = req.params;
|
||||
const { text, senderCompanyId } = req.body;
|
||||
|
||||
if (!text || !threadId) {
|
||||
return res.status(400).json({ error: 'Text and threadId required' });
|
||||
}
|
||||
|
||||
// Определить получателя на основе threadId
|
||||
// threadId формат: "thread-id1-id2"
|
||||
const threadParts = threadId.replace('thread-', '').split('-');
|
||||
let recipientCompanyId = null;
|
||||
|
||||
const currentSender = senderCompanyId || req.companyId;
|
||||
const currentSenderString = currentSender.toString ? currentSender.toString() : currentSender;
|
||||
|
||||
if (threadParts.length >= 2) {
|
||||
const companyId1 = threadParts[0];
|
||||
const companyId2 = threadParts[1];
|
||||
// Получатель - это другая сторона
|
||||
recipientCompanyId = currentSenderString === companyId1 ? companyId2 : companyId1;
|
||||
}
|
||||
|
||||
log('[Messages] POST /messages/:threadId');
|
||||
log('[Messages] threadId:', threadId);
|
||||
log('[Messages] Sender:', currentSender);
|
||||
log('[Messages] SenderString:', currentSenderString);
|
||||
log('[Messages] Recipient:', recipientCompanyId);
|
||||
|
||||
// Найти recipientCompanyId по ObjectId если нужно
|
||||
let recipientObjectId = recipientCompanyId;
|
||||
try {
|
||||
if (typeof recipientCompanyId === 'string' && ObjectId.isValid(recipientCompanyId)) {
|
||||
recipientObjectId = new ObjectId(recipientCompanyId);
|
||||
}
|
||||
} catch (e) {
|
||||
log('[Messages] Could not convert recipientId to ObjectId');
|
||||
}
|
||||
|
||||
const message = new Message({
|
||||
threadId,
|
||||
senderCompanyId: currentSender,
|
||||
recipientCompanyId: recipientObjectId,
|
||||
text: text.trim(),
|
||||
read: false,
|
||||
timestamp: new Date()
|
||||
});
|
||||
|
||||
const savedMessage = await message.save();
|
||||
|
||||
log('[Messages] New message created:', savedMessage._id);
|
||||
log('[Messages] Message data:', {
|
||||
threadId: savedMessage.threadId,
|
||||
senderCompanyId: savedMessage.senderCompanyId,
|
||||
recipientCompanyId: savedMessage.recipientCompanyId
|
||||
});
|
||||
|
||||
res.status(201).json(savedMessage);
|
||||
} catch (error) {
|
||||
console.error('[Messages] Error creating message:', error.message, error.stack);
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// MIGRATION ENDPOINT - Fix recipientCompanyId for all messages
|
||||
router.post('/admin/migrate-fix-recipients', async (req, res) => {
|
||||
try {
|
||||
const allMessages = await Message.find().exec();
|
||||
log('[Messages] Migrating', allMessages.length, 'messages...');
|
||||
|
||||
let fixedCount = 0;
|
||||
let errorCount = 0;
|
||||
|
||||
for (const message of allMessages) {
|
||||
try {
|
||||
const threadId = message.threadId;
|
||||
if (!threadId) continue;
|
||||
|
||||
// Parse threadId формат "thread-id1-id2" или "id1-id2"
|
||||
const ids = threadId.replace('thread-', '').split('-');
|
||||
if (ids.length < 2) {
|
||||
errorCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const companyId1 = ids[0];
|
||||
const companyId2 = ids[1];
|
||||
|
||||
// Compare with senderCompanyId
|
||||
const senderIdString = message.senderCompanyId.toString ? message.senderCompanyId.toString() : message.senderCompanyId;
|
||||
const expectedRecipient = senderIdString === companyId1 ? companyId2 : companyId1;
|
||||
|
||||
// If recipientCompanyId is not set or wrong - fix it
|
||||
if (!message.recipientCompanyId || message.recipientCompanyId.toString() !== expectedRecipient) {
|
||||
let recipientObjectId = expectedRecipient;
|
||||
try {
|
||||
if (typeof expectedRecipient === 'string' && ObjectId.isValid(expectedRecipient)) {
|
||||
recipientObjectId = new ObjectId(expectedRecipient);
|
||||
}
|
||||
} catch (e) {
|
||||
// continue
|
||||
}
|
||||
|
||||
await Message.updateOne(
|
||||
{ _id: message._id },
|
||||
{ recipientCompanyId: recipientObjectId }
|
||||
);
|
||||
|
||||
fixedCount++;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[Messages] Migration error:', err.message);
|
||||
errorCount++;
|
||||
}
|
||||
}
|
||||
|
||||
log('[Messages] Migration completed! Fixed:', fixedCount, 'Errors:', errorCount);
|
||||
res.json({ success: true, fixed: fixedCount, errors: errorCount, total: allMessages.length });
|
||||
} catch (error) {
|
||||
console.error('[Messages] Migration error:', error.message);
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// DEBUG ENDPOINT
|
||||
router.get('/debug/all-messages', async (req, res) => {
|
||||
try {
|
||||
const allMessages = await Message.find().limit(10).exec();
|
||||
log('[Debug] Total messages in DB:', allMessages.length);
|
||||
|
||||
const info = allMessages.map(m => ({
|
||||
_id: m._id,
|
||||
threadId: m.threadId,
|
||||
senderCompanyId: m.senderCompanyId?.toString ? m.senderCompanyId.toString() : m.senderCompanyId,
|
||||
recipientCompanyId: m.recipientCompanyId?.toString ? m.recipientCompanyId.toString() : m.recipientCompanyId,
|
||||
text: m.text.substring(0, 30)
|
||||
}));
|
||||
|
||||
res.json({ totalCount: allMessages.length, messages: info });
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
175
server/routers/procurement/routes/products.js
Normal file
175
server/routers/procurement/routes/products.js
Normal file
@@ -0,0 +1,175 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const { verifyToken } = require('../middleware/auth');
|
||||
const Product = require('../models/Product');
|
||||
|
||||
// Функция для логирования с проверкой DEV переменной
|
||||
const log = (message, data = '') => {
|
||||
if (process.env.DEV === 'true') {
|
||||
if (data) {
|
||||
console.log(message, data);
|
||||
} else {
|
||||
console.log(message);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Helper to transform _id to id
|
||||
const transformProduct = (doc) => {
|
||||
if (!doc) return null;
|
||||
const obj = doc.toObject ? doc.toObject() : doc;
|
||||
return {
|
||||
...obj,
|
||||
id: obj._id,
|
||||
_id: undefined
|
||||
};
|
||||
};
|
||||
|
||||
// GET /products - Получить список продуктов/услуг компании (текущего пользователя)
|
||||
router.get('/', verifyToken, async (req, res) => {
|
||||
try {
|
||||
const companyId = req.companyId;
|
||||
|
||||
log('[Products] GET Fetching products for companyId:', companyId);
|
||||
|
||||
const products = await Product.find({ companyId })
|
||||
.sort({ createdAt: -1 })
|
||||
.exec();
|
||||
|
||||
log('[Products] Found', products.length, 'products');
|
||||
res.json(products.map(transformProduct));
|
||||
} catch (error) {
|
||||
console.error('[Products] Get error:', error.message);
|
||||
res.status(500).json({ error: 'Internal server error', message: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// POST /products - Создать продукт/услугу
|
||||
router.post('/', verifyToken, async (req, res) => {
|
||||
// try {
|
||||
const { name, category, description, type, productUrl, price, unit, minOrder } = req.body;
|
||||
const companyId = req.companyId;
|
||||
|
||||
log('[Products] POST Creating product:', { name, category, type });
|
||||
|
||||
// // Валидация
|
||||
// if (!name || !category || !description || !type) {
|
||||
// return res.status(400).json({ error: 'name, category, description, and type are required' });
|
||||
// }
|
||||
|
||||
// if (description.length < 20) {
|
||||
// return res.status(400).json({ error: 'Description must be at least 20 characters' });
|
||||
// }
|
||||
|
||||
const newProduct = new Product({
|
||||
name: name.trim(),
|
||||
category: category.trim(),
|
||||
description: description.trim(),
|
||||
type,
|
||||
productUrl: productUrl || '',
|
||||
companyId,
|
||||
price: price || '',
|
||||
unit: unit || '',
|
||||
minOrder: minOrder || ''
|
||||
});
|
||||
|
||||
const savedProduct = await newProduct.save();
|
||||
log('[Products] Product created with ID:', savedProduct._id);
|
||||
|
||||
res.status(201).json(transformProduct(savedProduct));
|
||||
// } catch (error) {
|
||||
// console.error('[Products] Create error:', error.message);
|
||||
// res.status(500).json({ error: 'Internal server error', message: error.message });
|
||||
// }
|
||||
});
|
||||
|
||||
// PUT /products/:id - Обновить продукт/услугу
|
||||
router.put('/:id', verifyToken, async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const updates = req.body;
|
||||
const companyId = req.companyId;
|
||||
|
||||
const product = await Product.findById(id);
|
||||
|
||||
if (!product) {
|
||||
return res.status(404).json({ error: 'Product not found' });
|
||||
}
|
||||
|
||||
// Проверить, что продукт принадлежит текущей компании
|
||||
if (product.companyId !== companyId) {
|
||||
return res.status(403).json({ error: 'Not authorized' });
|
||||
}
|
||||
|
||||
const updatedProduct = await Product.findByIdAndUpdate(
|
||||
id,
|
||||
{ ...updates, updatedAt: new Date() },
|
||||
{ new: true, runValidators: true }
|
||||
);
|
||||
|
||||
log('[Products] Product updated:', id);
|
||||
res.json(transformProduct(updatedProduct));
|
||||
} catch (error) {
|
||||
console.error('[Products] Update error:', error.message);
|
||||
res.status(500).json({ error: 'Internal server error', message: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// PATCH /products/:id - Частичное обновление продукта/услуги
|
||||
router.patch('/:id', verifyToken, async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const updates = req.body;
|
||||
const companyId = req.companyId;
|
||||
|
||||
const product = await Product.findById(id);
|
||||
|
||||
if (!product) {
|
||||
return res.status(404).json({ error: 'Product not found' });
|
||||
}
|
||||
|
||||
if (product.companyId !== companyId) {
|
||||
return res.status(403).json({ error: 'Not authorized' });
|
||||
}
|
||||
|
||||
const updatedProduct = await Product.findByIdAndUpdate(
|
||||
id,
|
||||
{ ...updates, updatedAt: new Date() },
|
||||
{ new: true, runValidators: true }
|
||||
);
|
||||
|
||||
log('[Products] Product patched:', id);
|
||||
res.json(transformProduct(updatedProduct));
|
||||
} catch (error) {
|
||||
console.error('[Products] Patch error:', error.message);
|
||||
res.status(500).json({ error: 'Internal server error', message: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// DELETE /products/:id - Удалить продукт/услугу
|
||||
router.delete('/:id', verifyToken, async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const companyId = req.companyId;
|
||||
|
||||
const product = await Product.findById(id);
|
||||
|
||||
if (!product) {
|
||||
return res.status(404).json({ error: 'Product not found' });
|
||||
}
|
||||
|
||||
if (product.companyId !== companyId) {
|
||||
return res.status(403).json({ error: 'Not authorized' });
|
||||
}
|
||||
|
||||
await Product.findByIdAndDelete(id);
|
||||
|
||||
log('[Products] Product deleted:', id);
|
||||
res.json({ message: 'Product deleted successfully' });
|
||||
} catch (error) {
|
||||
console.error('[Products] Delete error:', error.message);
|
||||
res.status(500).json({ error: 'Internal server error', message: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
563
server/routers/procurement/routes/requests.js
Normal file
563
server/routers/procurement/routes/requests.js
Normal file
@@ -0,0 +1,563 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const { verifyToken } = require('../middleware/auth');
|
||||
const Request = require('../models/Request');
|
||||
const BuyProduct = require('../models/BuyProduct');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const multer = require('multer');
|
||||
const mongoose = require('../../../utils/mongoose');
|
||||
|
||||
// Функция для логирования с проверкой DEV переменной
|
||||
const log = (message, data = '') => {
|
||||
if (process.env.DEV === 'true') {
|
||||
if (data) {
|
||||
console.log(message, data);
|
||||
} else {
|
||||
console.log(message);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const REQUESTS_UPLOAD_ROOT = 'server/routers/remote-assets/uploads/requests';
|
||||
|
||||
const ensureDirectory = (dirPath) => {
|
||||
if (!fs.existsSync(dirPath)) {
|
||||
fs.mkdirSync(dirPath, { recursive: true });
|
||||
}
|
||||
};
|
||||
|
||||
ensureDirectory(REQUESTS_UPLOAD_ROOT);
|
||||
|
||||
const MAX_REQUEST_FILE_SIZE = 20 * 1024 * 1024; // 20MB
|
||||
const ALLOWED_REQUEST_MIME_TYPES = new Set([
|
||||
'application/pdf',
|
||||
'application/msword',
|
||||
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
'application/vnd.ms-excel',
|
||||
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
'text/csv',
|
||||
]);
|
||||
|
||||
const storage = multer.diskStorage({
|
||||
destination: (req, file, cb) => {
|
||||
const subfolder = req.requestUploadSubfolder || '';
|
||||
const destinationDir = `${REQUESTS_UPLOAD_ROOT}/${subfolder}`;
|
||||
ensureDirectory(destinationDir);
|
||||
cb(null, destinationDir);
|
||||
},
|
||||
filename: (req, file, cb) => {
|
||||
const extension = path.extname(file.originalname) || '';
|
||||
const baseName = path
|
||||
.basename(file.originalname, extension)
|
||||
.replace(/[^a-zA-Z0-9-_]+/g, '_')
|
||||
.toLowerCase();
|
||||
cb(null, `${Date.now()}_${baseName}${extension}`);
|
||||
},
|
||||
});
|
||||
|
||||
const upload = multer({
|
||||
storage,
|
||||
limits: {
|
||||
fileSize: MAX_REQUEST_FILE_SIZE,
|
||||
},
|
||||
fileFilter: (req, file, cb) => {
|
||||
if (ALLOWED_REQUEST_MIME_TYPES.has(file.mimetype)) {
|
||||
cb(null, true);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!req.invalidFiles) {
|
||||
req.invalidFiles = [];
|
||||
}
|
||||
req.invalidFiles.push(file.originalname);
|
||||
cb(null, false);
|
||||
},
|
||||
});
|
||||
|
||||
const handleFilesUpload = (fieldName, subfolderResolver, maxCount = 10) => (req, res, next) => {
|
||||
req.invalidFiles = [];
|
||||
req.requestUploadSubfolder = subfolderResolver(req);
|
||||
|
||||
upload.array(fieldName, maxCount)(req, res, (err) => {
|
||||
if (err) {
|
||||
console.error('[Requests] Multer error:', err.message);
|
||||
if (err.code === 'LIMIT_FILE_SIZE') {
|
||||
return res.status(400).json({ error: 'File is too large. Maximum size is 20MB.' });
|
||||
}
|
||||
return res.status(400).json({ error: err.message });
|
||||
}
|
||||
next();
|
||||
});
|
||||
};
|
||||
|
||||
const cleanupUploadedFiles = async (req) => {
|
||||
if (!Array.isArray(req.files) || req.files.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const subfolder = req.requestUploadSubfolder || '';
|
||||
const removalTasks = req.files.map((file) => {
|
||||
const filePath = `${REQUESTS_UPLOAD_ROOT}/${subfolder}/${file.filename}`;
|
||||
return fs.promises.unlink(filePath).catch((error) => {
|
||||
if (error.code !== 'ENOENT') {
|
||||
console.error('[Requests] Failed to cleanup uploaded file:', error.message);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
await Promise.all(removalTasks);
|
||||
};
|
||||
|
||||
const mapFilesToMetadata = (req) => {
|
||||
if (!Array.isArray(req.files) || req.files.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const subfolder = req.requestUploadSubfolder || '';
|
||||
return req.files.map((file) => {
|
||||
const relativePath = `requests/${subfolder}/${file.filename}`;
|
||||
return {
|
||||
id: `file-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
||||
name: file.originalname,
|
||||
url: `/uploads/${relativePath}`,
|
||||
type: file.mimetype,
|
||||
size: file.size,
|
||||
uploadedAt: new Date(),
|
||||
storagePath: relativePath,
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const normalizeToArray = (value) => {
|
||||
if (!value) {
|
||||
return [];
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
return value;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(value);
|
||||
if (Array.isArray(parsed)) {
|
||||
return parsed;
|
||||
}
|
||||
} catch (error) {
|
||||
// ignore JSON parse errors
|
||||
}
|
||||
|
||||
return String(value)
|
||||
.split(',')
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean);
|
||||
};
|
||||
|
||||
const removeStoredFiles = async (files = []) => {
|
||||
if (!files || files.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const tasks = files
|
||||
.filter((file) => file && file.storagePath)
|
||||
.map((file) => {
|
||||
const absolutePath = `server/routers/remote-assets/uploads/${file.storagePath}`;
|
||||
return fs.promises.unlink(absolutePath).catch((error) => {
|
||||
if (error.code !== 'ENOENT') {
|
||||
console.error('[Requests] Failed to remove stored file:', error.message);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
await Promise.all(tasks);
|
||||
};
|
||||
|
||||
// GET /requests/sent - получить отправленные запросы
|
||||
router.get('/sent', verifyToken, async (req, res) => {
|
||||
try {
|
||||
const companyId = req.companyId;
|
||||
|
||||
if (!companyId) {
|
||||
return res.status(400).json({ error: 'Company ID is required' });
|
||||
}
|
||||
|
||||
const requests = await Request.find({ senderCompanyId: companyId })
|
||||
.sort({ createdAt: -1 })
|
||||
.exec();
|
||||
|
||||
log('[Requests] Returned', requests.length, 'sent requests for company', companyId);
|
||||
|
||||
res.json(requests);
|
||||
} catch (error) {
|
||||
console.error('[Requests] Error fetching sent requests:', error.message);
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// GET /requests/received - получить полученные запросы
|
||||
router.get('/received', verifyToken, async (req, res) => {
|
||||
try {
|
||||
const companyId = req.companyId;
|
||||
|
||||
if (!companyId) {
|
||||
return res.status(400).json({ error: 'Company ID is required' });
|
||||
}
|
||||
|
||||
const requests = await Request.find({ recipientCompanyId: companyId })
|
||||
.sort({ createdAt: -1 })
|
||||
.exec();
|
||||
|
||||
log('[Requests] Returned', requests.length, 'received requests for company', companyId);
|
||||
|
||||
res.json(requests);
|
||||
} catch (error) {
|
||||
console.error('[Requests] Error fetching received requests:', error.message);
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// POST /requests - создать запрос
|
||||
router.post(
|
||||
'/',
|
||||
verifyToken,
|
||||
handleFilesUpload('files', (req) => `sent/${(req.companyId || 'unknown').toString()}`, 10),
|
||||
async (req, res) => {
|
||||
try {
|
||||
const senderCompanyId = req.companyId;
|
||||
const recipients = normalizeToArray(req.body.recipientCompanyIds);
|
||||
const text = (req.body.text || '').trim();
|
||||
const productId = req.body.productId ? String(req.body.productId) : null;
|
||||
let subject = (req.body.subject || '').trim();
|
||||
|
||||
if (req.invalidFiles && req.invalidFiles.length > 0) {
|
||||
await cleanupUploadedFiles(req);
|
||||
return res.status(400).json({
|
||||
error: 'Unsupported file type. Allowed formats: PDF, DOC, DOCX, XLS, XLSX, CSV.',
|
||||
details: req.invalidFiles,
|
||||
});
|
||||
}
|
||||
|
||||
if (!text) {
|
||||
await cleanupUploadedFiles(req);
|
||||
return res.status(400).json({ error: 'Request text is required' });
|
||||
}
|
||||
|
||||
if (!recipients.length) {
|
||||
await cleanupUploadedFiles(req);
|
||||
return res.status(400).json({ error: 'At least one recipient is required' });
|
||||
}
|
||||
|
||||
let uploadedFiles = mapFilesToMetadata(req);
|
||||
|
||||
console.log('========================');
|
||||
console.log('[Requests] Initial uploadedFiles:', uploadedFiles.length);
|
||||
console.log('[Requests] ProductId:', productId);
|
||||
|
||||
// Если есть productId, получаем данные товара
|
||||
if (productId) {
|
||||
try {
|
||||
const product = await BuyProduct.findById(productId);
|
||||
console.log('[Requests] Product found:', product ? product.name : 'null');
|
||||
console.log('[Requests] Product files count:', product?.files?.length || 0);
|
||||
if (product && product.files) {
|
||||
console.log('[Requests] Product files:', JSON.stringify(product.files, null, 2));
|
||||
}
|
||||
|
||||
if (product) {
|
||||
// Берем subject из товара, если не указан
|
||||
if (!subject) {
|
||||
subject = product.name;
|
||||
}
|
||||
|
||||
// Если файлы не загружены вручную, используем файлы из товара
|
||||
if (uploadedFiles.length === 0 && product.files && product.files.length > 0) {
|
||||
console.log('[Requests] ✅ Copying files from product...');
|
||||
// Копируем файлы из товара, изменяя путь для запроса
|
||||
uploadedFiles = product.files.map(file => ({
|
||||
id: file.id || `file-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
||||
name: file.name,
|
||||
url: file.url,
|
||||
type: file.type,
|
||||
size: file.size,
|
||||
uploadedAt: file.uploadedAt || new Date(),
|
||||
storagePath: file.storagePath || file.url.replace('/uploads/', ''),
|
||||
}));
|
||||
console.log('[Requests] ✅ Using', uploadedFiles.length, 'files from product:', productId);
|
||||
console.log('[Requests] ✅ Copied files:', JSON.stringify(uploadedFiles, null, 2));
|
||||
} else {
|
||||
console.log('[Requests] ❌ NOT copying files. uploadedFiles.length:', uploadedFiles.length, 'product.files.length:', product.files?.length || 0);
|
||||
}
|
||||
}
|
||||
} catch (lookupError) {
|
||||
console.error('[Requests] ❌ Failed to lookup product:', lookupError.message);
|
||||
console.error(lookupError.stack);
|
||||
}
|
||||
}
|
||||
|
||||
console.log('[Requests] Final uploadedFiles for saving:', JSON.stringify(uploadedFiles, null, 2));
|
||||
console.log('========================');
|
||||
|
||||
if (!subject) {
|
||||
await cleanupUploadedFiles(req);
|
||||
return res.status(400).json({ error: 'Subject is required' });
|
||||
}
|
||||
|
||||
const results = [];
|
||||
for (const recipientCompanyId of recipients) {
|
||||
try {
|
||||
const request = new Request({
|
||||
senderCompanyId,
|
||||
recipientCompanyId,
|
||||
text,
|
||||
productId,
|
||||
subject,
|
||||
files: uploadedFiles,
|
||||
responseFiles: [],
|
||||
status: 'pending',
|
||||
});
|
||||
|
||||
await request.save();
|
||||
results.push({
|
||||
companyId: recipientCompanyId,
|
||||
success: true,
|
||||
message: 'Request sent successfully',
|
||||
});
|
||||
|
||||
log('[Requests] Request sent to company:', recipientCompanyId);
|
||||
} catch (err) {
|
||||
console.error('[Requests] Error storing request for company:', recipientCompanyId, err.message);
|
||||
results.push({
|
||||
companyId: recipientCompanyId,
|
||||
success: false,
|
||||
message: err.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const createdAt = new Date();
|
||||
|
||||
res.status(201).json({
|
||||
id: 'bulk-' + Date.now(),
|
||||
text,
|
||||
subject,
|
||||
productId,
|
||||
files: uploadedFiles,
|
||||
result: results,
|
||||
createdAt,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[Requests] Error creating request:', error.message);
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// PUT /requests/:id - ответить на запрос
|
||||
router.put(
|
||||
'/:id',
|
||||
verifyToken,
|
||||
handleFilesUpload('responseFiles', (req) => `responses/${req.params.id || 'unknown'}`, 5),
|
||||
async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
console.log('[Requests] PUT /requests/:id called with id:', id);
|
||||
console.log('[Requests] Request body:', req.body);
|
||||
console.log('[Requests] Files:', req.files);
|
||||
console.log('[Requests] CompanyId:', req.companyId);
|
||||
|
||||
const responseText = (req.body.response || '').trim();
|
||||
const statusRaw = (req.body.status || 'accepted').toLowerCase();
|
||||
const status = statusRaw === 'rejected' ? 'rejected' : 'accepted';
|
||||
|
||||
console.log('[Requests] Response text:', responseText);
|
||||
console.log('[Requests] Status:', status);
|
||||
|
||||
if (req.invalidFiles && req.invalidFiles.length > 0) {
|
||||
await cleanupUploadedFiles(req);
|
||||
return res.status(400).json({
|
||||
error: 'Unsupported file type. Allowed formats: PDF, DOC, DOCX, XLS, XLSX, CSV.',
|
||||
details: req.invalidFiles,
|
||||
});
|
||||
}
|
||||
|
||||
if (!responseText) {
|
||||
await cleanupUploadedFiles(req);
|
||||
return res.status(400).json({ error: 'Response text is required' });
|
||||
}
|
||||
|
||||
const request = await Request.findById(id);
|
||||
|
||||
if (!request) {
|
||||
await cleanupUploadedFiles(req);
|
||||
return res.status(404).json({ error: 'Request not found' });
|
||||
}
|
||||
|
||||
if (request.recipientCompanyId !== req.companyId) {
|
||||
await cleanupUploadedFiles(req);
|
||||
return res.status(403).json({ error: 'Not authorized' });
|
||||
}
|
||||
|
||||
const uploadedResponseFiles = mapFilesToMetadata(req);
|
||||
console.log('[Requests] Uploaded response files count:', uploadedResponseFiles.length);
|
||||
console.log('[Requests] Uploaded response files:', JSON.stringify(uploadedResponseFiles, null, 2));
|
||||
|
||||
if (uploadedResponseFiles.length > 0) {
|
||||
await removeStoredFiles(request.responseFiles || []);
|
||||
request.responseFiles = uploadedResponseFiles;
|
||||
}
|
||||
|
||||
request.response = responseText;
|
||||
request.status = status;
|
||||
request.respondedAt = new Date();
|
||||
request.updatedAt = new Date();
|
||||
|
||||
let savedRequest;
|
||||
try {
|
||||
savedRequest = await request.save();
|
||||
log('[Requests] Request responded:', id);
|
||||
} catch (saveError) {
|
||||
console.error('[Requests] Mongoose save failed, trying direct MongoDB update:', saveError.message);
|
||||
// Fallback: использовать MongoDB драйвер напрямую
|
||||
const updateData = {
|
||||
response: responseText,
|
||||
status: status,
|
||||
respondedAt: new Date(),
|
||||
updatedAt: new Date()
|
||||
};
|
||||
if (uploadedResponseFiles.length > 0) {
|
||||
updateData.responseFiles = uploadedResponseFiles;
|
||||
}
|
||||
|
||||
const result = await mongoose.connection.collection('requests').findOneAndUpdate(
|
||||
{ _id: new mongoose.Types.ObjectId(id) },
|
||||
{ $set: updateData },
|
||||
{ returnDocument: 'after' }
|
||||
);
|
||||
|
||||
if (!result) {
|
||||
throw new Error('Failed to update request');
|
||||
}
|
||||
savedRequest = result;
|
||||
log('[Requests] Request responded via direct MongoDB update:', id);
|
||||
}
|
||||
|
||||
res.json(savedRequest);
|
||||
} catch (error) {
|
||||
console.error('[Requests] Error responding to request:', error.message);
|
||||
console.error('[Requests] Error stack:', error.stack);
|
||||
if (error.name === 'ValidationError') {
|
||||
console.error('[Requests] Validation errors:', JSON.stringify(error.errors, null, 2));
|
||||
}
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// GET /requests/download/:id/:fileId - скачать файл ответа
|
||||
router.get('/download/:id/:fileId', verifyToken, async (req, res) => {
|
||||
try {
|
||||
console.log('[Requests] Download request received:', {
|
||||
requestId: req.params.id,
|
||||
fileId: req.params.fileId,
|
||||
userId: req.userId,
|
||||
companyId: req.companyId,
|
||||
});
|
||||
|
||||
const { id, fileId } = req.params;
|
||||
const request = await Request.findById(id);
|
||||
|
||||
if (!request) {
|
||||
return res.status(404).json({ error: 'Request not found' });
|
||||
}
|
||||
|
||||
// Проверяем, что пользователь имеет доступ к запросу (отправитель или получатель)
|
||||
if (request.senderCompanyId !== req.companyId && request.recipientCompanyId !== req.companyId) {
|
||||
return res.status(403).json({ error: 'Not authorized' });
|
||||
}
|
||||
|
||||
// Ищем файл в responseFiles или в обычных files
|
||||
let file = request.responseFiles?.find((f) => f.id === fileId);
|
||||
if (!file) {
|
||||
file = request.files?.find((f) => f.id === fileId);
|
||||
}
|
||||
if (!file) {
|
||||
return res.status(404).json({ error: 'File not found' });
|
||||
}
|
||||
|
||||
// Создаем абсолютный путь к файлу
|
||||
// Если storagePath не начинается с 'requests/', значит это файл из buy-products
|
||||
let fullPath = file.storagePath;
|
||||
if (!fullPath.startsWith('requests/')) {
|
||||
fullPath = `buy-products/${fullPath}`;
|
||||
}
|
||||
const filePath = path.resolve(`server/routers/remote-assets/uploads/${fullPath}`);
|
||||
|
||||
console.log('[Requests] Trying to download file:', {
|
||||
fileId: file.id,
|
||||
fileName: file.name,
|
||||
storagePath: file.storagePath,
|
||||
absolutePath: filePath,
|
||||
exists: fs.existsSync(filePath),
|
||||
});
|
||||
|
||||
// Проверяем существование файла
|
||||
if (!fs.existsSync(filePath)) {
|
||||
console.error('[Requests] File not found on disk:', filePath);
|
||||
return res.status(404).json({ error: 'File not found on disk' });
|
||||
}
|
||||
|
||||
// Устанавливаем правильные заголовки для скачивания с поддержкой кириллицы
|
||||
const encodedFileName = encodeURIComponent(file.name);
|
||||
res.setHeader('Content-Type', file.type || 'application/octet-stream');
|
||||
res.setHeader('Content-Disposition', `attachment; filename*=UTF-8''${encodedFileName}`);
|
||||
res.setHeader('Content-Length', file.size);
|
||||
|
||||
// Отправляем файл
|
||||
res.sendFile(filePath, (err) => {
|
||||
if (err) {
|
||||
console.error('[Requests] Error sending file:', err.message);
|
||||
if (!res.headersSent) {
|
||||
res.status(500).json({ error: 'Error sending file' });
|
||||
}
|
||||
} else {
|
||||
log('[Requests] File downloaded:', file.name);
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[Requests] Error downloading file:', error.message);
|
||||
if (!res.headersSent) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// DELETE /requests/:id - удалить запрос
|
||||
router.delete('/:id', verifyToken, async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
|
||||
const request = await Request.findById(id);
|
||||
|
||||
if (!request) {
|
||||
return res.status(404).json({ error: 'Request not found' });
|
||||
}
|
||||
|
||||
// Может удалить отправитель или получатель
|
||||
if (request.senderCompanyId !== req.companyId && request.recipientCompanyId !== req.companyId) {
|
||||
return res.status(403).json({ error: 'Not authorized' });
|
||||
}
|
||||
|
||||
await removeStoredFiles(request.files || []);
|
||||
await removeStoredFiles(request.responseFiles || []);
|
||||
|
||||
await Request.findByIdAndDelete(id);
|
||||
|
||||
log('[Requests] Request deleted:', id);
|
||||
|
||||
res.json({ message: 'Request deleted successfully' });
|
||||
} catch (error) {
|
||||
console.error('[Requests] Error deleting request:', error.message);
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
145
server/routers/procurement/routes/reviews.js
Normal file
145
server/routers/procurement/routes/reviews.js
Normal file
@@ -0,0 +1,145 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const { verifyToken } = require('../middleware/auth');
|
||||
const Review = require('../models/Review');
|
||||
const Company = require('../models/Company');
|
||||
|
||||
// Функция для логирования с проверкой DEV переменной
|
||||
const log = (message, data = '') => {
|
||||
if (process.env.DEV === 'true') {
|
||||
if (data) {
|
||||
console.log(message, data);
|
||||
} else {
|
||||
console.log(message);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Функция для пересчета рейтинга компании
|
||||
const updateCompanyRating = async (companyId) => {
|
||||
try {
|
||||
const reviews = await Review.find({ companyId });
|
||||
|
||||
if (reviews.length === 0) {
|
||||
await Company.findByIdAndUpdate(companyId, {
|
||||
rating: 0,
|
||||
reviews: 0,
|
||||
updatedAt: new Date()
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const totalRating = reviews.reduce((sum, review) => sum + review.rating, 0);
|
||||
const averageRating = totalRating / reviews.length;
|
||||
|
||||
await Company.findByIdAndUpdate(companyId, {
|
||||
rating: averageRating,
|
||||
reviews: reviews.length,
|
||||
updatedAt: new Date()
|
||||
});
|
||||
|
||||
log('[Reviews] Updated company rating:', companyId, 'New rating:', averageRating);
|
||||
} catch (error) {
|
||||
console.error('[Reviews] Error updating company rating:', error.message);
|
||||
}
|
||||
};
|
||||
|
||||
// GET /reviews/company/:companyId - получить отзывы компании
|
||||
router.get('/company/:companyId', verifyToken, async (req, res) => {
|
||||
try {
|
||||
const { companyId } = req.params;
|
||||
|
||||
const companyReviews = await Review.find({ companyId })
|
||||
.sort({ createdAt: -1 })
|
||||
.exec();
|
||||
|
||||
log('[Reviews] Returned', companyReviews.length, 'reviews for company', companyId);
|
||||
|
||||
res.json(companyReviews);
|
||||
} catch (error) {
|
||||
console.error('[Reviews] Error fetching reviews:', error.message);
|
||||
res.status(500).json({
|
||||
error: 'Internal server error',
|
||||
message: error.message,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// POST /reviews - создать новый отзыв
|
||||
router.post('/', verifyToken, async (req, res) => {
|
||||
try {
|
||||
const { companyId, rating, comment } = req.body;
|
||||
|
||||
if (!companyId || !rating || !comment) {
|
||||
return res.status(400).json({
|
||||
error: 'Заполните все обязательные поля: компания, рейтинг и комментарий',
|
||||
});
|
||||
}
|
||||
|
||||
if (rating < 1 || rating > 5) {
|
||||
return res.status(400).json({
|
||||
error: 'Рейтинг должен быть от 1 до 5',
|
||||
});
|
||||
}
|
||||
|
||||
const trimmedComment = comment.trim();
|
||||
if (trimmedComment.length < 10) {
|
||||
return res.status(400).json({
|
||||
error: 'Отзыв должен содержать минимум 10 символов',
|
||||
});
|
||||
}
|
||||
|
||||
if (trimmedComment.length > 1000) {
|
||||
return res.status(400).json({
|
||||
error: 'Отзыв не должен превышать 1000 символов',
|
||||
});
|
||||
}
|
||||
|
||||
// Получить данные пользователя из БД для актуальной информации
|
||||
const User = require('../models/User');
|
||||
const Company = require('../models/Company');
|
||||
|
||||
const user = await User.findById(req.userId);
|
||||
const userCompany = user && user.companyId ? await Company.findById(user.companyId) : null;
|
||||
|
||||
if (!user) {
|
||||
return res.status(404).json({
|
||||
error: 'Пользователь не найден',
|
||||
});
|
||||
}
|
||||
|
||||
// Создать новый отзыв
|
||||
const newReview = new Review({
|
||||
companyId,
|
||||
authorCompanyId: user.companyId || req.companyId,
|
||||
authorName: user.firstName && user.lastName
|
||||
? `${user.firstName} ${user.lastName}`
|
||||
: req.user?.firstName && req.user?.lastName
|
||||
? `${req.user.firstName} ${req.user.lastName}`
|
||||
: 'Аноним',
|
||||
authorCompany: userCompany?.fullName || userCompany?.shortName || req.user?.companyName || 'Компания',
|
||||
rating: parseInt(rating),
|
||||
comment: trimmedComment,
|
||||
verified: true,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date()
|
||||
});
|
||||
|
||||
const savedReview = await newReview.save();
|
||||
|
||||
log('[Reviews] New review created:', savedReview._id);
|
||||
|
||||
// Пересчитываем рейтинг компании
|
||||
await updateCompanyRating(companyId);
|
||||
|
||||
res.status(201).json(savedReview);
|
||||
} catch (error) {
|
||||
console.error('[Reviews] Error creating review:', error.message);
|
||||
res.status(500).json({
|
||||
error: 'Ошибка при сохранении отзыва',
|
||||
message: error.message,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
337
server/routers/procurement/routes/search.js
Normal file
337
server/routers/procurement/routes/search.js
Normal file
@@ -0,0 +1,337 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const { verifyToken } = require('../middleware/auth');
|
||||
const Company = require('../models/Company');
|
||||
|
||||
// Функция для логирования с проверкой DEV переменной
|
||||
const log = (message, data = '') => {
|
||||
if (process.env.DEV === 'true') {
|
||||
if (data) {
|
||||
console.log(message, data);
|
||||
} else {
|
||||
console.log(message);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// GET /search/recommendations - получить рекомендации компаний (ДОЛЖЕН быть ПЕРЕД /*)
|
||||
router.get('/recommendations', verifyToken, async (req, res) => {
|
||||
try {
|
||||
// Получить компанию пользователя, чтобы исключить её из результатов
|
||||
const User = require('../models/User');
|
||||
const user = await User.findById(req.userId);
|
||||
|
||||
let filter = {};
|
||||
if (user && user.companyId) {
|
||||
filter._id = { $ne: user.companyId };
|
||||
}
|
||||
|
||||
const companies = await Company.find(filter)
|
||||
.sort({ rating: -1 })
|
||||
.limit(5);
|
||||
|
||||
const recommendations = companies.map(company => ({
|
||||
id: company._id.toString(),
|
||||
name: company.fullName || company.shortName,
|
||||
industry: company.industry,
|
||||
logo: company.logo,
|
||||
matchScore: Math.floor(Math.random() * 30 + 70), // 70-100
|
||||
reason: 'Matches your search criteria'
|
||||
}));
|
||||
|
||||
log('[Search] Returned recommendations:', recommendations.length);
|
||||
|
||||
res.json(recommendations);
|
||||
} catch (error) {
|
||||
console.error('[Search] Recommendations error:', error.message);
|
||||
res.status(500).json({
|
||||
error: 'Internal server error',
|
||||
message: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// GET /search - Поиск компаний
|
||||
router.get('/', verifyToken, async (req, res) => {
|
||||
try {
|
||||
console.log('[Search] === NEW VERSION WITH FIXED SIZE FILTER ===');
|
||||
|
||||
const {
|
||||
query = '',
|
||||
page = 1,
|
||||
limit = 10,
|
||||
offset, // Добавляем поддержку offset для точной пагинации
|
||||
industries,
|
||||
companySize,
|
||||
geography,
|
||||
minRating = 0,
|
||||
hasReviews,
|
||||
hasAcceptedDocs,
|
||||
sortBy = 'relevance',
|
||||
sortOrder = 'desc',
|
||||
minEmployees, // Кастомный фильтр: минимум сотрудников
|
||||
maxEmployees // Кастомный фильтр: максимум сотрудников
|
||||
} = req.query;
|
||||
|
||||
console.log('[Search] Filters:', { minEmployees, maxEmployees, companySize });
|
||||
|
||||
// Получить компанию пользователя, чтобы исключить её из результатов
|
||||
const User = require('../models/User');
|
||||
const user = await User.findById(req.userId);
|
||||
|
||||
log('[Search] Request params:', { query, industries, companySize, geography, minRating, hasReviews, hasAcceptedDocs, sortBy, sortOrder });
|
||||
|
||||
// Маппинг кодов фильтров на значения в БД
|
||||
const industryMap = {
|
||||
'it': 'IT',
|
||||
'finance': 'Финансы',
|
||||
'manufacturing': 'Производство',
|
||||
'construction': 'Строительство',
|
||||
'retail': 'Розничная торговля',
|
||||
'wholesale': 'Оптовая торговля',
|
||||
'logistics': 'Логистика',
|
||||
'healthcare': 'Здравоохранение',
|
||||
'education': 'Образование',
|
||||
'consulting': 'Консалтинг',
|
||||
'marketing': 'Маркетинг',
|
||||
'realestate': 'Недвижимость',
|
||||
'food': 'Пищевая промышленность',
|
||||
'agriculture': 'Сельское хозяйство',
|
||||
'energy': 'Энергетика',
|
||||
'telecom': 'Телекоммуникации',
|
||||
'media': 'Медиа'
|
||||
};
|
||||
|
||||
// Начальный фильтр: исключить собственную компанию
|
||||
let filters = [];
|
||||
|
||||
if (user && user.companyId) {
|
||||
filters.push({ _id: { $ne: user.companyId } });
|
||||
}
|
||||
|
||||
// Текстовый поиск
|
||||
if (query && query.trim()) {
|
||||
const q = query.toLowerCase();
|
||||
filters.push({
|
||||
$or: [
|
||||
{ fullName: { $regex: q, $options: 'i' } },
|
||||
{ shortName: { $regex: q, $options: 'i' } },
|
||||
{ slogan: { $regex: q, $options: 'i' } },
|
||||
{ industry: { $regex: q, $options: 'i' } }
|
||||
]
|
||||
});
|
||||
}
|
||||
|
||||
// Фильтр по отраслям - преобразуем коды в значения БД
|
||||
if (industries) {
|
||||
const industryList = Array.isArray(industries) ? industries : [industries];
|
||||
if (industryList.length > 0) {
|
||||
const dbIndustries = industryList
|
||||
.map(code => industryMap[code])
|
||||
.filter(val => val !== undefined);
|
||||
|
||||
log('[Search] Raw industries param:', industries);
|
||||
log('[Search] Industry codes:', industryList, 'Mapped to:', dbIndustries);
|
||||
|
||||
if (dbIndustries.length > 0) {
|
||||
filters.push({ industry: { $in: dbIndustries } });
|
||||
log('[Search] Added industry filter:', { industry: { $in: dbIndustries } });
|
||||
} else {
|
||||
log('[Search] No industries mapped! Codes were:', industryList);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Функция для парсинга диапазона из строки вида "51-250" или "500+"
|
||||
const parseEmployeeRange = (sizeStr) => {
|
||||
if (sizeStr.includes('+')) {
|
||||
const min = parseInt(sizeStr.replace('+', ''));
|
||||
return { min, max: Infinity };
|
||||
}
|
||||
const parts = sizeStr.split('-');
|
||||
return {
|
||||
min: parseInt(parts[0]),
|
||||
max: parts[1] ? parseInt(parts[1]) : parseInt(parts[0])
|
||||
};
|
||||
};
|
||||
|
||||
// Функция для проверки пересечения двух диапазонов
|
||||
const rangesOverlap = (range1, range2) => {
|
||||
return range1.min <= range2.max && range1.max >= range2.min;
|
||||
};
|
||||
|
||||
// Фильтр по размеру компании (чекбоксы) или кастомный диапазон
|
||||
// Важно: этот фильтр должен получить все компании для корректной работы пересечения диапазонов
|
||||
let sizeFilteredIds = null;
|
||||
if ((companySize && companySize.length > 0) || minEmployees || maxEmployees) {
|
||||
// Получаем все компании (без других фильтров, так как размер компании - это property-based фильтр)
|
||||
const allCompanies = await Company.find({});
|
||||
|
||||
log('[Search] Employee size filter - checking companies:', allCompanies.length);
|
||||
|
||||
let matchingIds = [];
|
||||
|
||||
// Если есть кастомный диапазон - используем его
|
||||
if (minEmployees || maxEmployees) {
|
||||
const customRange = {
|
||||
min: minEmployees ? parseInt(minEmployees, 10) : 0,
|
||||
max: maxEmployees ? parseInt(maxEmployees, 10) : Infinity
|
||||
};
|
||||
|
||||
log('[Search] Custom employee range filter:', customRange);
|
||||
|
||||
matchingIds = allCompanies
|
||||
.filter(company => {
|
||||
if (!company.companySize) {
|
||||
log('[Search] Company has no size:', company.fullName);
|
||||
return false;
|
||||
}
|
||||
|
||||
const companyRange = parseEmployeeRange(company.companySize);
|
||||
const overlaps = rangesOverlap(companyRange, customRange);
|
||||
|
||||
log('[Search] Checking overlap:', {
|
||||
company: company.fullName,
|
||||
companyRange,
|
||||
customRange,
|
||||
overlaps
|
||||
});
|
||||
|
||||
return overlaps;
|
||||
})
|
||||
.map(c => c._id);
|
||||
|
||||
log('[Search] Matching companies by custom range:', matchingIds.length);
|
||||
}
|
||||
// Иначе используем чекбоксы
|
||||
else if (companySize && companySize.length > 0) {
|
||||
const sizeList = Array.isArray(companySize) ? companySize : [companySize];
|
||||
|
||||
log('[Search] Company size checkboxes filter:', sizeList);
|
||||
|
||||
matchingIds = allCompanies
|
||||
.filter(company => {
|
||||
if (!company.companySize) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const companyRange = parseEmployeeRange(company.companySize);
|
||||
|
||||
// Проверяем пересечение с любым из выбранных диапазонов
|
||||
const matches = sizeList.some(selectedSize => {
|
||||
const filterRange = parseEmployeeRange(selectedSize);
|
||||
const overlaps = rangesOverlap(companyRange, filterRange);
|
||||
log('[Search] Check:', company.fullName, companyRange, 'vs', filterRange, '=', overlaps);
|
||||
return overlaps;
|
||||
});
|
||||
|
||||
return matches;
|
||||
})
|
||||
.map(c => c._id);
|
||||
|
||||
log('[Search] Matching companies by size checkboxes:', matchingIds.length);
|
||||
}
|
||||
|
||||
// Сохраняем ID для дальнейшей фильтрации
|
||||
sizeFilteredIds = matchingIds;
|
||||
log('[Search] Size filtered IDs count:', sizeFilteredIds.length);
|
||||
}
|
||||
|
||||
// Фильтр по географии
|
||||
if (geography) {
|
||||
const geoList = Array.isArray(geography) ? geography : [geography];
|
||||
if (geoList.length > 0) {
|
||||
filters.push({ partnerGeography: { $in: geoList } });
|
||||
log('[Search] Geography filter:', { partnerGeography: { $in: geoList } });
|
||||
}
|
||||
}
|
||||
|
||||
// Фильтр по рейтингу
|
||||
if (minRating) {
|
||||
const rating = parseFloat(minRating);
|
||||
if (rating > 0) {
|
||||
filters.push({ rating: { $gte: rating } });
|
||||
}
|
||||
}
|
||||
|
||||
// Фильтр по отзывам
|
||||
if (hasReviews === 'true') {
|
||||
filters.push({ verified: true });
|
||||
}
|
||||
|
||||
// Фильтр по акцептам
|
||||
if (hasAcceptedDocs === 'true') {
|
||||
filters.push({ verified: true });
|
||||
}
|
||||
|
||||
// Применяем фильтр по размеру компании (если был задан)
|
||||
if (sizeFilteredIds !== null) {
|
||||
if (sizeFilteredIds.length > 0) {
|
||||
filters.push({ _id: { $in: sizeFilteredIds } });
|
||||
log('[Search] Applied size filter, IDs:', sizeFilteredIds.length);
|
||||
} else {
|
||||
// Если нет подходящих компаний по размеру, возвращаем пустой результат
|
||||
filters.push({ _id: null });
|
||||
log('[Search] No companies match size criteria');
|
||||
}
|
||||
}
|
||||
|
||||
// Комбинировать все фильтры
|
||||
let filter = filters.length > 0 ? { $and: filters } : {};
|
||||
|
||||
// Пагинация - используем offset если передан, иначе вычисляем из page
|
||||
const limitNum = parseInt(limit) || 10;
|
||||
const skip = offset !== undefined ? parseInt(offset) : ((parseInt(page) || 1) - 1) * limitNum;
|
||||
const pageNum = offset !== undefined ? Math.floor(skip / limitNum) + 1 : parseInt(page) || 1;
|
||||
|
||||
// Сортировка
|
||||
let sortOptions = {};
|
||||
if (sortBy === 'name') {
|
||||
sortOptions.fullName = sortOrder === 'asc' ? 1 : -1;
|
||||
} else {
|
||||
sortOptions.rating = sortOrder === 'asc' ? 1 : -1;
|
||||
}
|
||||
|
||||
log('[Search] Final MongoDB filter:', JSON.stringify(filter, null, 2));
|
||||
|
||||
let filterDebug = filters.length > 0 ? { $and: filters } : {};
|
||||
const allCompanies = await Company.find({});
|
||||
log('[Search] All companies in DB:', allCompanies.map(c => ({ name: c.fullName, geography: c.partnerGeography, industry: c.industry })));
|
||||
|
||||
const total = await Company.countDocuments(filter);
|
||||
const companies = await Company.find(filter)
|
||||
.sort(sortOptions)
|
||||
.skip(skip)
|
||||
.limit(limitNum);
|
||||
|
||||
const paginatedResults = companies.map(c => ({
|
||||
...c.toObject(),
|
||||
id: c._id
|
||||
}));
|
||||
|
||||
log('[Search] Query:', query, 'Industries:', industries, 'Size:', companySize, 'Geo:', geography);
|
||||
log('[Search] Total found:', total, 'Returning:', paginatedResults.length, 'companies');
|
||||
log('[Search] Company details:', paginatedResults.map(c => ({ name: c.fullName, industry: c.industry })));
|
||||
|
||||
res.json({
|
||||
companies: paginatedResults,
|
||||
total,
|
||||
page: pageNum,
|
||||
totalPages: Math.ceil(total / limitNum),
|
||||
_debug: {
|
||||
filter: JSON.stringify(filter),
|
||||
industriesReceived: industries
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[Search] Error:', error.message);
|
||||
res.status(500).json({
|
||||
error: 'Internal server error',
|
||||
message: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
|
||||
|
||||
92
server/routers/procurement/scripts/migrate-messages.js
Normal file
92
server/routers/procurement/scripts/migrate-messages.js
Normal file
@@ -0,0 +1,92 @@
|
||||
const mongoose = require('../../../utils/mongoose');
|
||||
const { ObjectId } = mongoose.Types;
|
||||
const Message = require('../models/Message');
|
||||
require('dotenv').config();
|
||||
|
||||
async function migrateMessages() {
|
||||
try {
|
||||
// Подключение к MongoDB происходит через server/utils/mongoose.ts
|
||||
console.log('[Migration] Checking MongoDB connection...');
|
||||
if (mongoose.connection.readyState !== 1) {
|
||||
console.log('[Migration] Waiting for MongoDB connection...');
|
||||
await new Promise((resolve) => {
|
||||
mongoose.connection.once('connected', resolve);
|
||||
});
|
||||
}
|
||||
console.log('[Migration] Connected to MongoDB');
|
||||
|
||||
// Найти все сообщения
|
||||
const allMessages = await Message.find().exec();
|
||||
console.log('[Migration] Found', allMessages.length, 'total messages');
|
||||
|
||||
let fixedCount = 0;
|
||||
let errorCount = 0;
|
||||
|
||||
// Проходим по каждому сообщению
|
||||
for (const message of allMessages) {
|
||||
try {
|
||||
const threadId = message.threadId;
|
||||
if (!threadId) {
|
||||
console.log('[Migration] Skipping message', message._id, '- no threadId');
|
||||
continue;
|
||||
}
|
||||
|
||||
// Парсим threadId формата "thread-id1-id2" или "id1-id2"
|
||||
let ids = threadId.replace('thread-', '').split('-');
|
||||
|
||||
if (ids.length < 2) {
|
||||
console.log('[Migration] Invalid threadId format:', threadId);
|
||||
errorCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const companyId1 = ids[0];
|
||||
const companyId2 = ids[1];
|
||||
|
||||
// Сравниваем с senderCompanyId
|
||||
const senderIdString = message.senderCompanyId.toString ? message.senderCompanyId.toString() : message.senderCompanyId;
|
||||
const expectedRecipient = senderIdString === companyId1 ? companyId2 : companyId1;
|
||||
|
||||
// Если recipientCompanyId не установлена или неправильная - исправляем
|
||||
if (!message.recipientCompanyId || message.recipientCompanyId.toString() !== expectedRecipient) {
|
||||
console.log('[Migration] Fixing message', message._id);
|
||||
console.log(' Old recipientCompanyId:', message.recipientCompanyId);
|
||||
console.log(' Expected:', expectedRecipient);
|
||||
|
||||
// Конвертируем в ObjectId если нужно
|
||||
let recipientObjectId = expectedRecipient;
|
||||
try {
|
||||
if (typeof expectedRecipient === 'string' && ObjectId.isValid(expectedRecipient)) {
|
||||
recipientObjectId = new ObjectId(expectedRecipient);
|
||||
}
|
||||
} catch (e) {
|
||||
console.log(' Could not convert to ObjectId');
|
||||
}
|
||||
|
||||
await Message.updateOne(
|
||||
{ _id: message._id },
|
||||
{ recipientCompanyId: recipientObjectId }
|
||||
);
|
||||
|
||||
fixedCount++;
|
||||
console.log(' ✅ Fixed');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[Migration] Error processing message', message._id, ':', err.message);
|
||||
errorCount++;
|
||||
}
|
||||
}
|
||||
|
||||
console.log('[Migration] ✅ Migration completed!');
|
||||
console.log('[Migration] Fixed:', fixedCount, 'messages');
|
||||
console.log('[Migration] Errors:', errorCount);
|
||||
|
||||
await mongoose.connection.close();
|
||||
console.log('[Migration] Disconnected from MongoDB');
|
||||
} catch (err) {
|
||||
console.error('[Migration] ❌ Error:', err.message);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
migrateMessages();
|
||||
382
server/routers/procurement/scripts/recreate-test-user.js
Normal file
382
server/routers/procurement/scripts/recreate-test-user.js
Normal file
@@ -0,0 +1,382 @@
|
||||
const mongoose = require('../../../utils/mongoose');
|
||||
require('dotenv').config();
|
||||
|
||||
// Импорт моделей
|
||||
const User = require('../models/User');
|
||||
const Company = require('../models/Company');
|
||||
const Request = require('../models/Request');
|
||||
|
||||
// Подключение к MongoDB происходит через server/utils/mongoose.ts
|
||||
// Проверяем, подключено ли уже
|
||||
const ensureConnection = async () => {
|
||||
if (mongoose.connection.readyState === 1) {
|
||||
console.log('✅ MongoDB уже подключено');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('⏳ Ожидание подключения к MongoDB...');
|
||||
await new Promise((resolve) => {
|
||||
if (mongoose.connection.readyState === 1) {
|
||||
resolve();
|
||||
} else {
|
||||
mongoose.connection.once('connected', resolve);
|
||||
}
|
||||
});
|
||||
console.log('✅ Подключено к MongoDB');
|
||||
};
|
||||
|
||||
const recreateTestUser = async () => {
|
||||
try {
|
||||
await ensureConnection();
|
||||
|
||||
const presetCompanyId = new mongoose.Types.ObjectId('68fe2ccda3526c303ca06796');
|
||||
const presetUserEmail = 'admin@test-company.ru';
|
||||
|
||||
const presetCompanyId2 = new mongoose.Types.ObjectId('68fe2ccda3526c303ca06797');
|
||||
const presetUserEmail2 = 'manager@partner-company.ru';
|
||||
|
||||
// Удалить старых тестовых пользователей
|
||||
console.log('🗑️ Удаление старых тестовых пользователей...');
|
||||
const testEmails = [presetUserEmail, presetUserEmail2];
|
||||
|
||||
for (const email of testEmails) {
|
||||
const oldUser = await User.findOne({ email });
|
||||
if (oldUser) {
|
||||
// Удалить связанную компанию
|
||||
if (oldUser.companyId) {
|
||||
await Company.findByIdAndDelete(oldUser.companyId);
|
||||
console.log(` ✓ Старая компания для ${email} удалена`);
|
||||
}
|
||||
await User.findByIdAndDelete(oldUser._id);
|
||||
console.log(` ✓ Старый пользователь ${email} удален`);
|
||||
} else {
|
||||
console.log(` ℹ️ Пользователь ${email} не найден`);
|
||||
}
|
||||
}
|
||||
|
||||
// Создать новую компанию с правильной кодировкой UTF-8
|
||||
console.log('\n🏢 Создание тестовой компании...');
|
||||
const company = await Company.create({
|
||||
_id: presetCompanyId,
|
||||
fullName: 'ООО "Тестовая Компания"',
|
||||
shortName: 'Тестовая Компания',
|
||||
inn: '1234567890',
|
||||
ogrn: '1234567890123',
|
||||
legalForm: 'ООО',
|
||||
industry: 'IT',
|
||||
companySize: '51-250',
|
||||
website: 'https://test-company.ru',
|
||||
phone: '+7 (999) 123-45-67',
|
||||
email: 'info@test-company.ru',
|
||||
description: 'Тестовая компания для разработки',
|
||||
legalAddress: 'г. Москва, ул. Тестовая, д. 1',
|
||||
actualAddress: 'г. Москва, ул. Тестовая, д. 1',
|
||||
foundedYear: 2015,
|
||||
employeeCount: '51-250',
|
||||
revenue: 'До 120 млн ₽',
|
||||
rating: 4.5,
|
||||
reviews: 10,
|
||||
verified: true,
|
||||
partnerGeography: ['moscow', 'russia_all'],
|
||||
slogan: 'Ваш надежный партнер в IT',
|
||||
});
|
||||
console.log(' ✓ Компания создана:', company.fullName);
|
||||
|
||||
// Создать первого пользователя с правильной кодировкой UTF-8
|
||||
console.log('\n👤 Создание первого тестового пользователя...');
|
||||
const user = await User.create({
|
||||
email: presetUserEmail,
|
||||
password: 'SecurePass123!',
|
||||
firstName: 'Иван',
|
||||
lastName: 'Иванов',
|
||||
position: 'Директор',
|
||||
phone: '+7 (999) 123-45-67',
|
||||
companyId: company._id,
|
||||
});
|
||||
console.log(' ✓ Пользователь создан:', user.firstName, user.lastName);
|
||||
|
||||
// Создать вторую компанию
|
||||
console.log('\n🏢 Создание второй тестовой компании...');
|
||||
const company2 = await Company.create({
|
||||
_id: presetCompanyId2,
|
||||
fullName: 'ООО "Партнер"',
|
||||
shortName: 'Партнер',
|
||||
inn: '9876543210',
|
||||
ogrn: '1089876543210',
|
||||
legalForm: 'ООО',
|
||||
industry: 'Торговля',
|
||||
companySize: '11-50',
|
||||
website: 'https://partner-company.ru',
|
||||
phone: '+7 (495) 987-65-43',
|
||||
email: 'info@partner-company.ru',
|
||||
description: 'Надежный партнер для бизнеса',
|
||||
legalAddress: 'г. Санкт-Петербург, пр. Невский, д. 100',
|
||||
actualAddress: 'г. Санкт-Петербург, пр. Невский, д. 100',
|
||||
foundedYear: 2018,
|
||||
employeeCount: '11-50',
|
||||
revenue: 'До 60 млн ₽',
|
||||
rating: 4.3,
|
||||
reviews: 5,
|
||||
verified: true,
|
||||
partnerGeography: ['spb', 'russia_all'],
|
||||
slogan: 'Качество и надежность',
|
||||
});
|
||||
console.log(' ✓ Компания создана:', company2.fullName);
|
||||
|
||||
// Создать второго пользователя
|
||||
console.log('\n👤 Создание второго тестового пользователя...');
|
||||
const user2 = await User.create({
|
||||
email: presetUserEmail2,
|
||||
password: 'SecurePass123!',
|
||||
firstName: 'Петр',
|
||||
lastName: 'Петров',
|
||||
position: 'Менеджер',
|
||||
phone: '+7 (495) 987-65-43',
|
||||
companyId: company2._id,
|
||||
});
|
||||
console.log(' ✓ Пользователь создан:', user2.firstName, user2.lastName);
|
||||
|
||||
// Проверка что данные сохранены правильно
|
||||
console.log('\n✅ Проверка данных:');
|
||||
console.log('\n Пользователь 1:');
|
||||
console.log(' Email:', user.email);
|
||||
console.log(' Имя:', user.firstName);
|
||||
console.log(' Фамилия:', user.lastName);
|
||||
console.log(' Компания:', company.fullName);
|
||||
console.log(' Должность:', user.position);
|
||||
|
||||
console.log('\n Пользователь 2:');
|
||||
console.log(' Email:', user2.email);
|
||||
console.log(' Имя:', user2.firstName);
|
||||
console.log(' Фамилия:', user2.lastName);
|
||||
console.log(' Компания:', company2.fullName);
|
||||
console.log(' Должность:', user2.position);
|
||||
|
||||
console.log('\n✅ ГОТОВО! Тестовые пользователи созданы с правильной кодировкой UTF-8');
|
||||
console.log('\n📋 Данные для входа:');
|
||||
console.log('\n Пользователь 1:');
|
||||
console.log(' Email: admin@test-company.ru');
|
||||
console.log(' Пароль: SecurePass123!');
|
||||
console.log('\n Пользователь 2:');
|
||||
console.log(' Email: manager@partner-company.ru');
|
||||
console.log(' Пароль: SecurePass123!');
|
||||
console.log('');
|
||||
|
||||
// Создать дополнительные тестовые компании для поиска
|
||||
console.log('\n🏢 Создание дополнительных тестовых компаний...');
|
||||
const testCompanies = [
|
||||
{
|
||||
fullName: 'ООО "ТехноСтрой"',
|
||||
shortName: 'ТехноСтрой',
|
||||
inn: '7707083894',
|
||||
ogrn: '1077707083894',
|
||||
legalForm: 'ООО',
|
||||
industry: 'Строительство',
|
||||
companySize: '51-250',
|
||||
website: 'https://technostroy.ru',
|
||||
phone: '+7 (495) 111-22-33',
|
||||
email: 'info@technostroy.ru',
|
||||
description: 'Строительство промышленных объектов',
|
||||
foundedYear: 2010,
|
||||
employeeCount: '51-250',
|
||||
revenue: 'До 2 млрд ₽',
|
||||
rating: 4.2,
|
||||
reviews: 15,
|
||||
verified: true,
|
||||
partnerGeography: ['moscow', 'russia_all'],
|
||||
slogan: 'Строим будущее вместе',
|
||||
},
|
||||
{
|
||||
fullName: 'АО "ФинансГрупп"',
|
||||
shortName: 'ФинансГрупп',
|
||||
inn: '7707083895',
|
||||
ogrn: '1077707083895',
|
||||
legalForm: 'АО',
|
||||
industry: 'Финансы',
|
||||
companySize: '500+',
|
||||
website: 'https://finansgrupp.ru',
|
||||
phone: '+7 (495) 222-33-44',
|
||||
email: 'contact@finansgrupp.ru',
|
||||
description: 'Финансовые услуги для бизнеса',
|
||||
foundedYear: 2005,
|
||||
employeeCount: '500+',
|
||||
revenue: 'Более 2 млрд ₽',
|
||||
rating: 4.8,
|
||||
reviews: 50,
|
||||
verified: true,
|
||||
partnerGeography: ['moscow', 'russia_all', 'international'],
|
||||
slogan: 'Финансовая стабильность',
|
||||
},
|
||||
{
|
||||
fullName: 'ООО "ИТ Решения"',
|
||||
shortName: 'ИТ Решения',
|
||||
inn: '7707083896',
|
||||
ogrn: '1077707083896',
|
||||
legalForm: 'ООО',
|
||||
industry: 'IT',
|
||||
companySize: '11-50',
|
||||
website: 'https://it-solutions.ru',
|
||||
phone: '+7 (495) 333-44-55',
|
||||
email: 'hello@it-solutions.ru',
|
||||
description: 'Разработка программного обеспечения',
|
||||
foundedYear: 2018,
|
||||
employeeCount: '11-50',
|
||||
revenue: 'До 60 млн ₽',
|
||||
rating: 4.5,
|
||||
reviews: 8,
|
||||
verified: true,
|
||||
partnerGeography: ['moscow', 'spb', 'russia_all'],
|
||||
slogan: 'Инновации для вашего бизнеса',
|
||||
},
|
||||
{
|
||||
fullName: 'ООО "ЛогистикПро"',
|
||||
shortName: 'ЛогистикПро',
|
||||
inn: '7707083897',
|
||||
ogrn: '1077707083897',
|
||||
legalForm: 'ООО',
|
||||
industry: 'Логистика',
|
||||
companySize: '51-250',
|
||||
website: 'https://logistikpro.ru',
|
||||
phone: '+7 (495) 444-55-66',
|
||||
email: 'info@logistikpro.ru',
|
||||
description: 'Транспортные и логистические услуги',
|
||||
foundedYear: 2012,
|
||||
employeeCount: '51-250',
|
||||
revenue: 'До 120 млн ₽',
|
||||
rating: 4.3,
|
||||
reviews: 20,
|
||||
verified: true,
|
||||
partnerGeography: ['russia_all', 'cis'],
|
||||
slogan: 'Доставим в срок',
|
||||
},
|
||||
{
|
||||
fullName: 'ООО "ПродуктТрейд"',
|
||||
shortName: 'ПродуктТрейд',
|
||||
inn: '7707083898',
|
||||
ogrn: '1077707083898',
|
||||
legalForm: 'ООО',
|
||||
industry: 'Оптовая торговля',
|
||||
companySize: '251-500',
|
||||
website: 'https://produkttrade.ru',
|
||||
phone: '+7 (495) 555-66-77',
|
||||
email: 'sales@produkttrade.ru',
|
||||
description: 'Оптовая торговля продуктами питания',
|
||||
foundedYear: 2008,
|
||||
employeeCount: '251-500',
|
||||
revenue: 'До 2 млрд ₽',
|
||||
rating: 4.1,
|
||||
reviews: 30,
|
||||
verified: true,
|
||||
partnerGeography: ['moscow', 'russia_all'],
|
||||
slogan: 'Качество и надежность',
|
||||
},
|
||||
{
|
||||
fullName: 'ООО "МедСервис"',
|
||||
shortName: 'МедСервис',
|
||||
inn: '7707083899',
|
||||
ogrn: '1077707083899',
|
||||
legalForm: 'ООО',
|
||||
industry: 'Здравоохранение',
|
||||
companySize: '11-50',
|
||||
website: 'https://medservice.ru',
|
||||
phone: '+7 (495) 666-77-88',
|
||||
email: 'info@medservice.ru',
|
||||
description: 'Медицинские услуги и оборудование',
|
||||
foundedYear: 2016,
|
||||
employeeCount: '11-50',
|
||||
revenue: 'До 60 млн ₽',
|
||||
rating: 4.6,
|
||||
reviews: 12,
|
||||
verified: true,
|
||||
partnerGeography: ['moscow', 'central'],
|
||||
slogan: 'Забота о вашем здоровье',
|
||||
},
|
||||
];
|
||||
|
||||
for (const companyData of testCompanies) {
|
||||
await Company.updateOne(
|
||||
{ inn: companyData.inn },
|
||||
{ $set: companyData },
|
||||
{ upsert: true }
|
||||
);
|
||||
console.log(` ✓ Компания создана/обновлена: ${companyData.shortName}`);
|
||||
}
|
||||
|
||||
// Создать тестовые запросы
|
||||
console.log('\n📨 Создание тестовых запросов...');
|
||||
await Request.deleteMany({});
|
||||
|
||||
const companies = await Company.find().limit(10).exec();
|
||||
const testCompanyId = company._id.toString();
|
||||
const requests = [];
|
||||
const now = new Date();
|
||||
|
||||
// Создаем отправленные запросы (от тестовой компании)
|
||||
for (let i = 0; i < 5; i++) {
|
||||
const recipientCompany = companies[i % companies.length];
|
||||
if (recipientCompany._id.toString() === testCompanyId) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const createdAt = new Date(now.getTime() - i * 24 * 60 * 60 * 1000);
|
||||
|
||||
requests.push({
|
||||
senderCompanyId: testCompanyId,
|
||||
recipientCompanyId: recipientCompany._id.toString(),
|
||||
subject: `Запрос на поставку ${i + 1}`,
|
||||
text: `Здравствуйте! Интересует поставка товаров/услуг. Запрос ${i + 1}. Прошу предоставить коммерческое предложение.`,
|
||||
files: [],
|
||||
responseFiles: [],
|
||||
status: i % 3 === 0 ? 'accepted' : i % 3 === 1 ? 'rejected' : 'pending',
|
||||
response: i % 3 === 0
|
||||
? 'Благодарим за запрос! Готовы предоставить услуги. Отправили КП на почту.'
|
||||
: i % 3 === 1
|
||||
? 'К сожалению, в данный момент не можем предоставить эти услуги.'
|
||||
: null,
|
||||
respondedAt: i % 3 !== 2 ? new Date(createdAt.getTime() + 2 * 60 * 60 * 1000) : null,
|
||||
createdAt,
|
||||
updatedAt: i % 3 !== 2 ? new Date(createdAt.getTime() + 2 * 60 * 60 * 1000) : createdAt,
|
||||
});
|
||||
}
|
||||
|
||||
// Создаем полученные запросы (к тестовой компании)
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const senderCompany = companies[(i + 2) % companies.length];
|
||||
if (senderCompany._id.toString() === testCompanyId) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const createdAt = new Date(now.getTime() - (i + 1) * 12 * 60 * 60 * 1000);
|
||||
|
||||
requests.push({
|
||||
senderCompanyId: senderCompany._id.toString(),
|
||||
recipientCompanyId: testCompanyId,
|
||||
subject: `Предложение о сотрудничестве ${i + 1}`,
|
||||
text: `Добрый день! Предлагаем сотрудничество. Запрос ${i + 1}. Заинтересованы в вашей продукции.`,
|
||||
files: [],
|
||||
responseFiles: [],
|
||||
status: 'pending',
|
||||
response: null,
|
||||
respondedAt: null,
|
||||
createdAt,
|
||||
updatedAt: createdAt,
|
||||
});
|
||||
}
|
||||
|
||||
if (requests.length > 0) {
|
||||
await Request.insertMany(requests);
|
||||
console.log(` ✓ Создано ${requests.length} тестовых запросов`);
|
||||
}
|
||||
|
||||
await mongoose.connection.close();
|
||||
process.exit(0);
|
||||
} catch (error) {
|
||||
console.error('\n❌ Ошибка:', error.message);
|
||||
console.error(error);
|
||||
process.exit(1);
|
||||
}
|
||||
};
|
||||
|
||||
// Запуск
|
||||
recreateTestUser();
|
||||
|
||||
126
server/routers/procurement/scripts/seed-activities.js
Normal file
126
server/routers/procurement/scripts/seed-activities.js
Normal file
@@ -0,0 +1,126 @@
|
||||
const mongoose = require('../../../utils/mongoose');
|
||||
require('dotenv').config();
|
||||
|
||||
// Подключение моделей
|
||||
const Activity = require('../models/Activity');
|
||||
const User = require('../models/User');
|
||||
const Company = require('../models/Company');
|
||||
|
||||
const activityTemplates = [
|
||||
{
|
||||
type: 'request_received',
|
||||
title: 'Получен новый запрос',
|
||||
description: 'Компания отправила вам запрос на поставку товаров',
|
||||
},
|
||||
{
|
||||
type: 'request_sent',
|
||||
title: 'Запрос отправлен',
|
||||
description: 'Ваш запрос был отправлен компании',
|
||||
},
|
||||
{
|
||||
type: 'request_response',
|
||||
title: 'Получен ответ на запрос',
|
||||
description: 'Компания ответила на ваш запрос',
|
||||
},
|
||||
{
|
||||
type: 'product_accepted',
|
||||
title: 'Товар акцептован',
|
||||
description: 'Ваш товар был акцептован компанией',
|
||||
},
|
||||
{
|
||||
type: 'message_received',
|
||||
title: 'Новое сообщение',
|
||||
description: 'Вы получили новое сообщение от компании',
|
||||
},
|
||||
{
|
||||
type: 'review_received',
|
||||
title: 'Новый отзыв',
|
||||
description: 'Компания оставила отзыв о сотрудничестве',
|
||||
},
|
||||
{
|
||||
type: 'profile_updated',
|
||||
title: 'Профиль обновлен',
|
||||
description: 'Информация о вашей компании была обновлена',
|
||||
},
|
||||
{
|
||||
type: 'buy_product_added',
|
||||
title: 'Добавлен товар для закупки',
|
||||
description: 'В раздел "Я покупаю" добавлен новый товар',
|
||||
},
|
||||
];
|
||||
|
||||
async function seedActivities() {
|
||||
try {
|
||||
// Подключение к MongoDB происходит через server/utils/mongoose.ts
|
||||
console.log('🌱 Checking MongoDB connection...');
|
||||
if (mongoose.connection.readyState !== 1) {
|
||||
console.log('⏳ Waiting for MongoDB connection...');
|
||||
await new Promise((resolve) => {
|
||||
mongoose.connection.once('connected', resolve);
|
||||
});
|
||||
}
|
||||
console.log('✅ Connected to MongoDB');
|
||||
|
||||
// Найти тестового пользователя
|
||||
const testUser = await User.findOne({ email: 'admin@test-company.ru' });
|
||||
if (!testUser) {
|
||||
console.log('❌ Test user not found. Please run recreate-test-user.js first.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const company = await Company.findById(testUser.companyId);
|
||||
if (!company) {
|
||||
console.log('❌ Company not found');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Найти другие компании для связанных активностей
|
||||
const otherCompanies = await Company.find({
|
||||
_id: { $ne: company._id }
|
||||
}).limit(3);
|
||||
|
||||
console.log('🗑️ Clearing existing activities...');
|
||||
await Activity.deleteMany({ companyId: company._id.toString() });
|
||||
|
||||
console.log('➕ Creating activities...');
|
||||
const activities = [];
|
||||
|
||||
for (let i = 0; i < 8; i++) {
|
||||
const template = activityTemplates[i % activityTemplates.length];
|
||||
const relatedCompany = otherCompanies[i % otherCompanies.length];
|
||||
|
||||
const activity = {
|
||||
companyId: company._id.toString(),
|
||||
userId: testUser._id.toString(),
|
||||
type: template.type,
|
||||
title: template.title,
|
||||
description: template.description,
|
||||
relatedCompanyId: relatedCompany?._id.toString(),
|
||||
relatedCompanyName: relatedCompany?.shortName || relatedCompany?.fullName,
|
||||
read: i >= 5, // Первые 5 непрочитанные
|
||||
createdAt: new Date(Date.now() - i * 3600000), // Каждый час назад
|
||||
};
|
||||
|
||||
activities.push(activity);
|
||||
}
|
||||
|
||||
await Activity.insertMany(activities);
|
||||
|
||||
console.log(`✅ Created ${activities.length} activities`);
|
||||
console.log('✨ Activities seeded successfully!');
|
||||
|
||||
await mongoose.connection.close();
|
||||
console.log('👋 Database connection closed');
|
||||
} catch (error) {
|
||||
console.error('❌ Error seeding activities:', error);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// Запуск
|
||||
if (require.main === module) {
|
||||
seedActivities();
|
||||
}
|
||||
|
||||
module.exports = { seedActivities };
|
||||
|
||||
118
server/routers/procurement/scripts/seed-requests.js
Normal file
118
server/routers/procurement/scripts/seed-requests.js
Normal file
@@ -0,0 +1,118 @@
|
||||
const mongoose = require('../../../utils/mongoose');
|
||||
const Request = require('../models/Request');
|
||||
const Company = require('../models/Company');
|
||||
const User = require('../models/User');
|
||||
|
||||
async function seedRequests() {
|
||||
try {
|
||||
// Подключение к MongoDB происходит через server/utils/mongoose.ts
|
||||
if (mongoose.connection.readyState !== 1) {
|
||||
console.log('⏳ Waiting for MongoDB connection...');
|
||||
await new Promise((resolve) => {
|
||||
mongoose.connection.once('connected', resolve);
|
||||
});
|
||||
}
|
||||
console.log('✅ Connected to MongoDB');
|
||||
|
||||
// Получаем все компании
|
||||
const companies = await Company.find().limit(10).exec();
|
||||
if (companies.length < 2) {
|
||||
console.error('❌ Need at least 2 companies in database');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Получаем тестового пользователя
|
||||
const testUser = await User.findOne({ email: 'admin@test-company.ru' }).exec();
|
||||
if (!testUser) {
|
||||
console.error('❌ Test user not found');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const testCompanyId = testUser.companyId.toString();
|
||||
console.log('📋 Test company ID:', testCompanyId);
|
||||
console.log('📋 Found', companies.length, 'companies');
|
||||
|
||||
// Удаляем старые запросы
|
||||
await Request.deleteMany({});
|
||||
console.log('🗑️ Cleared old requests');
|
||||
|
||||
const requests = [];
|
||||
const now = new Date();
|
||||
|
||||
// Создаем отправленные запросы (от тестовой компании)
|
||||
for (let i = 0; i < 5; i++) {
|
||||
const recipientCompany = companies[i % companies.length];
|
||||
if (recipientCompany._id.toString() === testCompanyId) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const createdAt = new Date(now.getTime() - i * 24 * 60 * 60 * 1000); // За последние 5 дней
|
||||
|
||||
requests.push({
|
||||
senderCompanyId: testCompanyId,
|
||||
recipientCompanyId: recipientCompany._id.toString(),
|
||||
subject: `Запрос на поставку ${i + 1}`,
|
||||
text: `Здравствуйте! Интересует поставка товаров/услуг. Запрос ${i + 1}. Прошу предоставить коммерческое предложение.`,
|
||||
files: [],
|
||||
responseFiles: [],
|
||||
status: i % 3 === 0 ? 'accepted' : i % 3 === 1 ? 'rejected' : 'pending',
|
||||
response: i % 3 === 0
|
||||
? 'Благодарим за запрос! Готовы предоставить услуги. Отправили КП на почту.'
|
||||
: i % 3 === 1
|
||||
? 'К сожалению, в данный момент не можем предоставить эти услуги.'
|
||||
: null,
|
||||
respondedAt: i % 3 !== 2 ? new Date(createdAt.getTime() + 2 * 60 * 60 * 1000) : null,
|
||||
createdAt,
|
||||
updatedAt: i % 3 !== 2 ? new Date(createdAt.getTime() + 2 * 60 * 60 * 1000) : createdAt,
|
||||
});
|
||||
}
|
||||
|
||||
// Создаем полученные запросы (к тестовой компании)
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const senderCompany = companies[(i + 2) % companies.length];
|
||||
if (senderCompany._id.toString() === testCompanyId) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const createdAt = new Date(now.getTime() - (i + 1) * 12 * 60 * 60 * 1000); // За последние 1.5 дня
|
||||
|
||||
requests.push({
|
||||
senderCompanyId: senderCompany._id.toString(),
|
||||
recipientCompanyId: testCompanyId,
|
||||
subject: `Предложение о сотрудничестве ${i + 1}`,
|
||||
text: `Добрый день! Предлагаем сотрудничество. Запрос ${i + 1}. Заинтересованы в вашей продукции.`,
|
||||
files: [],
|
||||
responseFiles: [],
|
||||
status: 'pending',
|
||||
response: null,
|
||||
respondedAt: null,
|
||||
createdAt,
|
||||
updatedAt: createdAt,
|
||||
});
|
||||
}
|
||||
|
||||
// Сохраняем все запросы
|
||||
const savedRequests = await Request.insertMany(requests);
|
||||
console.log('✅ Created', savedRequests.length, 'test requests');
|
||||
|
||||
// Статистика
|
||||
const sentCount = await Request.countDocuments({ senderCompanyId: testCompanyId });
|
||||
const receivedCount = await Request.countDocuments({ recipientCompanyId: testCompanyId });
|
||||
const withResponses = await Request.countDocuments({ senderCompanyId: testCompanyId, response: { $ne: null } });
|
||||
|
||||
console.log('📊 Statistics:');
|
||||
console.log(' - Sent requests:', sentCount);
|
||||
console.log(' - Received requests:', receivedCount);
|
||||
console.log(' - With responses:', withResponses);
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ Error:', error);
|
||||
process.exit(1);
|
||||
} finally {
|
||||
await mongoose.connection.close();
|
||||
console.log('👋 Disconnected from MongoDB');
|
||||
}
|
||||
}
|
||||
|
||||
seedRequests();
|
||||
|
||||
61
server/routers/procurement/scripts/test-logging.js
Normal file
61
server/routers/procurement/scripts/test-logging.js
Normal file
@@ -0,0 +1,61 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Скрипт для тестирования логирования
|
||||
*
|
||||
* Использование:
|
||||
* node stubs/scripts/test-logging.js # Логи скрыты (DEV не установлена)
|
||||
* DEV=true node stubs/scripts/test-logging.js # Логи видны
|
||||
*/
|
||||
|
||||
// Функция логирования из маршрутов
|
||||
const log = (message, data = '') => {
|
||||
if (process.env.DEV === 'true') {
|
||||
if (data) {
|
||||
console.log(message, data);
|
||||
} else {
|
||||
console.log(message);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
console.log('');
|
||||
console.log('='.repeat(60));
|
||||
console.log('TEST: Логирование с переменной окружения DEV');
|
||||
console.log('='.repeat(60));
|
||||
console.log('');
|
||||
|
||||
console.log('Значение DEV:', process.env.DEV || '(не установлена)');
|
||||
console.log('');
|
||||
|
||||
// Тестируем различные логи
|
||||
log('[Auth] Token verified - userId: 68fe2ccda3526c303ca06799 companyId: 68fe2ccda3526c303ca06796');
|
||||
log('[Auth] Generating token for userId:', '68fe2ccda3526c303ca06799');
|
||||
log('[BuyProducts] Found', 0, 'products for company 68fe2ccda3526c303ca06796');
|
||||
log('[Products] GET Fetching products for companyId:', '68fe2ccda3526c303ca06799');
|
||||
log('[Products] Found', 1, 'products');
|
||||
log('[Reviews] Returned', 0, 'reviews for company 68fe2ccda3526c303ca06796');
|
||||
log('[Messages] Fetching threads for companyId:', '68fe2ccda3526c303ca06796');
|
||||
log('[Messages] Found', 4, 'messages for company');
|
||||
log('[Messages] Returned', 3, 'unique threads');
|
||||
log('[Search] Request params:', { query: '', page: 1 });
|
||||
|
||||
console.log('');
|
||||
console.log('='.repeat(60));
|
||||
console.log('РЕЗУЛЬТАТ:');
|
||||
console.log('='.repeat(60));
|
||||
|
||||
if (process.env.DEV === 'true') {
|
||||
console.log('✅ DEV=true - логи ВИДНЫ выше');
|
||||
} else {
|
||||
console.log('❌ DEV не установлена или != "true" - логи СКРЫТЫ');
|
||||
console.log('');
|
||||
console.log('Для включения логов запустите:');
|
||||
console.log(' export DEV=true && npm start (Linux/Mac)');
|
||||
console.log(' $env:DEV = "true"; npm start (PowerShell)');
|
||||
console.log(' set DEV=true && npm start (CMD)');
|
||||
}
|
||||
|
||||
console.log('');
|
||||
console.log('='.repeat(60));
|
||||
console.log('');
|
||||
@@ -187,6 +187,7 @@ function showConfirm(message, callback, title) {
|
||||
function generateQRCode(data, size) {
|
||||
const typeNumber = 0; // Автоматическое определение
|
||||
const errorCorrectionLevel = 'L'; // Низкий уровень коррекции ошибок
|
||||
// eslint-disable-next-line no-undef
|
||||
const qr = qrcode(typeNumber, errorCorrectionLevel);
|
||||
qr.addData(data);
|
||||
qr.make();
|
||||
|
||||
@@ -344,21 +344,21 @@ $(document).ready(function() {
|
||||
|
||||
// Инициализируем атрибуты required
|
||||
updateRequiredAttributes();
|
||||
});
|
||||
|
||||
// Обработчик удаления вопроса
|
||||
$(document).on('click', '.remove-question', function() {
|
||||
$(this).closest('.question-item').remove();
|
||||
updateQuestionNumbers();
|
||||
|
||||
// Вызываем функцию обновления атрибутов required
|
||||
updateRequiredAttributes();
|
||||
});
|
||||
|
||||
// Обработчик удаления опции
|
||||
$(document).on('click', '.remove-option', function() {
|
||||
$(this).closest('.option-item').remove();
|
||||
// Обработчик удаления вопроса
|
||||
$(document).on('click', '.remove-question', function() {
|
||||
$(this).closest('.question-item').remove();
|
||||
updateQuestionNumbers();
|
||||
|
||||
// Вызываем функцию обновления атрибутов required
|
||||
updateRequiredAttributes();
|
||||
});
|
||||
|
||||
// Вызываем функцию обновления атрибутов required
|
||||
updateRequiredAttributes();
|
||||
// Обработчик удаления опции
|
||||
$(document).on('click', '.remove-option', function() {
|
||||
$(this).closest('.option-item').remove();
|
||||
|
||||
// Вызываем функцию обновления атрибутов required
|
||||
updateRequiredAttributes();
|
||||
});
|
||||
});
|
||||
833
server/routers/smoke-tracker/API.md
Normal file
833
server/routers/smoke-tracker/API.md
Normal file
@@ -0,0 +1,833 @@
|
||||
# Smoke Tracker API — Документация для Frontend
|
||||
|
||||
## Базовый URL
|
||||
|
||||
```
|
||||
http://localhost:8044/smoke-tracker
|
||||
```
|
||||
|
||||
В production окружении замените на соответствующий домен.
|
||||
|
||||
---
|
||||
|
||||
## Оглавление
|
||||
|
||||
1. [Авторизация](#авторизация)
|
||||
- [Регистрация](#post-authsignup)
|
||||
- [Вход](#post-authsignin)
|
||||
2. [Логирование сигарет](#логирование-сигарет)
|
||||
- [Записать сигарету](#post-cigarettes)
|
||||
- [Получить список сигарет](#get-cigarettes)
|
||||
3. [Статистика](#статистика)
|
||||
- [Дневная статистика](#get-statsdaily)
|
||||
- [Сводная статистика](#get-statssummary)
|
||||
|
||||
---
|
||||
|
||||
## Авторизация
|
||||
|
||||
Все эндпоинты, кроме `/auth/signup` и `/auth/signin`, требуют JWT-токен в заголовке:
|
||||
|
||||
```
|
||||
Authorization: Bearer <token>
|
||||
```
|
||||
|
||||
Токен возвращается при успешном входе (`/auth/signin`) и действителен **12 часов**.
|
||||
|
||||
---
|
||||
|
||||
### `POST /auth/signup`
|
||||
|
||||
**Описание**: Регистрация нового пользователя
|
||||
|
||||
**Требуется авторизация**: ❌ Нет
|
||||
|
||||
**Тело запроса** (JSON):
|
||||
|
||||
```json
|
||||
{
|
||||
"login": "string", // обязательно, уникальный логин
|
||||
"password": "string" // обязательно
|
||||
}
|
||||
```
|
||||
|
||||
**Пример запроса**:
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:8044/smoke-tracker/auth/signup \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"login": "user123",
|
||||
"password": "mySecurePassword"
|
||||
}'
|
||||
```
|
||||
|
||||
**Ответ при успехе** (200 OK):
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"body": {
|
||||
"ok": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Возможные ошибки**:
|
||||
|
||||
- **400 Bad Request**: `"Не все поля заполнены: login, password"` — не указаны обязательные поля
|
||||
- **500 Internal Server Error**: `"Пользователь с таким логином уже существует"` — логин занят
|
||||
|
||||
---
|
||||
|
||||
### `POST /auth/signin`
|
||||
|
||||
**Описание**: Вход в систему (получение JWT-токена)
|
||||
|
||||
**Требуется авторизация**: ❌ Нет
|
||||
|
||||
**Тело запроса** (JSON):
|
||||
|
||||
```json
|
||||
{
|
||||
"login": "string", // обязательно
|
||||
"password": "string" // обязательно
|
||||
}
|
||||
```
|
||||
|
||||
**Пример запроса**:
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:8044/smoke-tracker/auth/signin \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"login": "user123",
|
||||
"password": "mySecurePassword"
|
||||
}'
|
||||
```
|
||||
|
||||
**Ответ при успехе** (200 OK):
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"body": {
|
||||
"user": {
|
||||
"id": "507f1f77bcf86cd799439011",
|
||||
"login": "user123",
|
||||
"created": "2024-01-15T10:30:00.000Z"
|
||||
},
|
||||
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Поля ответа**:
|
||||
|
||||
- `user.id` — уникальный идентификатор пользователя
|
||||
- `user.login` — логин пользователя
|
||||
- `user.created` — дата создания аккаунта (ISO 8601)
|
||||
- `token` — JWT-токен для авторизации (без ограничений по времени действия)
|
||||
|
||||
**Возможные ошибки**:
|
||||
|
||||
- **400 Bad Request**: `"Не все поля заполнены: login, password"` — не указаны обязательные поля
|
||||
- **500 Internal Server Error**: `"Неверный логин или пароль"` — неправильные учётные данные
|
||||
|
||||
**Использование токена**:
|
||||
|
||||
Сохраните токен в localStorage/sessionStorage/cookie и передавайте в заголовке всех последующих запросов:
|
||||
|
||||
```javascript
|
||||
// Пример для fetch API
|
||||
const token = localStorage.getItem('smokeToken');
|
||||
|
||||
fetch('http://localhost:8044/smoke-tracker/cigarettes', {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Логирование сигарет
|
||||
|
||||
### `POST /cigarettes`
|
||||
|
||||
**Описание**: Записать факт выкуренной сигареты
|
||||
|
||||
**Требуется авторизация**: ✅ Да (Bearer token)
|
||||
|
||||
**Тело запроса** (JSON):
|
||||
|
||||
```json
|
||||
{
|
||||
"smokedAt": "string (ISO 8601)", // необязательно, по умолчанию — текущее время
|
||||
"note": "string" // необязательно, заметка/комментарий
|
||||
}
|
||||
```
|
||||
|
||||
**Пример запроса**:
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:8044/smoke-tracker/cigarettes \
|
||||
-H "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"smokedAt": "2024-01-15T14:30:00.000Z",
|
||||
"note": "После обеда"
|
||||
}'
|
||||
```
|
||||
|
||||
**Пример без указания времени** (будет текущее время):
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:8044/smoke-tracker/cigarettes \
|
||||
-H "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{}'
|
||||
```
|
||||
|
||||
**Ответ при успехе** (200 OK):
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"body": {
|
||||
"id": "507f1f77bcf86cd799439012",
|
||||
"userId": "507f1f77bcf86cd799439011",
|
||||
"smokedAt": "2024-01-15T14:30:00.000Z",
|
||||
"note": "После обеда",
|
||||
"created": "2024-01-15T14:30:05.123Z"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Поля ответа**:
|
||||
|
||||
- `id` — уникальный идентификатор записи
|
||||
- `userId` — ID пользователя
|
||||
- `smokedAt` — дата и время курения (ISO 8601)
|
||||
- `note` — заметка (если была указана)
|
||||
- `created` — дата создания записи в БД
|
||||
|
||||
**Возможные ошибки**:
|
||||
|
||||
- **401 Unauthorized**: `"Требуется авторизация"` — не передан токен
|
||||
- **401 Unauthorized**: `"Неверный или истекший токен авторизации"` — токен невалидный/просрочен
|
||||
- **400 Bad Request**: `"Некорректный формат даты smokedAt"` — неверный формат даты
|
||||
|
||||
---
|
||||
|
||||
### `GET /cigarettes`
|
||||
|
||||
**Описание**: Получить список всех выкуренных сигарет текущего пользователя
|
||||
|
||||
**Требуется авторизация**: ✅ Да (Bearer token)
|
||||
|
||||
**Query-параметры** (все необязательные):
|
||||
|
||||
| Параметр | Тип | Описание | Пример |
|
||||
|----------|-----|----------|--------|
|
||||
| `from` | string (ISO 8601) | Начало периода (включительно) | `2024-01-01T00:00:00.000Z` |
|
||||
| `to` | string (ISO 8601) | Конец периода (включительно) | `2024-01-31T23:59:59.999Z` |
|
||||
|
||||
**Пример запроса** (все сигареты):
|
||||
|
||||
```bash
|
||||
curl -X GET http://localhost:8044/smoke-tracker/cigarettes \
|
||||
-H "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
|
||||
```
|
||||
|
||||
**Пример запроса** (с фильтрацией по датам):
|
||||
|
||||
```bash
|
||||
curl -X GET "http://localhost:8044/smoke-tracker/cigarettes?from=2024-01-01T00:00:00.000Z&to=2024-01-31T23:59:59.999Z" \
|
||||
-H "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
|
||||
```
|
||||
|
||||
**Ответ при успехе** (200 OK):
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"body": [
|
||||
{
|
||||
"id": "507f1f77bcf86cd799439012",
|
||||
"userId": "507f1f77bcf86cd799439011",
|
||||
"smokedAt": "2024-01-15T10:30:00.000Z",
|
||||
"note": "Утренняя",
|
||||
"created": "2024-01-15T10:30:05.123Z"
|
||||
},
|
||||
{
|
||||
"id": "507f1f77bcf86cd799439013",
|
||||
"userId": "507f1f77bcf86cd799439011",
|
||||
"smokedAt": "2024-01-15T14:30:00.000Z",
|
||||
"note": "После обеда",
|
||||
"created": "2024-01-15T14:30:05.456Z"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**Особенности**:
|
||||
|
||||
- Записи отсортированы по `smokedAt` (от старых к новым)
|
||||
- Если указаны `from` и/или `to`, будет применена фильтрация
|
||||
- Пустой массив возвращается, если сигарет в периоде нет
|
||||
|
||||
**Возможные ошибки**:
|
||||
|
||||
- **401 Unauthorized**: `"Требуется авторизация"` — не передан токен
|
||||
- **401 Unauthorized**: `"Неверный или истекший токен авторизации"` — токен невалидный/просрочен
|
||||
|
||||
---
|
||||
|
||||
## Статистика
|
||||
|
||||
### `GET /stats/daily`
|
||||
|
||||
**Описание**: Получить дневную статистику по количеству сигарет для построения графика
|
||||
|
||||
**Требуется авторизация**: ✅ Да (Bearer token)
|
||||
|
||||
**Query-параметры** (все необязательные):
|
||||
|
||||
| Параметр | Тип | Описание | Пример | По умолчанию |
|
||||
|----------|-----|----------|--------|--------------|
|
||||
| `from` | string (ISO 8601) | Начало периода | `2024-01-01T00:00:00.000Z` | 30 дней назад от текущей даты |
|
||||
| `to` | string (ISO 8601) | Конец периода | `2024-01-31T23:59:59.999Z` | Текущая дата и время |
|
||||
|
||||
**Пример запроса** (последние 30 дней):
|
||||
|
||||
```bash
|
||||
curl -X GET http://localhost:8044/smoke-tracker/stats/daily \
|
||||
-H "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
|
||||
```
|
||||
|
||||
**Пример запроса** (с указанием периода):
|
||||
|
||||
```bash
|
||||
curl -X GET "http://localhost:8044/smoke-tracker/stats/daily?from=2024-01-01T00:00:00.000Z&to=2024-01-31T23:59:59.999Z" \
|
||||
-H "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
|
||||
```
|
||||
|
||||
**Ответ при успехе** (200 OK):
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"body": [
|
||||
{
|
||||
"date": "2024-01-15",
|
||||
"count": 8
|
||||
},
|
||||
{
|
||||
"date": "2024-01-16",
|
||||
"count": 12
|
||||
},
|
||||
{
|
||||
"date": "2024-01-17",
|
||||
"count": 5
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**Поля ответа**:
|
||||
|
||||
- `date` — дата в формате `YYYY-MM-DD`
|
||||
- `count` — количество сигарет, выкуренных в этот день
|
||||
|
||||
**Особенности**:
|
||||
|
||||
- Данные отсортированы по дате (от старых к новым)
|
||||
- Дни без сигарет **не включаются** в ответ (фронтенду нужно самостоятельно заполнить пропуски нулями при построении графика)
|
||||
- Агрегация происходит по дате из поля `smokedAt` (не `created`)
|
||||
|
||||
**Пример использования для графика** (Chart.js):
|
||||
|
||||
```javascript
|
||||
const response = await fetch('http://localhost:8044/smoke-tracker/stats/daily', {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`
|
||||
}
|
||||
});
|
||||
|
||||
const { body } = await response.json();
|
||||
|
||||
// Заполнение пропущенных дней нулями
|
||||
const fillMissingDates = (data, from, to) => {
|
||||
const result = [];
|
||||
const current = new Date(from);
|
||||
const end = new Date(to);
|
||||
|
||||
while (current <= end) {
|
||||
const dateStr = current.toISOString().split('T')[0];
|
||||
const existing = data.find(d => d.date === dateStr);
|
||||
|
||||
result.push({
|
||||
date: dateStr,
|
||||
count: existing ? existing.count : 0
|
||||
});
|
||||
|
||||
current.setDate(current.getDate() + 1);
|
||||
}
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
const filledData = fillMissingDates(body, '2024-01-01', '2024-01-31');
|
||||
|
||||
// Данные для графика
|
||||
const chartData = {
|
||||
labels: filledData.map(d => d.date),
|
||||
datasets: [{
|
||||
label: 'Количество сигарет',
|
||||
data: filledData.map(d => d.count),
|
||||
borderColor: 'rgb(255, 99, 132)',
|
||||
backgroundColor: 'rgba(255, 99, 132, 0.2)',
|
||||
}]
|
||||
};
|
||||
```
|
||||
|
||||
**Возможные ошибки**:
|
||||
|
||||
- **401 Unauthorized**: `"Требуется авторизация"` — не передан токен
|
||||
- **401 Unauthorized**: `"Неверный или истекший токен авторизации"` — токен невалидный/просрочен
|
||||
|
||||
---
|
||||
|
||||
### `GET /stats/summary`
|
||||
|
||||
**Описание**: Получить расширенную статистику для текущего пользователя и общую по всем пользователям
|
||||
|
||||
**Требуется авторизация**: ✅ Да (Bearer token)
|
||||
|
||||
**Query-параметры** (все необязательные):
|
||||
|
||||
| Параметр | Тип | Описание | Пример | По умолчанию |
|
||||
|----------|-----|----------|--------|--------------|
|
||||
| `from` | string (ISO 8601) | Начало периода | `2024-01-01T00:00:00.000Z` | 30 дней назад от текущей даты |
|
||||
| `to` | string (ISO 8601) | Конец периода | `2024-01-31T23:59:59.999Z` | Текущая дата и время |
|
||||
|
||||
**Пример запроса**:
|
||||
|
||||
```bash
|
||||
curl -X GET http://localhost:8044/smoke-tracker/stats/summary \
|
||||
-H "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
|
||||
```
|
||||
|
||||
**Ответ при успехе** (200 OK):
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"body": {
|
||||
"user": {
|
||||
"daily": [
|
||||
{
|
||||
"date": "2024-01-15",
|
||||
"count": 8
|
||||
},
|
||||
{
|
||||
"date": "2024-01-16",
|
||||
"count": 12
|
||||
}
|
||||
],
|
||||
"averagePerDay": 10.5,
|
||||
"weekday": [
|
||||
{
|
||||
"dayOfWeek": 2,
|
||||
"dayName": "Понедельник",
|
||||
"count": 25,
|
||||
"average": "6.25"
|
||||
},
|
||||
{
|
||||
"dayOfWeek": 3,
|
||||
"dayName": "Вторник",
|
||||
"count": 30,
|
||||
"average": "7.50"
|
||||
}
|
||||
],
|
||||
"total": 315,
|
||||
"daysWithData": 30
|
||||
},
|
||||
"global": {
|
||||
"daily": [
|
||||
{
|
||||
"date": "2024-01-15",
|
||||
"count": 45
|
||||
},
|
||||
{
|
||||
"date": "2024-01-16",
|
||||
"count": 52
|
||||
}
|
||||
],
|
||||
"averagePerDay": 48.5,
|
||||
"weekday": [
|
||||
{
|
||||
"dayOfWeek": 2,
|
||||
"dayName": "Понедельник",
|
||||
"count": 120,
|
||||
"average": "30.00"
|
||||
},
|
||||
{
|
||||
"dayOfWeek": 3,
|
||||
"dayName": "Вторник",
|
||||
"count": 135,
|
||||
"average": "33.75"
|
||||
}
|
||||
],
|
||||
"total": 1455,
|
||||
"daysWithData": 30,
|
||||
"activeUsers": 5
|
||||
},
|
||||
"period": {
|
||||
"from": "2024-01-01T00:00:00.000Z",
|
||||
"to": "2024-01-31T23:59:59.999Z"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Структура ответа**:
|
||||
|
||||
**`user`** — статистика текущего пользователя:
|
||||
- `daily` — массив с количеством сигарет по дням
|
||||
- `date` — дата в формате YYYY-MM-DD
|
||||
- `count` — количество сигарет
|
||||
- `averagePerDay` — среднее количество сигарет в день (число с плавающей точкой)
|
||||
- `weekday` — статистика по дням недели
|
||||
- `dayOfWeek` — номер дня недели (1 = воскресенье, 2 = понедельник, ..., 7 = суббота)
|
||||
- `dayName` — название дня недели
|
||||
- `count` — общее количество сигарет в этот день недели за весь период
|
||||
- `average` — среднее количество за один такой день недели (строка)
|
||||
- `total` — общее количество сигарет за период
|
||||
- `daysWithData` — количество дней, в которые были записи
|
||||
|
||||
**`global`** — общая статистика по всем **активным** пользователям:
|
||||
- `daily` — массив с суммарным количеством сигарет всех активных пользователей по дням
|
||||
- `averagePerDay` — среднее количество сигарет в день (активные пользователи)
|
||||
- `weekday` — статистика по дням недели (активные пользователи)
|
||||
- `total` — общее количество сигарет всех активных пользователей за период
|
||||
- `daysWithData` — количество дней с записями
|
||||
- `activeUsers` — количество активных пользователей в период
|
||||
|
||||
> **Примечание**: Активными считаются только пользователи, которые в среднем выкуривают **от 2 до 40 сигарет в день**. Это позволяет исключить из статистики:
|
||||
> - Тестовые аккаунты и неактивных пользователей (< 2 сигарет/день)
|
||||
> - Ошибочные или накликанные данные (> 40 сигарет/день)
|
||||
|
||||
**`period`** — информация о запрошенном периоде:
|
||||
- `from` — начало периода (ISO 8601)
|
||||
- `to` — конец периода (ISO 8601)
|
||||
|
||||
**Особенности**:
|
||||
|
||||
- Дни недели нумеруются по стандарту MongoDB: 1 = Воскресенье, 2 = Понедельник, ..., 7 = Суббота
|
||||
- `average` для дней недели рассчитывается делением общего количества на количество таких дней в периоде
|
||||
- Дни без записей **не включаются** в массив `daily`
|
||||
- Глобальная статистика позволяет сравнить свои результаты с другими пользователями
|
||||
|
||||
**Примеры использования**:
|
||||
|
||||
```javascript
|
||||
// Получение сводной статистики
|
||||
const response = await fetch('http://localhost:8044/smoke-tracker/stats/summary', {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`
|
||||
}
|
||||
});
|
||||
|
||||
const { body } = await response.json();
|
||||
|
||||
console.log(`Вы в среднем выкуриваете ${body.user.averagePerDay} сигарет в день`);
|
||||
console.log(`Общее среднее по всем пользователям: ${body.global.averagePerDay} сигарет в день`);
|
||||
console.log(`Активных пользователей в периоде: ${body.global.activeUsers}`);
|
||||
|
||||
// Поиск самого "тяжёлого" дня недели
|
||||
const maxWeekday = body.user.weekday.reduce((max, day) =>
|
||||
parseFloat(day.average) > parseFloat(max.average) ? day : max
|
||||
);
|
||||
console.log(`Больше всего вы курите в ${maxWeekday.dayName} (в среднем ${maxWeekday.average} сигарет)`);
|
||||
```
|
||||
|
||||
**Визуализация данных по дням недели**:
|
||||
|
||||
```javascript
|
||||
// Данные для круговой диаграммы (Chart.js)
|
||||
const weekdayChartData = {
|
||||
labels: body.user.weekday.map(d => d.dayName),
|
||||
datasets: [{
|
||||
label: 'Сигарет в день недели',
|
||||
data: body.user.weekday.map(d => d.count),
|
||||
backgroundColor: [
|
||||
'rgba(255, 99, 132, 0.6)',
|
||||
'rgba(54, 162, 235, 0.6)',
|
||||
'rgba(255, 206, 86, 0.6)',
|
||||
'rgba(75, 192, 192, 0.6)',
|
||||
'rgba(153, 102, 255, 0.6)',
|
||||
'rgba(255, 159, 64, 0.6)',
|
||||
'rgba(199, 199, 199, 0.6)'
|
||||
]
|
||||
}]
|
||||
};
|
||||
```
|
||||
|
||||
**Сравнение с глобальной статистикой**:
|
||||
|
||||
```javascript
|
||||
// Сравнительный график (ваши данные vs общие данные)
|
||||
const comparisonData = {
|
||||
labels: body.user.weekday.map(d => d.dayName),
|
||||
datasets: [
|
||||
{
|
||||
label: 'Вы',
|
||||
data: body.user.weekday.map(d => parseFloat(d.average)),
|
||||
borderColor: 'rgb(255, 99, 132)',
|
||||
backgroundColor: 'rgba(255, 99, 132, 0.2)',
|
||||
},
|
||||
{
|
||||
label: 'Среднее по пользователям',
|
||||
data: body.global.weekday.map(d => parseFloat(d.average)),
|
||||
borderColor: 'rgb(54, 162, 235)',
|
||||
backgroundColor: 'rgba(54, 162, 235, 0.2)',
|
||||
}
|
||||
]
|
||||
};
|
||||
```
|
||||
|
||||
**Возможные ошибки**:
|
||||
|
||||
- **401 Unauthorized**: `"Требуется авторизация"` — не передан токен
|
||||
- **401 Unauthorized**: `"Неверный или истекший токен авторизации"` — токен невалидный/просрочен
|
||||
|
||||
---
|
||||
|
||||
## Общая структура ответов
|
||||
|
||||
Все эндпоинты возвращают JSON в следующем формате:
|
||||
|
||||
**Успешный ответ**:
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"body": { /* данные */ }
|
||||
}
|
||||
```
|
||||
|
||||
**Ответ с ошибкой**:
|
||||
|
||||
```json
|
||||
{
|
||||
"success": false,
|
||||
"errors": "Описание ошибки"
|
||||
}
|
||||
```
|
||||
|
||||
или (при использовании глобального обработчика ошибок):
|
||||
|
||||
```json
|
||||
{
|
||||
"message": "Описание ошибки"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Коды состояния HTTP
|
||||
|
||||
| Код | Описание |
|
||||
|-----|----------|
|
||||
| **200 OK** | Запрос выполнен успешно |
|
||||
| **400 Bad Request** | Некорректные данные в запросе |
|
||||
| **401 Unauthorized** | Требуется авторизация или токен невалидный |
|
||||
| **500 Internal Server Error** | Внутренняя ошибка сервера |
|
||||
|
||||
---
|
||||
|
||||
## Примеры интеграции
|
||||
|
||||
### React + Axios
|
||||
|
||||
```javascript
|
||||
import axios from 'axios';
|
||||
|
||||
const API_BASE_URL = 'http://localhost:8044/smoke-tracker';
|
||||
|
||||
// Создание экземпляра axios с базовыми настройками
|
||||
const api = axios.create({
|
||||
baseURL: API_BASE_URL,
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
|
||||
// Интерцептор для добавления токена
|
||||
api.interceptors.request.use(config => {
|
||||
const token = localStorage.getItem('smokeToken');
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
return config;
|
||||
});
|
||||
|
||||
// Регистрация
|
||||
export const signup = async (login, password) => {
|
||||
const { data } = await api.post('/auth/signup', { login, password });
|
||||
return data;
|
||||
};
|
||||
|
||||
// Вход
|
||||
export const signin = async (login, password) => {
|
||||
const { data } = await api.post('/auth/signin', { login, password });
|
||||
if (data.success) {
|
||||
localStorage.setItem('smokeToken', data.body.token);
|
||||
}
|
||||
return data;
|
||||
};
|
||||
|
||||
// Выход
|
||||
export const signout = () => {
|
||||
localStorage.removeItem('smokeToken');
|
||||
};
|
||||
|
||||
// Записать сигарету
|
||||
export const logCigarette = async (smokedAt = null, note = '') => {
|
||||
const { data } = await api.post('/cigarettes', { smokedAt, note });
|
||||
return data;
|
||||
};
|
||||
|
||||
// Получить список сигарет
|
||||
export const getCigarettes = async (from = null, to = null) => {
|
||||
const params = {};
|
||||
if (from) params.from = from;
|
||||
if (to) params.to = to;
|
||||
|
||||
const { data } = await api.get('/cigarettes', { params });
|
||||
return data;
|
||||
};
|
||||
|
||||
// Получить дневную статистику
|
||||
export const getDailyStats = async (from = null, to = null) => {
|
||||
const params = {};
|
||||
if (from) params.from = from;
|
||||
if (to) params.to = to;
|
||||
|
||||
const { data } = await api.get('/stats/daily', { params });
|
||||
return data;
|
||||
};
|
||||
```
|
||||
|
||||
### Vanilla JavaScript + Fetch
|
||||
|
||||
```javascript
|
||||
const API_BASE_URL = 'http://localhost:8044/smoke-tracker';
|
||||
|
||||
// Получение токена
|
||||
const getToken = () => localStorage.getItem('smokeToken');
|
||||
|
||||
// Базовый запрос
|
||||
const apiRequest = async (endpoint, options = {}) => {
|
||||
const token = getToken();
|
||||
|
||||
const headers = {
|
||||
'Content-Type': 'application/json',
|
||||
...options.headers
|
||||
};
|
||||
|
||||
if (token) {
|
||||
headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
const response = await fetch(`${API_BASE_URL}${endpoint}`, {
|
||||
...options,
|
||||
headers
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.message || error.errors || 'Ошибка запроса');
|
||||
}
|
||||
|
||||
return response.json();
|
||||
};
|
||||
|
||||
// Регистрация
|
||||
async function signup(login, password) {
|
||||
return apiRequest('/auth/signup', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ login, password })
|
||||
});
|
||||
}
|
||||
|
||||
// Вход
|
||||
async function signin(login, password) {
|
||||
const data = await apiRequest('/auth/signin', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ login, password })
|
||||
});
|
||||
|
||||
if (data.success) {
|
||||
localStorage.setItem('smokeToken', data.body.token);
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
// Записать сигарету
|
||||
async function logCigarette(note = '') {
|
||||
return apiRequest('/cigarettes', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ note })
|
||||
});
|
||||
}
|
||||
|
||||
// Получить дневную статистику
|
||||
async function getDailyStats() {
|
||||
return apiRequest('/stats/daily');
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Рекомендации по безопасности
|
||||
|
||||
1. **Хранение токена**:
|
||||
- Для веб-приложений: используйте `httpOnly` cookies или `sessionStorage`
|
||||
- Избегайте `localStorage` при работе с чувствительными данными
|
||||
- Для мобильных приложений: используйте безопасное хранилище (Keychain/Keystore)
|
||||
|
||||
2. **HTTPS**: В production всегда используйте HTTPS для защиты токена при передаче
|
||||
|
||||
3. **Обработка истечения токена**:
|
||||
- Токен действителен 12 часов
|
||||
- При получении ошибки 401 перенаправляйте пользователя на страницу входа
|
||||
- Реализуйте механизм refresh token для бесшовного обновления
|
||||
|
||||
4. **Валидация на фронтенде**:
|
||||
- Проверяйте корректность email/логина перед отправкой
|
||||
- Требуйте минимальную длину пароля (8+ символов)
|
||||
- Показывайте индикатор силы пароля
|
||||
|
||||
---
|
||||
|
||||
## Postman-коллекция
|
||||
|
||||
Готовая коллекция для тестирования доступна в файле:
|
||||
|
||||
```
|
||||
server/routers/smoke-tracker/postman/smoke-tracker.postman_collection.json
|
||||
```
|
||||
|
||||
Импортируйте её в Postman для быстрого тестирования всех эндпоинтов.
|
||||
|
||||
---
|
||||
|
||||
## Поддержка
|
||||
|
||||
При возникновении вопросов или обнаружении проблем обращайтесь к разработчикам backend-команды.
|
||||
|
||||
87
server/routers/smoke-tracker/auth.js
Normal file
87
server/routers/smoke-tracker/auth.js
Normal file
@@ -0,0 +1,87 @@
|
||||
const { Router } = require('express')
|
||||
const hash = require('pbkdf2-password')()
|
||||
const { promisify } = require('node:util')
|
||||
const jwt = require('jsonwebtoken')
|
||||
|
||||
const { getAnswer } = require('../../utils/common')
|
||||
|
||||
const { SmokeAuthModel } = require('./model/auth')
|
||||
const { SmokeUserModel } = require('./model/user')
|
||||
const { SMOKE_TRACKER_TOKEN_KEY } = require('./const')
|
||||
const { requiredValidate } = require('./utils')
|
||||
|
||||
const router = Router()
|
||||
|
||||
router.post(
|
||||
'/signup',
|
||||
requiredValidate('login', 'password'),
|
||||
async (req, res, next) => {
|
||||
const { login, password } = req.body
|
||||
|
||||
const existing = await SmokeAuthModel.findOne({ login })
|
||||
|
||||
if (existing) {
|
||||
throw new Error('Пользователь с таким логином уже существует')
|
||||
}
|
||||
|
||||
hash({ password }, async function (err, pass, salt, hashValue) {
|
||||
if (err) return next(err)
|
||||
|
||||
const user = await SmokeUserModel.create({ login })
|
||||
await SmokeAuthModel.create({ login, hash: hashValue, salt, userId: user.id })
|
||||
|
||||
res.json(getAnswer(null, { ok: true }))
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
function authenticate(login, pass, cb) {
|
||||
SmokeAuthModel.findOne({ login })
|
||||
.populate('userId')
|
||||
.exec()
|
||||
.then((user) => {
|
||||
if (!user) return cb(null, null)
|
||||
|
||||
hash({ password: pass, salt: user.salt }, function (err, pass, salt, hashValue) {
|
||||
if (err) return cb(err)
|
||||
if (hashValue === user.hash) return cb(null, user)
|
||||
cb(null, null)
|
||||
})
|
||||
})
|
||||
.catch((err) => cb(err))
|
||||
}
|
||||
|
||||
const auth = promisify(authenticate)
|
||||
|
||||
router.post(
|
||||
'/signin',
|
||||
requiredValidate('login', 'password'),
|
||||
async (req, res) => {
|
||||
const { login, password } = req.body
|
||||
|
||||
const user = await auth(login, password)
|
||||
|
||||
if (!user) {
|
||||
throw new Error('Неверный логин или пароль')
|
||||
}
|
||||
|
||||
const accessToken = jwt.sign(
|
||||
{
|
||||
...JSON.parse(JSON.stringify(user.userId)),
|
||||
},
|
||||
SMOKE_TRACKER_TOKEN_KEY
|
||||
// Для этого проекта токен делаем бессрочным (без поля expiresIn)
|
||||
)
|
||||
|
||||
res.json(
|
||||
getAnswer(null, {
|
||||
user: user.userId,
|
||||
token: accessToken,
|
||||
})
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
module.exports = router
|
||||
|
||||
|
||||
76
server/routers/smoke-tracker/cigarettes.js
Normal file
76
server/routers/smoke-tracker/cigarettes.js
Normal file
@@ -0,0 +1,76 @@
|
||||
const { Router } = require('express')
|
||||
const mongoose = require('mongoose')
|
||||
|
||||
const { getAnswer } = require('../../utils/common')
|
||||
const { CigaretteModel } = require('./model/cigarette')
|
||||
const { authMiddleware } = require('./middleware/auth')
|
||||
|
||||
const router = Router()
|
||||
|
||||
// Все эндпоинты ниже требуют авторизации
|
||||
router.use(authMiddleware)
|
||||
|
||||
// Логирование одной сигареты
|
||||
router.post('/', async (req, res, next) => {
|
||||
try {
|
||||
const { smokedAt, note } = req.body || {}
|
||||
const user = req.user
|
||||
|
||||
let date
|
||||
if (smokedAt) {
|
||||
const parsed = new Date(smokedAt)
|
||||
if (Number.isNaN(parsed.getTime())) {
|
||||
throw new Error('Некорректный формат даты smokedAt')
|
||||
}
|
||||
date = parsed
|
||||
} else {
|
||||
date = new Date()
|
||||
}
|
||||
|
||||
const item = await CigaretteModel.create({
|
||||
userId: new mongoose.Types.ObjectId(user.id),
|
||||
smokedAt: date,
|
||||
note,
|
||||
})
|
||||
|
||||
res.json(getAnswer(null, item))
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
// Получение списка сигарет пользователя (для отладки и таблиц)
|
||||
router.get('/', async (req, res, next) => {
|
||||
try {
|
||||
const user = req.user
|
||||
const { from, to } = req.query
|
||||
|
||||
const filter = { userId: new mongoose.Types.ObjectId(user.id) }
|
||||
|
||||
if (from || to) {
|
||||
filter.smokedAt = {}
|
||||
if (from) {
|
||||
const fromDate = new Date(from)
|
||||
if (!Number.isNaN(fromDate.getTime())) {
|
||||
filter.smokedAt.$gte = fromDate
|
||||
}
|
||||
}
|
||||
if (to) {
|
||||
const toDate = new Date(to)
|
||||
if (!Number.isNaN(toDate.getTime())) {
|
||||
filter.smokedAt.$lte = toDate
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const items = await CigaretteModel.find(filter).sort({ smokedAt: 1 })
|
||||
|
||||
res.json(getAnswer(null, items))
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
module.exports = router
|
||||
|
||||
|
||||
9
server/routers/smoke-tracker/const.js
Normal file
9
server/routers/smoke-tracker/const.js
Normal file
@@ -0,0 +1,9 @@
|
||||
exports.SMOKE_TRACKER_USER_MODEL_NAME = 'SMOKE_TRACKER_USER'
|
||||
exports.SMOKE_TRACKER_AUTH_MODEL_NAME = 'SMOKE_TRACKER_AUTH'
|
||||
exports.SMOKE_TRACKER_CIGARETTE_MODEL_NAME = 'SMOKE_TRACKER_CIGARETTE'
|
||||
|
||||
exports.SMOKE_TRACKER_TOKEN_KEY =
|
||||
process.env.SMOKE_TRACKER_TOKEN_KEY ||
|
||||
'smoke-tracker-secret-key-change-me'
|
||||
|
||||
|
||||
13
server/routers/smoke-tracker/index.js
Normal file
13
server/routers/smoke-tracker/index.js
Normal file
@@ -0,0 +1,13 @@
|
||||
const router = require('express').Router()
|
||||
|
||||
const authRouter = require('./auth')
|
||||
const cigarettesRouter = require('./cigarettes')
|
||||
const statsRouter = require('./stats')
|
||||
|
||||
router.use('/auth', authRouter)
|
||||
router.use('/cigarettes', cigarettesRouter)
|
||||
router.use('/stats', statsRouter)
|
||||
|
||||
module.exports = router
|
||||
|
||||
|
||||
26
server/routers/smoke-tracker/middleware/auth.js
Normal file
26
server/routers/smoke-tracker/middleware/auth.js
Normal file
@@ -0,0 +1,26 @@
|
||||
const jwt = require('jsonwebtoken')
|
||||
|
||||
const { SMOKE_TRACKER_TOKEN_KEY } = require('../const')
|
||||
|
||||
const authMiddleware = (req, res, next) => {
|
||||
const authHeader = req.headers.authorization || ''
|
||||
const token = authHeader.startsWith('Bearer ')
|
||||
? authHeader.slice(7)
|
||||
: null
|
||||
|
||||
if (!token) {
|
||||
throw new Error('Требуется авторизация')
|
||||
}
|
||||
|
||||
try {
|
||||
const decoded = jwt.verify(token, SMOKE_TRACKER_TOKEN_KEY)
|
||||
req.user = decoded
|
||||
next()
|
||||
} catch (e) {
|
||||
throw new Error('Неверный или истекший токен авторизации')
|
||||
}
|
||||
}
|
||||
|
||||
module.exports.authMiddleware = authMiddleware
|
||||
|
||||
|
||||
33
server/routers/smoke-tracker/model/auth.js
Normal file
33
server/routers/smoke-tracker/model/auth.js
Normal file
@@ -0,0 +1,33 @@
|
||||
const { Schema, model } = require('mongoose')
|
||||
|
||||
const {
|
||||
SMOKE_TRACKER_AUTH_MODEL_NAME,
|
||||
SMOKE_TRACKER_USER_MODEL_NAME,
|
||||
} = require('../const')
|
||||
|
||||
const schema = new Schema({
|
||||
login: { type: String, required: true, unique: true },
|
||||
hash: { type: String, required: true },
|
||||
salt: { type: String, required: true },
|
||||
userId: { type: Schema.Types.ObjectId, ref: SMOKE_TRACKER_USER_MODEL_NAME },
|
||||
created: {
|
||||
type: Date,
|
||||
default: () => new Date().toISOString(),
|
||||
},
|
||||
})
|
||||
|
||||
schema.set('toJSON', {
|
||||
virtuals: true,
|
||||
versionKey: false,
|
||||
transform: function (doc, ret) {
|
||||
delete ret._id
|
||||
},
|
||||
})
|
||||
|
||||
schema.virtual('id').get(function () {
|
||||
return this._id.toHexString()
|
||||
})
|
||||
|
||||
exports.SmokeAuthModel = model(SMOKE_TRACKER_AUTH_MODEL_NAME, schema)
|
||||
|
||||
|
||||
38
server/routers/smoke-tracker/model/cigarette.js
Normal file
38
server/routers/smoke-tracker/model/cigarette.js
Normal file
@@ -0,0 +1,38 @@
|
||||
const { Schema, model } = require('mongoose')
|
||||
|
||||
const {
|
||||
SMOKE_TRACKER_CIGARETTE_MODEL_NAME,
|
||||
SMOKE_TRACKER_USER_MODEL_NAME,
|
||||
} = require('../const')
|
||||
|
||||
const schema = new Schema({
|
||||
userId: { type: Schema.Types.ObjectId, ref: SMOKE_TRACKER_USER_MODEL_NAME, required: true },
|
||||
smokedAt: {
|
||||
type: Date,
|
||||
required: true,
|
||||
default: () => new Date().toISOString(),
|
||||
},
|
||||
note: {
|
||||
type: String,
|
||||
},
|
||||
created: {
|
||||
type: Date,
|
||||
default: () => new Date().toISOString(),
|
||||
},
|
||||
})
|
||||
|
||||
schema.set('toJSON', {
|
||||
virtuals: true,
|
||||
versionKey: false,
|
||||
transform: function (doc, ret) {
|
||||
delete ret._id
|
||||
},
|
||||
})
|
||||
|
||||
schema.virtual('id').get(function () {
|
||||
return this._id.toHexString()
|
||||
})
|
||||
|
||||
exports.CigaretteModel = model(SMOKE_TRACKER_CIGARETTE_MODEL_NAME, schema)
|
||||
|
||||
|
||||
27
server/routers/smoke-tracker/model/user.js
Normal file
27
server/routers/smoke-tracker/model/user.js
Normal file
@@ -0,0 +1,27 @@
|
||||
const { Schema, model } = require('mongoose')
|
||||
|
||||
const { SMOKE_TRACKER_USER_MODEL_NAME } = require('../const')
|
||||
|
||||
const schema = new Schema({
|
||||
login: { type: String, required: true, unique: true },
|
||||
created: {
|
||||
type: Date,
|
||||
default: () => new Date().toISOString(),
|
||||
},
|
||||
})
|
||||
|
||||
schema.set('toJSON', {
|
||||
virtuals: true,
|
||||
versionKey: false,
|
||||
transform: function (doc, ret) {
|
||||
delete ret._id
|
||||
},
|
||||
})
|
||||
|
||||
schema.virtual('id').get(function () {
|
||||
return this._id.toHexString()
|
||||
})
|
||||
|
||||
exports.SmokeUserModel = model(SMOKE_TRACKER_USER_MODEL_NAME, schema)
|
||||
|
||||
|
||||
@@ -0,0 +1,244 @@
|
||||
{
|
||||
"info": {
|
||||
"_postman_id": "9d74101d-f788-4dbf-83b3-11c8f9789b73",
|
||||
"name": "Smoke Tracker",
|
||||
"schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json",
|
||||
"_exporter_id": "smoke-tracker"
|
||||
},
|
||||
"item": [
|
||||
{
|
||||
"name": "Auth • Signup",
|
||||
"request": {
|
||||
"method": "POST",
|
||||
"header": [
|
||||
{
|
||||
"key": "Content-Type",
|
||||
"name": "Content-Type",
|
||||
"value": "application/json",
|
||||
"type": "text"
|
||||
}
|
||||
],
|
||||
"body": {
|
||||
"mode": "raw",
|
||||
"raw": "{\n \"login\": \"smoker-demo\",\n \"password\": \"secret123\"\n}"
|
||||
},
|
||||
"url": {
|
||||
"raw": "{{baseUrl}}/smoke-tracker/auth/signup",
|
||||
"host": [
|
||||
"{{baseUrl}}"
|
||||
],
|
||||
"path": [
|
||||
"smoke-tracker",
|
||||
"auth",
|
||||
"signup"
|
||||
]
|
||||
},
|
||||
"description": "Регистрация нового пользователя. Повторный вызов с тем же логином вернёт ошибку."
|
||||
},
|
||||
"response": []
|
||||
},
|
||||
{
|
||||
"name": "Auth • Signin",
|
||||
"event": [
|
||||
{
|
||||
"listen": "test",
|
||||
"script": {
|
||||
"type": "text/javascript",
|
||||
"exec": [
|
||||
"const json = pm.response.json();",
|
||||
"if (json && json.body && json.body.token) {",
|
||||
" pm.environment.set('smokeToken', json.body.token);",
|
||||
"}"
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
"request": {
|
||||
"method": "POST",
|
||||
"header": [
|
||||
{
|
||||
"key": "Content-Type",
|
||||
"name": "Content-Type",
|
||||
"value": "application/json",
|
||||
"type": "text"
|
||||
}
|
||||
],
|
||||
"body": {
|
||||
"mode": "raw",
|
||||
"raw": "{\n \"login\": \"smoker-demo\",\n \"password\": \"secret123\"\n}"
|
||||
},
|
||||
"url": {
|
||||
"raw": "{{baseUrl}}/smoke-tracker/auth/signin",
|
||||
"host": [
|
||||
"{{baseUrl}}"
|
||||
],
|
||||
"path": [
|
||||
"smoke-tracker",
|
||||
"auth",
|
||||
"signin"
|
||||
]
|
||||
},
|
||||
"description": "Авторизация пользователя. Скрипт тестов сохранит JWT в переменную окружения smokeToken."
|
||||
},
|
||||
"response": []
|
||||
},
|
||||
{
|
||||
"name": "Cigarettes • Log entry",
|
||||
"request": {
|
||||
"method": "POST",
|
||||
"header": [
|
||||
{
|
||||
"key": "Content-Type",
|
||||
"name": "Content-Type",
|
||||
"value": "application/json",
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"key": "Authorization",
|
||||
"name": "Authorization",
|
||||
"value": "Bearer {{smokeToken}}",
|
||||
"type": "text"
|
||||
}
|
||||
],
|
||||
"body": {
|
||||
"mode": "raw",
|
||||
"raw": "{\n \"smokedAt\": \"2025-01-01T09:30:00.000Z\",\n \"note\": \"Первая сигарета за день\"\n}"
|
||||
},
|
||||
"url": {
|
||||
"raw": "{{baseUrl}}/smoke-tracker/cigarettes",
|
||||
"host": [
|
||||
"{{baseUrl}}"
|
||||
],
|
||||
"path": [
|
||||
"smoke-tracker",
|
||||
"cigarettes"
|
||||
]
|
||||
},
|
||||
"description": "Создать запись о выкуренной сигарете. Если smokedAt не указан, сервер использует текущее время."
|
||||
},
|
||||
"response": []
|
||||
},
|
||||
{
|
||||
"name": "Cigarettes • List",
|
||||
"request": {
|
||||
"method": "GET",
|
||||
"header": [
|
||||
{
|
||||
"key": "Authorization",
|
||||
"name": "Authorization",
|
||||
"value": "Bearer {{smokeToken}}",
|
||||
"type": "text"
|
||||
}
|
||||
],
|
||||
"url": {
|
||||
"raw": "{{baseUrl}}/smoke-tracker/cigarettes?from=2025-01-01T00:00:00.000Z&to=2025-01-07T23:59:59.999Z",
|
||||
"host": [
|
||||
"{{baseUrl}}"
|
||||
],
|
||||
"path": [
|
||||
"smoke-tracker",
|
||||
"cigarettes"
|
||||
],
|
||||
"query": [
|
||||
{
|
||||
"key": "from",
|
||||
"value": "2025-01-01T00:00:00.000Z"
|
||||
},
|
||||
{
|
||||
"key": "to",
|
||||
"value": "2025-01-07T23:59:59.999Z"
|
||||
}
|
||||
]
|
||||
},
|
||||
"description": "Список сигарет текущего пользователя. Параметры from/to необязательны."
|
||||
},
|
||||
"response": []
|
||||
},
|
||||
{
|
||||
"name": "Stats • Daily",
|
||||
"request": {
|
||||
"method": "GET",
|
||||
"header": [
|
||||
{
|
||||
"key": "Authorization",
|
||||
"name": "Authorization",
|
||||
"value": "Bearer {{smokeToken}}",
|
||||
"type": "text"
|
||||
}
|
||||
],
|
||||
"url": {
|
||||
"raw": "{{baseUrl}}/smoke-tracker/stats/daily?from=2025-01-01&to=2025-01-31",
|
||||
"host": [
|
||||
"{{baseUrl}}"
|
||||
],
|
||||
"path": [
|
||||
"smoke-tracker",
|
||||
"stats",
|
||||
"daily"
|
||||
],
|
||||
"query": [
|
||||
{
|
||||
"key": "from",
|
||||
"value": "2025-01-01"
|
||||
},
|
||||
{
|
||||
"key": "to",
|
||||
"value": "2025-01-31"
|
||||
}
|
||||
]
|
||||
},
|
||||
"description": "Агрегация по дням для графиков. Если from/to не заданы, используется последний месяц."
|
||||
},
|
||||
"response": []
|
||||
},
|
||||
{
|
||||
"name": "Stats • Summary",
|
||||
"request": {
|
||||
"method": "GET",
|
||||
"header": [
|
||||
{
|
||||
"key": "Authorization",
|
||||
"name": "Authorization",
|
||||
"value": "Bearer {{smokeToken}}",
|
||||
"type": "text"
|
||||
}
|
||||
],
|
||||
"url": {
|
||||
"raw": "{{baseUrl}}/smoke-tracker/stats/summary?from=2025-01-01T00:00:00.000Z&to=2025-01-31T23:59:59.999Z",
|
||||
"host": [
|
||||
"{{baseUrl}}"
|
||||
],
|
||||
"path": [
|
||||
"smoke-tracker",
|
||||
"stats",
|
||||
"summary"
|
||||
],
|
||||
"query": [
|
||||
{
|
||||
"key": "from",
|
||||
"value": "2025-01-01T00:00:00.000Z"
|
||||
},
|
||||
{
|
||||
"key": "to",
|
||||
"value": "2025-01-31T23:59:59.999Z"
|
||||
}
|
||||
]
|
||||
},
|
||||
"description": "Расширенная статистика: среднее в день, статистика по дням недели, сравнение с общими показателями всех пользователей."
|
||||
},
|
||||
"response": []
|
||||
}
|
||||
],
|
||||
"event": [],
|
||||
"variable": [
|
||||
{
|
||||
"key": "baseUrl",
|
||||
"value": "http://localhost:8044"
|
||||
},
|
||||
{
|
||||
"key": "smokeToken",
|
||||
"value": ""
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user