Merge pull request 'feature/worker' (#111) from feature/worker into master
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:
2025-12-05 16:59:41 +03:00
105 changed files with 18855 additions and 557 deletions

9
.env.example Normal file
View 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

View 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

View File

@@ -1,16 +1,38 @@
FROM node:20
FROM node:22 AS builder
WORKDIR /usr/src/app/
# Сначала копируем только файлы, необходимые для установки зависимостей
COPY ./package.json /usr/src/app/package.json
COPY ./package-lock.json /usr/src/app/package-lock.json
# Устанавливаем все зависимости
RUN npm ci
# Затем копируем исходный код проекта и файлы конфигурации
COPY ./tsconfig.json /usr/src/app/tsconfig.json
COPY ./server /usr/src/app/server
# Сборка проекта
RUN npm run build
# Вторая стадия - рабочий образ
FROM node:22
RUN mkdir -p /usr/src/app/server/log/
WORKDIR /usr/src/app/
COPY ./server /usr/src/app/server
# Копирование только package.json/package-lock.json для продакшн зависимостей
COPY ./package.json /usr/src/app/package.json
COPY ./package-lock.json /usr/src/app/package-lock.json
COPY ./.serverrc.js /usr/src/app/.serverrc.js
# COPY ./.env /usr/src/app/.env
# RUN npm i --omit=dev
RUN npm ci
# Установка только продакшн зависимостей
RUN npm ci --production
# Копирование собранного приложения из билдера
COPY --from=builder /usr/src/app/dist /usr/src/app/dist
COPY --from=builder /usr/src/app/server /usr/src/app/server
EXPOSE 8044
CMD ["npm", "run", "up:prod"]

View File

@@ -1,6 +1,12 @@
#!/bin/sh
docker stop ms-mongo
docker volume remove ms_volume
docker volume create ms_volume
docker run --rm -v ms_volume:/data/db --name ms-mongo -p 27017:27017 -d mongo:8.0.3
docker volume remove ms_volume8
docker volume create ms_volume8
docker run --rm \
-v ms_volume8:/data/db \
--name ms-mongo \
-p 27018:27017 \
-e MONGO_INITDB_ROOT_USERNAME=qqq \
-e MONGO_INITDB_ROOT_PASSWORD=qqq \
-d mongo:8.0.3

View File

@@ -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
View 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

View File

@@ -4,7 +4,7 @@ import pluginJs from "@eslint/js";
export default [
{ ignores: ['server/routers/old/*'] },
{ files: ["**/*.js"], languageOptions: { sourceType: "commonjs" } },
{ files: ["**/*.js"], languageOptions: { } },
{ languageOptions: { globals: globals.node } },
pluginJs.configs.recommended,
{

View File

@@ -1,43 +1,43 @@
/**
* For a detailed explanation regarding each configuration property, visit:
* Для подробного объяснения каждого свойства конфигурации, посетите:
* https://jestjs.io/docs/configuration
*/
/** @type {import('jest').Config} */
const config = {
// All imported modules in your tests should be mocked automatically
// Все импортированные модули в тестах должны быть автоматически замоканы
// automock: false,
// Stop running tests after `n` failures
// Остановить выполнение тестов после `n` неудач
// bail: 0,
// The directory where Jest should store its cached dependency information
// Директория, где Jest должен хранить кэшированную информацию о зависимостях
// cacheDirectory: "C:\\Users\\alex\\AppData\\Local\\Temp\\jest",
// Automatically clear mock calls, instances, contexts and results before every test
// Автоматически очищать вызовы моков, экземпляры, контексты и результаты перед каждым тестом
clearMocks: true,
// Indicates whether the coverage information should be collected while executing the test
// Указывает, должна ли собираться информация о покрытии во время выполнения тестов
collectCoverage: true,
// An array of glob patterns indicating a set of files for which coverage information should be collected
// Массив glob-паттернов, указывающих набор файлов, для которых должна собираться информация о покрытии
collectCoverageFrom: [
"<rootDir>/server/routers/**/*.js"
],
// The directory where Jest should output its coverage files
// Директория, куда Jest должен выводить файлы покрытия
coverageDirectory: "coverage",
// An array of regexp pattern strings used to skip coverage collection
// Массив строк regexp-паттернов, используемых для пропуска сбора покрытия
coveragePathIgnorePatterns: [
"\\\\node_modules\\\\",
"<rootDir>/server/routers/old"
],
// Indicates which provider should be used to instrument code for coverage
// Указывает, какой провайдер должен использоваться для инструментирования кода для покрытия
coverageProvider: "v8",
// A list of reporter names that Jest uses when writing coverage reports
// Список имен репортеров, которые Jest использует при записи отчетов о покрытии
// coverageReporters: [
// "json",
// "text",
@@ -45,156 +45,159 @@ const config = {
// "clover"
// ],
// An object that configures minimum threshold enforcement for coverage results
// Объект, который настраивает принудительное применение минимальных порогов для результатов покрытия
// coverageThreshold: undefined,
// A path to a custom dependency extractor
// Путь к пользовательскому извлекателю зависимостей
// dependencyExtractor: undefined,
// Make calling deprecated APIs throw helpful error messages
// Заставить вызовы устаревших API выбрасывать полезные сообщения об ошибках
// errorOnDeprecated: false,
// The default configuration for fake timers
// Конфигурация по умолчанию для поддельных таймеров
// fakeTimers: {
// "enableGlobally": false
// },
// Force coverage collection from ignored files using an array of glob patterns
// Принудительно собирать покрытие из игнорируемых файлов, используя массив glob-паттернов
// forceCoverageMatch: [],
// A path to a module which exports an async function that is triggered once before all test suites
// Путь к модулю, который экспортирует асинхронную функцию, вызываемую один раз перед всеми наборами тестов
// globalSetup: undefined,
// A path to a module which exports an async function that is triggered once after all test suites
// Путь к модулю, который экспортирует асинхронную функцию, вызываемую один раз после всех наборов тестов
// globalTeardown: undefined,
// A set of global variables that need to be available in all test environments
// Набор глобальных переменных, которые должны быть доступны во всех тестовых окружениях
// globals: {},
// The maximum amount of workers used to run your tests. Can be specified as % or a number. E.g. maxWorkers: 10% will use 10% of your CPU amount + 1 as the maximum worker number. maxWorkers: 2 will use a maximum of 2 workers.
// Максимальное количество воркеров, используемых для запуска тестов. Может быть указано в % или числом. Например, maxWorkers: 10% будет использовать 10% от количества CPU + 1 в качестве максимального числа воркеров. maxWorkers: 2 будет использовать максимум 2 воркера.
// maxWorkers: "50%",
// An array of directory names to be searched recursively up from the requiring module's location
// Массив имен директорий, которые должны быть рекурсивно найдены вверх от местоположения требуемого модуля
// moduleDirectories: [
// "node_modules"
// ],
// An array of file extensions your modules use
// moduleFileExtensions: [
// "js",
// "mjs",
// "cjs",
// "jsx",
// "ts",
// "tsx",
// "json",
// "node"
// ],
// Массив расширений файлов, которые используют ваши модули
moduleFileExtensions: [
"js",
"mjs",
"cjs",
"jsx",
"ts",
"tsx",
"json",
"node"
],
// A map from regular expressions to module names or to arrays of module names that allow to stub out resources with a single module
// Карта из регулярных выражений в имена модулей или массивы имен модулей, которые позволяют заглушить ресурсы одним модулем
// moduleNameMapper: {},
// An array of regexp pattern strings, matched against all module paths before considered 'visible' to the module loader
// Массив строк regexp-паттернов, сопоставляемых со всеми путями модулей перед тем, как они будут считаться 'видимыми' для загрузчика модулей
// modulePathIgnorePatterns: [],
// Activates notifications for test results
// Активирует уведомления для результатов тестов
// notify: false,
// An enum that specifies notification mode. Requires { notify: true }
// Перечисление, которое указывает режим уведомлений. Требует { notify: true }
// notifyMode: "failure-change",
// A preset that is used as a base for Jest's configuration
// preset: undefined,
// Пресет, который используется в качестве основы для конфигурации Jest
preset: 'ts-jest',
// Run tests from one or more projects
// Запускать тесты из одного или нескольких проектов
// projects: undefined,
// Use this configuration option to add custom reporters to Jest
// Используйте эту опцию конфигурации для добавления пользовательских репортеров в Jest
// reporters: undefined,
// Automatically reset mock state before every test
// Автоматически сбрасывать состояние моков перед каждым тестом
// resetMocks: false,
// Reset the module registry before running each individual test
// Сбрасывать реестр модулей перед запуском каждого отдельного теста
// resetModules: false,
// A path to a custom resolver
// Путь к пользовательскому резолверу
// resolver: undefined,
// Automatically restore mock state and implementation before every test
// Автоматически восстанавливать состояние моков и реализацию перед каждым тестом
// restoreMocks: false,
// The root directory that Jest should scan for tests and modules within
// Корневая директория, которую Jest должен сканировать для поиска тестов и модулей
// rootDir: undefined,
// A list of paths to directories that Jest should use to search for files in
// Список путей к директориям, которые Jest должен использовать для поиска файлов
// roots: [
// "<rootDir>"
// ],
// Allows you to use a custom runner instead of Jest's default test runner
// Позволяет использовать пользовательский раннер вместо стандартного тестового раннера Jest
// runner: "jest-runner",
// The paths to modules that run some code to configure or set up the testing environment before each test
// Пути к модулям, которые выполняют некоторый код для настройки или подготовки тестового окружения перед каждым тестом
// setupFiles: [],
// A list of paths to modules that run some code to configure or set up the testing framework before each test
// Список путей к модулям, которые выполняют некоторый код для настройки или подготовки тестового фреймворка перед каждым тестом
// setupFilesAfterEnv: [],
// The number of seconds after which a test is considered as slow and reported as such in the results.
// Количество секунд, после которого тест считается медленным и сообщается как таковой в результатах.
// slowTestThreshold: 5,
// A list of paths to snapshot serializer modules Jest should use for snapshot testing
// Список путей к модулям сериализаторов снимков, которые Jest должен использовать для тестирования снимков
// snapshotSerializers: [],
// The test environment that will be used for testing
// testEnvironment: "jest-environment-node",
// Тестовое окружение, которое будет использоваться для тестирования
testEnvironment: "node",
// Options that will be passed to the testEnvironment
// Опции, которые будут переданы в testEnvironment
// testEnvironmentOptions: {},
// Adds a location field to test results
// Добавляет поле местоположения к результатам тестов
// testLocationInResults: false,
// The glob patterns Jest uses to detect test files
// testMatch: [
// "**/__tests__/**/*.[jt]s?(x)",
// "**/?(*.)+(spec|test).[tj]s?(x)"
// ],
// Glob-паттерны, которые Jest использует для обнаружения тестовых файлов
testMatch: [
"**/__tests__/**/*.[jt]s?(x)",
"**/?(*.)+(spec|test).[tj]s?(x)"
],
// An array of regexp pattern strings that are matched against all test paths, matched tests are skipped
// Массив строк regexp-паттернов, которые сопоставляются со всеми тестовыми путями, сопоставленные тесты пропускаются
// testPathIgnorePatterns: [
// "\\\\node_modules\\\\"
// ],
// The regexp pattern or array of patterns that Jest uses to detect test files
// Regexp-паттерн или массив паттернов, которые Jest использует для обнаружения тестовых файлов
// testRegex: [],
// This option allows the use of a custom results processor
// Эта опция позволяет использовать пользовательский процессор результатов
// testResultsProcessor: undefined,
// This option allows use of a custom test runner
// Эта опция позволяет использовать пользовательский тестовый раннер
// testRunner: "jest-circus/runner",
// A map from regular expressions to paths to transformers
// transform: undefined,
// Карта из регулярных выражений в пути к трансформерам
transform: {
'^.+\\.ts$': 'ts-jest',
'^.+\\.tsx$': 'ts-jest',
},
// An array of regexp pattern strings that are matched against all source file paths, matched files will skip transformation
// Массив строк regexp-паттернов, которые сопоставляются со всеми путями исходных файлов, сопоставленные файлы будут пропускать трансформацию
// transformIgnorePatterns: [
// "\\\\node_modules\\\\",
// "\\.pnp\\.[^\\\\]+$"
// ],
// An array of regexp pattern strings that are matched against all modules before the module loader will automatically return a mock for them
// Массив строк regexp-паттернов, которые сопоставляются со всеми модулями перед тем, как загрузчик модулей автоматически вернет мок для них
// unmockedModulePathPatterns: undefined,
// Indicates whether each individual test should be reported during the run
// Указывает, должен ли каждый отдельный тест сообщаться во время выполнения
verbose: true,
// An array of regexp patterns that are matched against all source file paths before re-running tests in watch mode
// Массив regexp-паттернов, которые сопоставляются со всеми путями исходных файлов перед повторным запуском тестов в режиме наблюдения
// watchPathIgnorePatterns: [],
// Whether to use watchman for file crawling
// Использовать ли watchman для обхода файлов
// watchman: true,
};

3362
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,15 +1,13 @@
{
"name": "multi-stub",
"version": "1.2.1",
"version": "2.0.0",
"description": "",
"main": "index.js",
"main": "server/index.ts",
"type": "commonjs",
"scripts": {
"start": "cross-env PORT=8033 npx nodemon ./server",
"up:prod": "cross-env NODE_ENV=\"production\" node ./server",
"deploy:d:stop": "docker compose down",
"deploy:d:build": "docker compose build",
"deploy:d:up": "docker compose up -d",
"redeploy": "npm run deploy:d:stop && npm run deploy:d:build && npm run deploy:d:up",
"start": "cross-env NODE_ENV=\"development\" ts-node-dev .",
"build": "tsc",
"up:prod": "node dist/server/index.js",
"eslint": "npx eslint ./server",
"eslint:fix": "npx eslint ./server --fix",
"test": "jest"
@@ -23,9 +21,13 @@
"license": "MIT",
"homepage": "https://bitbucket.org/online-mentor/multi-stub#readme",
"dependencies": {
"@langchain/community": "^0.3.56",
"@langchain/core": "^0.3.77",
"@langchain/langgraph": "^0.4.9",
"ai": "^4.1.13",
"axios": "^1.7.7",
"bcrypt": "^5.1.0",
"bcryptjs": "^3.0.3",
"body-parser": "^1.19.0",
"cookie-parser": "^1.4.5",
"cors": "^2.8.5",
@@ -35,26 +37,33 @@
"express": "5.0.1",
"express-jwt": "^8.5.1",
"express-session": "^1.18.1",
"gigachat": "^0.0.16",
"jsdom": "^25.0.1",
"jsonwebtoken": "^9.0.2",
"mongodb": "^6.12.0",
"mongoose": "^8.9.2",
"langchain": "^0.3.34",
"langchain-gigachat": "^0.0.14",
"mongodb": "^6.20.0",
"mongoose": "^8.18.2",
"mongoose-sequence": "^6.0.1",
"morgan": "^1.10.0",
"morgan": "^1.10.1",
"multer": "^1.4.5-lts.1",
"pbkdf2-password": "^1.2.1",
"rotating-file-stream": "^3.2.5",
"socket.io": "^4.8.1",
"uuid": "^11.0.3"
"zod": "^3.24.3"
},
"devDependencies": {
"@eslint/js": "^9.17.0",
"@types/jest": "^30.0.0",
"@types/node": "22.10.2",
"eslint": "^9.17.0",
"globals": "^15.14.0",
"jest": "^29.7.0",
"mockingoose": "^2.16.2",
"nodemon": "3.1.9",
"supertest": "^7.0.0"
"supertest": "^7.0.0",
"ts-jest": "^29.4.6",
"ts-node-dev": "2.0.0",
"typescript": "5.7.3"
}
}

87
rules.md Normal file
View 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.

View File

@@ -4,7 +4,6 @@ exports[`todo list app get list 1`] = `
{
"body": [
{
"_id": "670f69b5796ce7a9069da2f7",
"created": "2024-10-16T07:22:29.042Z",
"id": "670f69b5796ce7a9069da2f7",
"items": [],

View File

@@ -2,7 +2,7 @@ const { describe, it, expect } = require('@jest/globals')
const request = require('supertest')
const express = require('express')
const mockingoose = require('mockingoose')
const { ListModel } = require('../data/model/todo/list')
const { ListModel } = require('../routers/todo/model/todo/list')
const todo = require('../routers/todo/routes')

View File

@@ -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
View 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 || 'Что-то пошло не так',
})
}

View File

@@ -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
View 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)

View File

@@ -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
View 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
View 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)

View File

@@ -1,7 +1,7 @@
const mongoose = require('mongoose');
// Типы вопросов
const QUESTION_TYPES = {
export const QUESTION_TYPES = {
SINGLE_CHOICE: 'single_choice', // Один вариант
MULTIPLE_CHOICE: 'multiple_choice', // Несколько вариантов
TEXT: 'text', // Текстовый ответ
@@ -10,7 +10,7 @@ const QUESTION_TYPES = {
};
// Типы отображения
const DISPLAY_TYPES = {
export const DISPLAY_TYPES = {
DEFAULT: 'default',
TAG_CLOUD: 'tag_cloud',
VOTING: 'voting',
@@ -51,10 +51,5 @@ const questionnaireSchema = new mongoose.Schema({
publicLink: { type: String, required: true } // ссылка для голосования
});
const Questionnaire = mongoose.model('Questionnaire', questionnaireSchema);
export const Questionnaire = mongoose.model('Questionnaire', questionnaireSchema);
module.exports = {
Questionnaire,
QUESTION_TYPES,
DISPLAY_TYPES
};

View File

@@ -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

View 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;

View 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);

View 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);

View 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);

View 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);

View 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);

View 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
};

View 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;

View 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;

View 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;

View 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;

View 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;

View File

@@ -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();

View File

@@ -1,15 +0,0 @@
const router = require('express').Router();
router.get('/recipe-data', (request, response) => {
response.send(require('./json/recipe-data/success.json'))
})
router.get('/userpage-data', (req, res)=>{
res.send(require('./json/userpage-data/success.json'))
})
router.get('/homepage-data', (req, res)=>{
res.send(require('./json/homepage-data/success.json'))
})
module.exports = router;

View 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;

View File

@@ -0,0 +1 @@
GIGACHAT_API_KEY=NzgzNTkxMjMtNDQ0Ny00ODFhLTkwMjgtODYxZjUzYjI0ZWQxOjA5NDEwMzEwLTM5YjItNDUzOS1hYWYzLWE4ZDA1MDExNmQ4Nw==

View File

@@ -0,0 +1,2 @@
node_modules/
.env

View 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` 生成图片(返回模拟图片链接)

View 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;

View File

@@ -0,0 +1,5 @@
module.exports = {
auth: true,
user: true,
image: true, // 关闭为 false
};

View File

@@ -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: {}
});
};

View File

@@ -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;

View File

@@ -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 });
}
};

