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/
|
RUN mkdir -p /usr/src/app/server/log/
|
||||||
WORKDIR /usr/src/app/
|
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.json /usr/src/app/package.json
|
||||||
COPY ./package-lock.json /usr/src/app/package-lock.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
|
EXPOSE 8044
|
||||||
|
|
||||||
CMD ["npm", "run", "up:prod"]
|
CMD ["npm", "run", "up:prod"]
|
||||||
|
|||||||
@@ -1,6 +1,12 @@
|
|||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
|
|
||||||
docker stop ms-mongo
|
docker stop ms-mongo
|
||||||
docker volume remove ms_volume
|
docker volume remove ms_volume8
|
||||||
docker volume create ms_volume
|
docker volume create ms_volume8
|
||||||
docker run --rm -v ms_volume:/data/db --name ms-mongo -p 27017:27017 -d mongo:8.0.3
|
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 [
|
export default [
|
||||||
{ ignores: ['server/routers/old/*'] },
|
{ ignores: ['server/routers/old/*'] },
|
||||||
{ files: ["**/*.js"], languageOptions: { sourceType: "commonjs" } },
|
{ files: ["**/*.js"], languageOptions: { } },
|
||||||
{ languageOptions: { globals: globals.node } },
|
{ languageOptions: { globals: globals.node } },
|
||||||
pluginJs.configs.recommended,
|
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
|
* https://jestjs.io/docs/configuration
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/** @type {import('jest').Config} */
|
/** @type {import('jest').Config} */
|
||||||
const config = {
|
const config = {
|
||||||
// All imported modules in your tests should be mocked automatically
|
// Все импортированные модули в тестах должны быть автоматически замоканы
|
||||||
// automock: false,
|
// automock: false,
|
||||||
|
|
||||||
// Stop running tests after `n` failures
|
// Остановить выполнение тестов после `n` неудач
|
||||||
// bail: 0,
|
// bail: 0,
|
||||||
|
|
||||||
// The directory where Jest should store its cached dependency information
|
// Директория, где Jest должен хранить кэшированную информацию о зависимостях
|
||||||
// cacheDirectory: "C:\\Users\\alex\\AppData\\Local\\Temp\\jest",
|
// cacheDirectory: "C:\\Users\\alex\\AppData\\Local\\Temp\\jest",
|
||||||
|
|
||||||
// Automatically clear mock calls, instances, contexts and results before every test
|
// Автоматически очищать вызовы моков, экземпляры, контексты и результаты перед каждым тестом
|
||||||
clearMocks: true,
|
clearMocks: true,
|
||||||
|
|
||||||
// Indicates whether the coverage information should be collected while executing the test
|
// Указывает, должна ли собираться информация о покрытии во время выполнения тестов
|
||||||
collectCoverage: true,
|
collectCoverage: true,
|
||||||
|
|
||||||
// An array of glob patterns indicating a set of files for which coverage information should be collected
|
// Массив glob-паттернов, указывающих набор файлов, для которых должна собираться информация о покрытии
|
||||||
collectCoverageFrom: [
|
collectCoverageFrom: [
|
||||||
"<rootDir>/server/routers/**/*.js"
|
"<rootDir>/server/routers/**/*.js"
|
||||||
],
|
],
|
||||||
|
|
||||||
// The directory where Jest should output its coverage files
|
// Директория, куда Jest должен выводить файлы покрытия
|
||||||
coverageDirectory: "coverage",
|
coverageDirectory: "coverage",
|
||||||
|
|
||||||
// An array of regexp pattern strings used to skip coverage collection
|
// Массив строк regexp-паттернов, используемых для пропуска сбора покрытия
|
||||||
coveragePathIgnorePatterns: [
|
coveragePathIgnorePatterns: [
|
||||||
"\\\\node_modules\\\\",
|
"\\\\node_modules\\\\",
|
||||||
"<rootDir>/server/routers/old"
|
"<rootDir>/server/routers/old"
|
||||||
],
|
],
|
||||||
|
|
||||||
// Indicates which provider should be used to instrument code for coverage
|
// Указывает, какой провайдер должен использоваться для инструментирования кода для покрытия
|
||||||
coverageProvider: "v8",
|
coverageProvider: "v8",
|
||||||
|
|
||||||
// A list of reporter names that Jest uses when writing coverage reports
|
// Список имен репортеров, которые Jest использует при записи отчетов о покрытии
|
||||||
// coverageReporters: [
|
// coverageReporters: [
|
||||||
// "json",
|
// "json",
|
||||||
// "text",
|
// "text",
|
||||||
@@ -45,156 +45,159 @@ const config = {
|
|||||||
// "clover"
|
// "clover"
|
||||||
// ],
|
// ],
|
||||||
|
|
||||||
// An object that configures minimum threshold enforcement for coverage results
|
// Объект, который настраивает принудительное применение минимальных порогов для результатов покрытия
|
||||||
// coverageThreshold: undefined,
|
// coverageThreshold: undefined,
|
||||||
|
|
||||||
// A path to a custom dependency extractor
|
// Путь к пользовательскому извлекателю зависимостей
|
||||||
// dependencyExtractor: undefined,
|
// dependencyExtractor: undefined,
|
||||||
|
|
||||||
// Make calling deprecated APIs throw helpful error messages
|
// Заставить вызовы устаревших API выбрасывать полезные сообщения об ошибках
|
||||||
// errorOnDeprecated: false,
|
// errorOnDeprecated: false,
|
||||||
|
|
||||||
// The default configuration for fake timers
|
// Конфигурация по умолчанию для поддельных таймеров
|
||||||
// fakeTimers: {
|
// fakeTimers: {
|
||||||
// "enableGlobally": false
|
// "enableGlobally": false
|
||||||
// },
|
// },
|
||||||
|
|
||||||
// Force coverage collection from ignored files using an array of glob patterns
|
// Принудительно собирать покрытие из игнорируемых файлов, используя массив glob-паттернов
|
||||||
// forceCoverageMatch: [],
|
// forceCoverageMatch: [],
|
||||||
|
|
||||||
// A path to a module which exports an async function that is triggered once before all test suites
|
// Путь к модулю, который экспортирует асинхронную функцию, вызываемую один раз перед всеми наборами тестов
|
||||||
// globalSetup: undefined,
|
// globalSetup: undefined,
|
||||||
|
|
||||||
// A path to a module which exports an async function that is triggered once after all test suites
|
// Путь к модулю, который экспортирует асинхронную функцию, вызываемую один раз после всех наборов тестов
|
||||||
// globalTeardown: undefined,
|
// globalTeardown: undefined,
|
||||||
|
|
||||||
// A set of global variables that need to be available in all test environments
|
// Набор глобальных переменных, которые должны быть доступны во всех тестовых окружениях
|
||||||
// globals: {},
|
// 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%",
|
// maxWorkers: "50%",
|
||||||
|
|
||||||
// An array of directory names to be searched recursively up from the requiring module's location
|
// Массив имен директорий, которые должны быть рекурсивно найдены вверх от местоположения требуемого модуля
|
||||||
// moduleDirectories: [
|
// moduleDirectories: [
|
||||||
// "node_modules"
|
// "node_modules"
|
||||||
// ],
|
// ],
|
||||||
|
|
||||||
// An array of file extensions your modules use
|
// Массив расширений файлов, которые используют ваши модули
|
||||||
// moduleFileExtensions: [
|
moduleFileExtensions: [
|
||||||
// "js",
|
"js",
|
||||||
// "mjs",
|
"mjs",
|
||||||
// "cjs",
|
"cjs",
|
||||||
// "jsx",
|
"jsx",
|
||||||
// "ts",
|
"ts",
|
||||||
// "tsx",
|
"tsx",
|
||||||
// "json",
|
"json",
|
||||||
// "node"
|
"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: {},
|
// moduleNameMapper: {},
|
||||||
|
|
||||||
// An array of regexp pattern strings, matched against all module paths before considered 'visible' to the module loader
|
// Массив строк regexp-паттернов, сопоставляемых со всеми путями модулей перед тем, как они будут считаться 'видимыми' для загрузчика модулей
|
||||||
// modulePathIgnorePatterns: [],
|
// modulePathIgnorePatterns: [],
|
||||||
|
|
||||||
// Activates notifications for test results
|
// Активирует уведомления для результатов тестов
|
||||||
// notify: false,
|
// notify: false,
|
||||||
|
|
||||||
// An enum that specifies notification mode. Requires { notify: true }
|
// Перечисление, которое указывает режим уведомлений. Требует { notify: true }
|
||||||
// notifyMode: "failure-change",
|
// notifyMode: "failure-change",
|
||||||
|
|
||||||
// A preset that is used as a base for Jest's configuration
|
// Пресет, который используется в качестве основы для конфигурации Jest
|
||||||
// preset: undefined,
|
preset: 'ts-jest',
|
||||||
|
|
||||||
// Run tests from one or more projects
|
// Запускать тесты из одного или нескольких проектов
|
||||||
// projects: undefined,
|
// projects: undefined,
|
||||||
|
|
||||||
// Use this configuration option to add custom reporters to Jest
|
// Используйте эту опцию конфигурации для добавления пользовательских репортеров в Jest
|
||||||
// reporters: undefined,
|
// reporters: undefined,
|
||||||
|
|
||||||
// Automatically reset mock state before every test
|
// Автоматически сбрасывать состояние моков перед каждым тестом
|
||||||
// resetMocks: false,
|
// resetMocks: false,
|
||||||
|
|
||||||
// Reset the module registry before running each individual test
|
// Сбрасывать реестр модулей перед запуском каждого отдельного теста
|
||||||
// resetModules: false,
|
// resetModules: false,
|
||||||
|
|
||||||
// A path to a custom resolver
|
// Путь к пользовательскому резолверу
|
||||||
// resolver: undefined,
|
// resolver: undefined,
|
||||||
|
|
||||||
// Automatically restore mock state and implementation before every test
|
// Автоматически восстанавливать состояние моков и реализацию перед каждым тестом
|
||||||
// restoreMocks: false,
|
// restoreMocks: false,
|
||||||
|
|
||||||
// The root directory that Jest should scan for tests and modules within
|
// Корневая директория, которую Jest должен сканировать для поиска тестов и модулей
|
||||||
// rootDir: undefined,
|
// rootDir: undefined,
|
||||||
|
|
||||||
// A list of paths to directories that Jest should use to search for files in
|
// Список путей к директориям, которые Jest должен использовать для поиска файлов
|
||||||
// roots: [
|
// roots: [
|
||||||
// "<rootDir>"
|
// "<rootDir>"
|
||||||
// ],
|
// ],
|
||||||
|
|
||||||
// Allows you to use a custom runner instead of Jest's default test runner
|
// Позволяет использовать пользовательский раннер вместо стандартного тестового раннера Jest
|
||||||
// runner: "jest-runner",
|
// runner: "jest-runner",
|
||||||
|
|
||||||
// The paths to modules that run some code to configure or set up the testing environment before each test
|
// Пути к модулям, которые выполняют некоторый код для настройки или подготовки тестового окружения перед каждым тестом
|
||||||
// setupFiles: [],
|
// setupFiles: [],
|
||||||
|
|
||||||
// A list of paths to modules that run some code to configure or set up the testing framework before each test
|
// Список путей к модулям, которые выполняют некоторый код для настройки или подготовки тестового фреймворка перед каждым тестом
|
||||||
// setupFilesAfterEnv: [],
|
// setupFilesAfterEnv: [],
|
||||||
|
|
||||||
// The number of seconds after which a test is considered as slow and reported as such in the results.
|
// Количество секунд, после которого тест считается медленным и сообщается как таковой в результатах.
|
||||||
// slowTestThreshold: 5,
|
// slowTestThreshold: 5,
|
||||||
|
|
||||||
// A list of paths to snapshot serializer modules Jest should use for snapshot testing
|
// Список путей к модулям сериализаторов снимков, которые Jest должен использовать для тестирования снимков
|
||||||
// snapshotSerializers: [],
|
// 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: {},
|
// testEnvironmentOptions: {},
|
||||||
|
|
||||||
// Adds a location field to test results
|
// Добавляет поле местоположения к результатам тестов
|
||||||
// testLocationInResults: false,
|
// testLocationInResults: false,
|
||||||
|
|
||||||
// The glob patterns Jest uses to detect test files
|
// Glob-паттерны, которые Jest использует для обнаружения тестовых файлов
|
||||||
// testMatch: [
|
testMatch: [
|
||||||
// "**/__tests__/**/*.[jt]s?(x)",
|
"**/__tests__/**/*.[jt]s?(x)",
|
||||||
// "**/?(*.)+(spec|test).[tj]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: [
|
// testPathIgnorePatterns: [
|
||||||
// "\\\\node_modules\\\\"
|
// "\\\\node_modules\\\\"
|
||||||
// ],
|
// ],
|
||||||
|
|
||||||
// The regexp pattern or array of patterns that Jest uses to detect test files
|
// Regexp-паттерн или массив паттернов, которые Jest использует для обнаружения тестовых файлов
|
||||||
// testRegex: [],
|
// testRegex: [],
|
||||||
|
|
||||||
// This option allows the use of a custom results processor
|
// Эта опция позволяет использовать пользовательский процессор результатов
|
||||||
// testResultsProcessor: undefined,
|
// testResultsProcessor: undefined,
|
||||||
|
|
||||||
// This option allows use of a custom test runner
|
// Эта опция позволяет использовать пользовательский тестовый раннер
|
||||||
// testRunner: "jest-circus/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: [
|
// transformIgnorePatterns: [
|
||||||
// "\\\\node_modules\\\\",
|
// "\\\\node_modules\\\\",
|
||||||
// "\\.pnp\\.[^\\\\]+$"
|
// "\\.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,
|
// unmockedModulePathPatterns: undefined,
|
||||||
|
|
||||||
// Indicates whether each individual test should be reported during the run
|
// Указывает, должен ли каждый отдельный тест сообщаться во время выполнения
|
||||||
verbose: true,
|
verbose: true,
|
||||||
|
|
||||||
// An array of regexp patterns that are matched against all source file paths before re-running tests in watch mode
|
// Массив regexp-паттернов, которые сопоставляются со всеми путями исходных файлов перед повторным запуском тестов в режиме наблюдения
|
||||||
// watchPathIgnorePatterns: [],
|
// watchPathIgnorePatterns: [],
|
||||||
|
|
||||||
// Whether to use watchman for file crawling
|
// Использовать ли watchman для обхода файлов
|
||||||
// watchman: true,
|
// 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",
|
"name": "multi-stub",
|
||||||
"version": "1.2.1",
|
"version": "2.0.0",
|
||||||
"description": "",
|
"description": "",
|
||||||
"main": "index.js",
|
"main": "server/index.ts",
|
||||||
|
"type": "commonjs",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "cross-env PORT=8033 npx nodemon ./server",
|
"start": "cross-env NODE_ENV=\"development\" ts-node-dev .",
|
||||||
"up:prod": "cross-env NODE_ENV=\"production\" node ./server",
|
"build": "tsc",
|
||||||
"deploy:d:stop": "docker compose down",
|
"up:prod": "node dist/server/index.js",
|
||||||
"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",
|
|
||||||
"eslint": "npx eslint ./server",
|
"eslint": "npx eslint ./server",
|
||||||
"eslint:fix": "npx eslint ./server --fix",
|
"eslint:fix": "npx eslint ./server --fix",
|
||||||
"test": "jest"
|
"test": "jest"
|
||||||
@@ -23,9 +21,13 @@
|
|||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"homepage": "https://bitbucket.org/online-mentor/multi-stub#readme",
|
"homepage": "https://bitbucket.org/online-mentor/multi-stub#readme",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@langchain/community": "^0.3.56",
|
||||||
|
"@langchain/core": "^0.3.77",
|
||||||
|
"@langchain/langgraph": "^0.4.9",
|
||||||
"ai": "^4.1.13",
|
"ai": "^4.1.13",
|
||||||
"axios": "^1.7.7",
|
"axios": "^1.7.7",
|
||||||
"bcrypt": "^5.1.0",
|
"bcrypt": "^5.1.0",
|
||||||
|
"bcryptjs": "^3.0.3",
|
||||||
"body-parser": "^1.19.0",
|
"body-parser": "^1.19.0",
|
||||||
"cookie-parser": "^1.4.5",
|
"cookie-parser": "^1.4.5",
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
@@ -35,26 +37,33 @@
|
|||||||
"express": "5.0.1",
|
"express": "5.0.1",
|
||||||
"express-jwt": "^8.5.1",
|
"express-jwt": "^8.5.1",
|
||||||
"express-session": "^1.18.1",
|
"express-session": "^1.18.1",
|
||||||
|
"gigachat": "^0.0.16",
|
||||||
"jsdom": "^25.0.1",
|
"jsdom": "^25.0.1",
|
||||||
"jsonwebtoken": "^9.0.2",
|
"jsonwebtoken": "^9.0.2",
|
||||||
"mongodb": "^6.12.0",
|
"langchain": "^0.3.34",
|
||||||
"mongoose": "^8.9.2",
|
"langchain-gigachat": "^0.0.14",
|
||||||
|
"mongodb": "^6.20.0",
|
||||||
|
"mongoose": "^8.18.2",
|
||||||
"mongoose-sequence": "^6.0.1",
|
"mongoose-sequence": "^6.0.1",
|
||||||
"morgan": "^1.10.0",
|
"morgan": "^1.10.1",
|
||||||
"multer": "^1.4.5-lts.1",
|
"multer": "^1.4.5-lts.1",
|
||||||
"pbkdf2-password": "^1.2.1",
|
"pbkdf2-password": "^1.2.1",
|
||||||
"rotating-file-stream": "^3.2.5",
|
"rotating-file-stream": "^3.2.5",
|
||||||
"socket.io": "^4.8.1",
|
"socket.io": "^4.8.1",
|
||||||
"uuid": "^11.0.3"
|
"zod": "^3.24.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.17.0",
|
"@eslint/js": "^9.17.0",
|
||||||
|
"@types/jest": "^30.0.0",
|
||||||
"@types/node": "22.10.2",
|
"@types/node": "22.10.2",
|
||||||
"eslint": "^9.17.0",
|
"eslint": "^9.17.0",
|
||||||
"globals": "^15.14.0",
|
"globals": "^15.14.0",
|
||||||
"jest": "^29.7.0",
|
"jest": "^29.7.0",
|
||||||
"mockingoose": "^2.16.2",
|
"mockingoose": "^2.16.2",
|
||||||
"nodemon": "3.1.9",
|
"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": [
|
"body": [
|
||||||
{
|
{
|
||||||
"_id": "670f69b5796ce7a9069da2f7",
|
|
||||||
"created": "2024-10-16T07:22:29.042Z",
|
"created": "2024-10-16T07:22:29.042Z",
|
||||||
"id": "670f69b5796ce7a9069da2f7",
|
"id": "670f69b5796ce7a9069da2f7",
|
||||||
"items": [],
|
"items": [],
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ const { describe, it, expect } = require('@jest/globals')
|
|||||||
const request = require('supertest')
|
const request = require('supertest')
|
||||||
const express = require('express')
|
const express = require('express')
|
||||||
const mockingoose = require('mockingoose')
|
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')
|
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 mongoose = require('mongoose');
|
||||||
|
|
||||||
// Типы вопросов
|
// Типы вопросов
|
||||||
const QUESTION_TYPES = {
|
export const QUESTION_TYPES = {
|
||||||
SINGLE_CHOICE: 'single_choice', // Один вариант
|
SINGLE_CHOICE: 'single_choice', // Один вариант
|
||||||
MULTIPLE_CHOICE: 'multiple_choice', // Несколько вариантов
|
MULTIPLE_CHOICE: 'multiple_choice', // Несколько вариантов
|
||||||
TEXT: 'text', // Текстовый ответ
|
TEXT: 'text', // Текстовый ответ
|
||||||
@@ -10,7 +10,7 @@ const QUESTION_TYPES = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Типы отображения
|
// Типы отображения
|
||||||
const DISPLAY_TYPES = {
|
export const DISPLAY_TYPES = {
|
||||||
DEFAULT: 'default',
|
DEFAULT: 'default',
|
||||||
TAG_CLOUD: 'tag_cloud',
|
TAG_CLOUD: 'tag_cloud',
|
||||||
VOTING: 'voting',
|
VOTING: 'voting',
|
||||||
@@ -51,10 +51,5 @@ const questionnaireSchema = new mongoose.Schema({
|
|||||||
publicLink: { type: String, required: true } // ссылка для голосования
|
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) {
|
function generateQRCode(data, size) {
|
||||||
const typeNumber = 0; // Автоматическое определение
|
const typeNumber = 0; // Автоматическое определение
|
||||||
const errorCorrectionLevel = 'L'; // Низкий уровень коррекции ошибок
|
const errorCorrectionLevel = 'L'; // Низкий уровень коррекции ошибок
|
||||||
|
// eslint-disable-next-line no-undef
|
||||||
const qr = qrcode(typeNumber, errorCorrectionLevel);
|
const qr = qrcode(typeNumber, errorCorrectionLevel);
|
||||||
qr.addData(data);
|
qr.addData(data);
|
||||||
qr.make();
|
qr.make();
|
||||||
|
|||||||
@@ -344,21 +344,21 @@ $(document).ready(function() {
|
|||||||
|
|
||||||
// Инициализируем атрибуты required
|
// Инициализируем атрибуты required
|
||||||
updateRequiredAttributes();
|
updateRequiredAttributes();
|
||||||
});
|
|
||||||
|
|
||||||
// Обработчик удаления вопроса
|
|
||||||
$(document).on('click', '.remove-question', function() {
|
|
||||||
$(this).closest('.question-item').remove();
|
|
||||||
updateQuestionNumbers();
|
|
||||||
|
|
||||||
// Вызываем функцию обновления атрибутов required
|
// Обработчик удаления вопроса
|
||||||
updateRequiredAttributes();
|
$(document).on('click', '.remove-question', function() {
|
||||||
});
|
$(this).closest('.question-item').remove();
|
||||||
|
updateQuestionNumbers();
|
||||||
// Обработчик удаления опции
|
|
||||||
$(document).on('click', '.remove-option', function() {
|
// Вызываем функцию обновления атрибутов required
|
||||||
$(this).closest('.option-item').remove();
|
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) {
|
if (errors) {
|
||||||
return {
|
return {
|
||||||
success: false,
|
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) {
|
if (errors.length) {
|
||||||
return {
|
return {
|
||||||
success: false,
|
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