Merge pull request 'feature/worker' (#111) from feature/worker into master
Some checks failed
Code Quality Checks / lint-and-typecheck (push) Failing after 6m11s
Some checks failed
Code Quality Checks / lint-and-typecheck (push) Failing after 6m11s
Reviewed-on: #111
This commit was merged in pull request #111.
This commit is contained in:
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
|
||||
28
.gitea/workflows/check.yaml
Normal file
28
.gitea/workflows/check.yaml
Normal file
@@ -0,0 +1,28 @@
|
||||
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 -- --quiet
|
||||
|
||||
- name: Run TypeScript type check
|
||||
run: npx tsc --noEmit
|
||||
|
||||
- name: Run tests
|
||||
run: npm test -- --quiet
|
||||
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();
|
||||
|
||||
@@ -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
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": ""
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
279
server/routers/smoke-tracker/stats.js
Normal file
279
server/routers/smoke-tracker/stats.js
Normal file
@@ -0,0 +1,279 @@
|
||||
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.get('/daily', async (req, res, next) => {
|
||||
try {
|
||||
const user = req.user
|
||||
const { from, to } = req.query
|
||||
|
||||
const now = new Date()
|
||||
const defaultFrom = new Date(now)
|
||||
defaultFrom.setDate(defaultFrom.getDate() - 30)
|
||||
|
||||
const fromDate = from ? new Date(from) : defaultFrom
|
||||
const toDate = to ? new Date(to) : now
|
||||
|
||||
const match = {
|
||||
userId: new mongoose.Types.ObjectId(user.id),
|
||||
smokedAt: {
|
||||
$gte: fromDate,
|
||||
$lte: toDate,
|
||||
},
|
||||
}
|
||||
|
||||
// Отладка: проверяем, сколько записей попадает в фильтр
|
||||
const totalCount = await CigaretteModel.countDocuments(match)
|
||||
console.log('[STATS] Match filter:', JSON.stringify(match, null, 2))
|
||||
console.log('[STATS] Total cigarettes in range:', totalCount)
|
||||
|
||||
const data = await CigaretteModel.aggregate([
|
||||
{ $match: match },
|
||||
{
|
||||
$group: {
|
||||
_id: {
|
||||
$dateToString: { format: '%Y-%m-%d', date: '$smokedAt', timezone: 'UTC' },
|
||||
},
|
||||
count: { $sum: 1 },
|
||||
},
|
||||
},
|
||||
{ $sort: { _id: 1 } },
|
||||
])
|
||||
|
||||
console.log('[STATS] Aggregation result:', data)
|
||||
|
||||
const result = data.map((item) => ({
|
||||
date: item._id,
|
||||
count: item.count,
|
||||
}))
|
||||
|
||||
res.json(getAnswer(null, result))
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
// Сводная статистика: среднее в день, по дням недели, общее по всем пользователям
|
||||
router.get('/summary', async (req, res, next) => {
|
||||
try {
|
||||
const user = req.user
|
||||
const { from, to } = req.query
|
||||
|
||||
const now = new Date()
|
||||
const defaultFrom = new Date(now)
|
||||
defaultFrom.setDate(defaultFrom.getDate() - 30)
|
||||
|
||||
const fromDate = from ? new Date(from) : defaultFrom
|
||||
const toDate = to ? new Date(to) : now
|
||||
|
||||
// Фильтр для текущего пользователя
|
||||
const userMatch = {
|
||||
userId: new mongoose.Types.ObjectId(user.id),
|
||||
smokedAt: {
|
||||
$gte: fromDate,
|
||||
$lte: toDate,
|
||||
},
|
||||
}
|
||||
|
||||
// Фильтр для всех пользователей (общая статистика)
|
||||
const globalMatch = {
|
||||
smokedAt: {
|
||||
$gte: fromDate,
|
||||
$lte: toDate,
|
||||
},
|
||||
}
|
||||
|
||||
// 1. Статистика по дням (для текущего пользователя)
|
||||
const dailyStats = await CigaretteModel.aggregate([
|
||||
{ $match: userMatch },
|
||||
{
|
||||
$group: {
|
||||
_id: {
|
||||
$dateToString: { format: '%Y-%m-%d', date: '$smokedAt', timezone: 'UTC' },
|
||||
},
|
||||
count: { $sum: 1 },
|
||||
},
|
||||
},
|
||||
{ $sort: { _id: 1 } },
|
||||
])
|
||||
|
||||
const dailyData = dailyStats.map((item) => ({
|
||||
date: item._id,
|
||||
count: item.count,
|
||||
}))
|
||||
|
||||
// 2. Среднее количество в день (для текущего пользователя)
|
||||
const totalCigarettes = dailyStats.reduce((sum, item) => sum + item.count, 0)
|
||||
const daysWithData = dailyStats.length
|
||||
const averagePerDay = daysWithData > 0 ? (totalCigarettes / daysWithData).toFixed(2) : 0
|
||||
|
||||
// 3. Статистика по дням недели (для текущего пользователя)
|
||||
const weekdayStats = await CigaretteModel.aggregate([
|
||||
{ $match: userMatch },
|
||||
{
|
||||
$group: {
|
||||
_id: { $dayOfWeek: '$smokedAt' }, // 1 = воскресенье, 2 = понедельник, ..., 7 = суббота
|
||||
count: { $sum: 1 },
|
||||
},
|
||||
},
|
||||
{ $sort: { _id: 1 } },
|
||||
])
|
||||
|
||||
const weekdayNames = ['Воскресенье', 'Понедельник', 'Вторник', 'Среда', 'Четверг', 'Пятница', 'Суббота']
|
||||
const weekdayData = weekdayStats.map((item) => {
|
||||
const dayIndex = item._id - 1 // MongoDB возвращает 1-7, приводим к 0-6
|
||||
return {
|
||||
dayOfWeek: item._id,
|
||||
dayName: weekdayNames[dayIndex],
|
||||
count: item.count,
|
||||
}
|
||||
})
|
||||
|
||||
// Вычисляем среднее для каждого дня недели
|
||||
const weekdayAverages = weekdayData.map((day) => {
|
||||
// Считаем, сколько раз встречается этот день недели в периоде
|
||||
const occurrences = Math.floor(
|
||||
(toDate.getTime() - fromDate.getTime()) / (1000 * 60 * 60 * 24 * 7)
|
||||
) + 1
|
||||
return {
|
||||
...day,
|
||||
average: occurrences > 0 ? (day.count / occurrences).toFixed(2) : day.count,
|
||||
}
|
||||
})
|
||||
|
||||
// 4. Определяем активных пользователей (от 2 до 40 сигарет в день в среднем)
|
||||
const MIN_CIGARETTES_PER_DAY = 2
|
||||
const MAX_CIGARETTES_PER_DAY = 40
|
||||
const periodDays = Math.ceil((toDate.getTime() - fromDate.getTime()) / (1000 * 60 * 60 * 24))
|
||||
|
||||
// Получаем статистику по каждому пользователю
|
||||
const userStats = await CigaretteModel.aggregate([
|
||||
{ $match: globalMatch },
|
||||
{
|
||||
$group: {
|
||||
_id: '$userId',
|
||||
total: { $sum: 1 },
|
||||
},
|
||||
},
|
||||
])
|
||||
|
||||
// Фильтруем активных пользователей (исключаем слишком низкие и слишком высокие значения)
|
||||
const activeUserIds = userStats
|
||||
.filter((stat) => {
|
||||
const avgPerDay = stat.total / periodDays
|
||||
return avgPerDay > MIN_CIGARETTES_PER_DAY && avgPerDay <= MAX_CIGARETTES_PER_DAY
|
||||
})
|
||||
.map((stat) => stat._id)
|
||||
|
||||
const filteredLow = userStats.filter((stat) => stat.total / periodDays <= MIN_CIGARETTES_PER_DAY).length
|
||||
const filteredHigh = userStats.filter((stat) => stat.total / periodDays > MAX_CIGARETTES_PER_DAY).length
|
||||
|
||||
console.log('[STATS] Total users:', userStats.length)
|
||||
console.log('[STATS] Active users (2-40 cigs/day):', activeUserIds.length)
|
||||
console.log('[STATS] Filtered out (too low):', filteredLow)
|
||||
console.log('[STATS] Filtered out (too high):', filteredHigh)
|
||||
|
||||
// Фильтр только для активных пользователей
|
||||
const activeGlobalMatch = {
|
||||
...globalMatch,
|
||||
userId: { $in: activeUserIds },
|
||||
}
|
||||
|
||||
// Общая статистика по активным пользователям
|
||||
const globalDailyStats = await CigaretteModel.aggregate([
|
||||
{ $match: activeGlobalMatch },
|
||||
{
|
||||
$group: {
|
||||
_id: {
|
||||
$dateToString: { format: '%Y-%m-%d', date: '$smokedAt', timezone: 'UTC' },
|
||||
},
|
||||
count: { $sum: 1 },
|
||||
},
|
||||
},
|
||||
{ $sort: { _id: 1 } },
|
||||
])
|
||||
|
||||
const globalDailyData = globalDailyStats.map((item) => ({
|
||||
date: item._id,
|
||||
count: item.count,
|
||||
}))
|
||||
|
||||
const globalTotalCigarettes = globalDailyStats.reduce((sum, item) => sum + item.count, 0)
|
||||
const globalDaysWithData = globalDailyStats.length
|
||||
const globalAveragePerDay =
|
||||
globalDaysWithData > 0 ? (globalTotalCigarettes / globalDaysWithData).toFixed(2) : 0
|
||||
|
||||
// Общая статистика по дням недели (активные пользователи)
|
||||
const globalWeekdayStats = await CigaretteModel.aggregate([
|
||||
{ $match: activeGlobalMatch },
|
||||
{
|
||||
$group: {
|
||||
_id: { $dayOfWeek: '$smokedAt' },
|
||||
count: { $sum: 1 },
|
||||
},
|
||||
},
|
||||
{ $sort: { _id: 1 } },
|
||||
])
|
||||
|
||||
const globalWeekdayData = globalWeekdayStats.map((item) => {
|
||||
const dayIndex = item._id - 1
|
||||
return {
|
||||
dayOfWeek: item._id,
|
||||
dayName: weekdayNames[dayIndex],
|
||||
count: item.count,
|
||||
}
|
||||
})
|
||||
|
||||
const globalWeekdayAverages = globalWeekdayData.map((day) => {
|
||||
const occurrences = Math.floor(
|
||||
(toDate.getTime() - fromDate.getTime()) / (1000 * 60 * 60 * 24 * 7)
|
||||
) + 1
|
||||
return {
|
||||
...day,
|
||||
average: occurrences > 0 ? (day.count / occurrences).toFixed(2) : day.count,
|
||||
}
|
||||
})
|
||||
|
||||
// Количество активных пользователей
|
||||
const activeUsers = activeUserIds
|
||||
|
||||
const result = {
|
||||
user: {
|
||||
daily: dailyData,
|
||||
averagePerDay: parseFloat(averagePerDay),
|
||||
weekday: weekdayAverages,
|
||||
total: totalCigarettes,
|
||||
daysWithData,
|
||||
},
|
||||
global: {
|
||||
daily: globalDailyData,
|
||||
averagePerDay: parseFloat(globalAveragePerDay),
|
||||
weekday: globalWeekdayAverages,
|
||||
total: globalTotalCigarettes,
|
||||
daysWithData: globalDaysWithData,
|
||||
activeUsers: activeUsers.length,
|
||||
},
|
||||
period: {
|
||||
from: fromDate.toISOString(),
|
||||
to: toDate.toISOString(),
|
||||
},
|
||||
}
|
||||
|
||||
res.json(getAnswer(null, result))
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
module.exports = router
|
||||
|
||||
|
||||
21
server/routers/smoke-tracker/utils.js
Normal file
21
server/routers/smoke-tracker/utils.js
Normal file
@@ -0,0 +1,21 @@
|
||||
const requiredValidate =
|
||||
(...fields) =>
|
||||
(req, res, next) => {
|
||||
const errors = []
|
||||
|
||||
fields.forEach((field) => {
|
||||
if (!req.body[field]) {
|
||||
errors.push(field)
|
||||
}
|
||||
})
|
||||
|
||||
if (errors.length) {
|
||||
throw new Error(`Не все поля заполнены: ${errors.join(', ')}`)
|
||||
} else {
|
||||
next()
|
||||
}
|
||||
}
|
||||
|
||||
module.exports.requiredValidate = requiredValidate
|
||||
|
||||
|
||||
1211
server/server.ts
Normal file
1211
server/server.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,4 @@
|
||||
exports.getAnswer = (errors, data, success = true) => {
|
||||
export const getAnswer = (errors, data, success = true) => {
|
||||
if (errors) {
|
||||
return {
|
||||
success: false,
|
||||
@@ -12,7 +12,7 @@ exports.getAnswer = (errors, data, success = true) => {
|
||||
}
|
||||
}
|
||||
|
||||
exports.getResponse = (errors, data, success = true) => {
|
||||
export const getResponse = (errors, data, success = true) => {
|
||||
if (errors.length) {
|
||||
return {
|
||||
success: false,
|
||||
@@ -1,4 +0,0 @@
|
||||
const rc = require('../../.serverrc')
|
||||
|
||||
// Connection URL
|
||||
exports.mongoUrl = `mongodb://${rc.mongoAddr}:${rc.mongoPort}`
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user