View File

@@ -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;

View File

@@ -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: {}
});
};

View File

@@ -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;

File diff suppressed because it is too large Load Diff

View 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"
}
}

View 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}`);
});

View 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;

View 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;

View 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;

View 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 };

View 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);

View 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);

View 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);

View 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);

View 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);

View 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);

View 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);

View 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);

View 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);

View 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);

View 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')
})
})
})

View 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;

View 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;

View 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

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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();

View 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();

View 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 };

View 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();

View 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('');

View File

@@ -187,6 +187,7 @@ function showConfirm(message, callback, title) {
function generateQRCode(data, size) {
const typeNumber = 0; // Автоматическое определение
const errorCorrectionLevel = 'L'; // Низкий уровень коррекции ошибок
// eslint-disable-next-line no-undef
const qr = qrcode(typeNumber, errorCorrectionLevel);
qr.addData(data);
qr.make();

View File

@@ -344,21 +344,21 @@ $(document).ready(function() {
// Инициализируем атрибуты required
updateRequiredAttributes();
});
// Обработчик удаления вопроса
$(document).on('click', '.remove-question', function() {
$(this).closest('.question-item').remove();
updateQuestionNumbers();
// Вызываем функцию обновления атрибутов required
updateRequiredAttributes();
});
// Обработчик удаления опции
$(document).on('click', '.remove-option', function() {
$(this).closest('.option-item').remove();
// Обработчик удаления вопроса
$(document).on('click', '.remove-question', function() {
$(this).closest('.question-item').remove();
updateQuestionNumbers();
// Вызываем функцию обновления атрибутов required
updateRequiredAttributes();
});
// Вызываем функцию обновления атрибутов required
updateRequiredAttributes();
// Обработчик удаления опции
$(document).on('click', '.remove-option', function() {
$(this).closest('.option-item').remove();
// Вызываем функцию обновления атрибутов required
updateRequiredAttributes();
});
});

View 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-команды.

View 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

View 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

View 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'

View 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

View 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

View 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)

View 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)

View 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)

View File

@@ -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": ""
}
]
}

View 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

View 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

File diff suppressed because it is too large Load Diff

View File

@@ -1,4 +1,4 @@
exports.getAnswer = (errors, data, success = true) => {
export const getAnswer = (errors, data, success = true) => {
if (errors) {
return {
success: false,
@@ -12,7 +12,7 @@ exports.getAnswer = (errors, data, success = true) => {
}
}
exports.getResponse = (errors, data, success = true) => {
export const getResponse = (errors, data, success = true) => {
if (errors.length) {
return {
success: false,

View File

@@ -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