Compare commits

...

11 Commits

Author SHA1 Message Date
Primakov Alexandr Alexandrovich
919714089a Add smoke test workflow to check application stability and MongoDB readiness; update ESLint and test commands for better output
Some checks failed
Code Quality Checks / lint-and-typecheck (push) Successful in 10m46s
Code Quality Checks / smoke-test (push) Failing after 8m12s
2025-12-05 17:06:39 +03:00
Primakov Alexandr Alexandrovich
7066252bcb Update Jest configuration to include TypeScript support and add new code quality checks workflow; translate comments to Russian and adjust paths in test files. 2025-12-05 16:51:44 +03:00
d477a0a5f1 fix 2025-11-22 00:05:51 +03:00
4c35decfd7 обновил критерии 2025-11-21 23:47:56 +03:00
599170df2c update 2025-11-21 22:37:14 +03:00
449aef6f54 добавил импорт 2025-11-21 18:43:04 +03:00
1d4521b803 обновление логики 2025-11-21 16:53:13 +03:00
fa860921da update 2025-11-21 16:19:47 +03:00
Primakov Alexandr Alexandrovich
2480f7c376 Update smoke-tracker API documentation to reflect changes in JWT token expiration; modify auth.js to implement a permanent token without expiration. 2025-11-17 20:13:20 +03:00
Primakov Alexandr Alexandrovich
414383163e Enhance smoke-tracker API to include statistics for active users only; update documentation to reflect changes in user activity criteria and statistics calculations. 2025-11-17 14:40:37 +03:00
Primakov Alexandr Alexandrovich
f856d94596 Add summary statistics endpoint to smoke-tracker API; update documentation to include new route 2025-11-17 14:14:15 +03:00
32 changed files with 2349 additions and 299 deletions

138
.gitea/workflows/check.yaml Normal file
View File

@@ -0,0 +1,138 @@
name: Code Quality Checks
run-name: Проверка кода (lint & typecheck) от ${{ gitea.actor }}
on: [push]
jobs:
lint-and-typecheck:
runs-on: ubuntu-latest
steps:
- name: Check out repository code
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '22'
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Run ESLint
run: npm run eslint
- name: Run TypeScript type check
run: npx tsc --noEmit
- name: Run tests
run: npm test
smoke-test:
runs-on: ubuntu-latest
needs: lint-and-typecheck
steps:
- name: Check out repository code
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '22'
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Start MongoDB
run: |
docker run -d \
--name mongodb-smoke-test \
-p 27017:27017 \
-e MONGO_INITDB_ROOT_USERNAME=admin \
-e MONGO_INITDB_ROOT_PASSWORD=password \
mongo:8.0.3
- name: Wait for MongoDB to be ready
run: |
timeout=30
elapsed=0
while ! docker exec mongodb-smoke-test mongosh --eval "db.adminCommand('ping')" --quiet > /dev/null 2>&1; do
if [ $elapsed -ge $timeout ]; then
echo "MongoDB не запустился за $timeout секунд"
exit 1
fi
echo "Ожидание запуска MongoDB... ($elapsed/$timeout)"
sleep 2
elapsed=$((elapsed + 2))
done
echo "MongoDB готов"
- name: Start application
env:
MONGO_ADDR: mongodb://admin:password@localhost:27017/test_db?authSource=admin
PORT: 8044
NODE_ENV: test
run: |
npm start > app.log 2>&1 &
echo $! > app.pid
echo "Приложение запущено с PID: $(cat app.pid)"
- name: Wait for application to start
run: |
timeout=30
elapsed=0
while ! node -e "require('http').get('http://localhost:8044', (r) => process.exit(r.statusCode === 200 ? 0 : 1)).on('error', () => process.exit(1))" 2>/dev/null; do
if [ $elapsed -ge $timeout ]; then
echo "Приложение не запустилось за $timeout секунд"
cat app.log
exit 1
fi
echo "Ожидание запуска приложения... ($elapsed/$timeout)"
sleep 2
elapsed=$((elapsed + 2))
done
echo "Приложение запущено"
- name: Check application stability (30 seconds)
run: |
duration=30
elapsed=0
while [ $elapsed -lt $duration ]; do
if ! kill -0 $(cat app.pid) 2>/dev/null; then
echo "❌ Приложение упало через $elapsed секунд"
cat app.log
exit 1
fi
if ! node -e "require('http').get('http://localhost:8044', (r) => process.exit(r.statusCode === 200 ? 0 : 1)).on('error', () => process.exit(1))" 2>/dev/null; then
echo "❌ Приложение не отвечает через $elapsed секунд"
cat app.log
exit 1
fi
echo "✓ Приложение работает ($elapsed/$duration секунд)"
sleep 5
elapsed=$((elapsed + 5))
done
echo "✅ Приложение стабильно работает в течение $duration секунд"
- name: Stop application
if: always()
run: |
if [ -f app.pid ]; then
pid=$(cat app.pid)
if kill -0 $pid 2>/dev/null; then
echo "Остановка приложения (PID: $pid)"
kill -TERM $pid || true
sleep 3
if kill -0 $pid 2>/dev/null; then
echo "Принудительная остановка приложения"
kill -9 $pid 2>/dev/null || true
fi
fi
rm -f app.pid
fi
- name: Stop MongoDB
if: always()
run: |
docker stop mongodb-smoke-test || true
docker rm mongodb-smoke-test || true

View File

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

531
package-lock.json generated
View File

@@ -42,6 +42,7 @@
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.17.0", "@eslint/js": "^9.17.0",
"@types/jest": "^30.0.0",
"@types/node": "22.10.2", "@types/node": "22.10.2",
"eslint": "^9.17.0", "eslint": "^9.17.0",
"globals": "^15.14.0", "globals": "^15.14.0",
@@ -49,6 +50,7 @@
"mockingoose": "^2.16.2", "mockingoose": "^2.16.2",
"nodemon": "3.1.9", "nodemon": "3.1.9",
"supertest": "^7.0.0", "supertest": "^7.0.0",
"ts-jest": "^29.4.6",
"ts-node-dev": "2.0.0", "ts-node-dev": "2.0.0",
"typescript": "5.7.3" "typescript": "5.7.3"
} }
@@ -225,14 +227,15 @@
"license": "ISC" "license": "ISC"
}, },
"node_modules/@babel/code-frame": { "node_modules/@babel/code-frame": {
"version": "7.25.7", "version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.25.7.tgz", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz",
"integrity": "sha512-0xZJFNE5XMpENsgfHYTw8FbX4kv53mFLn2i3XPoq69LyhYSCBJtitaHx9QnsVTrsogI4Z3+HtEfZ2/GFPOtf5g==", "integrity": "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@babel/highlight": "^7.25.7", "@babel/helper-validator-identifier": "^7.27.1",
"picocolors": "^1.0.0" "js-tokens": "^4.0.0",
"picocolors": "^1.1.1"
}, },
"engines": { "engines": {
"node": ">=6.9.0" "node": ">=6.9.0"
@@ -442,9 +445,9 @@
} }
}, },
"node_modules/@babel/helper-validator-identifier": { "node_modules/@babel/helper-validator-identifier": {
"version": "7.25.7", "version": "7.28.5",
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.25.7.tgz", "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz",
"integrity": "sha512-AM6TzwYqGChO45oiuPqwL2t20/HdMC1rTPAesnBCgPCSF1x3oN9MVUwQV2iyz4xqWrctwK5RNC8LV22kaQCNYg==", "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
@@ -475,100 +478,6 @@
"node": ">=6.9.0" "node": ">=6.9.0"
} }
}, },
"node_modules/@babel/highlight": {
"version": "7.25.7",
"resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.25.7.tgz",
"integrity": "sha512-iYyACpW3iW8Fw+ZybQK+drQre+ns/tKpXbNESfrhNnPLIklLbXr7MYJ6gPEd0iETGLOK+SxMjVvKb/ffmk+FEw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/helper-validator-identifier": "^7.25.7",
"chalk": "^2.4.2",
"js-tokens": "^4.0.0",
"picocolors": "^1.0.0"
},
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/highlight/node_modules/ansi-styles": {
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz",
"integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==",
"dev": true,
"license": "MIT",
"dependencies": {
"color-convert": "^1.9.0"
},
"engines": {
"node": ">=4"
}
},
"node_modules/@babel/highlight/node_modules/chalk": {
"version": "2.4.2",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz",
"integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-styles": "^3.2.1",
"escape-string-regexp": "^1.0.5",
"supports-color": "^5.3.0"
},
"engines": {
"node": ">=4"
}
},
"node_modules/@babel/highlight/node_modules/color-convert": {
"version": "1.9.3",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz",
"integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==",
"dev": true,
"license": "MIT",
"dependencies": {
"color-name": "1.1.3"
}
},
"node_modules/@babel/highlight/node_modules/color-name": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz",
"integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==",
"dev": true,
"license": "MIT"
},
"node_modules/@babel/highlight/node_modules/escape-string-regexp": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz",
"integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.8.0"
}
},
"node_modules/@babel/highlight/node_modules/has-flag": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz",
"integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=4"
}
},
"node_modules/@babel/highlight/node_modules/supports-color": {
"version": "5.5.0",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz",
"integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==",
"dev": true,
"license": "MIT",
"dependencies": {
"has-flag": "^3.0.0"
},
"engines": {
"node": ">=4"
}
},
"node_modules/@babel/parser": { "node_modules/@babel/parser": {
"version": "7.25.8", "version": "7.25.8",
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.25.8.tgz", "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.25.8.tgz",
@@ -1638,6 +1547,16 @@
} }
} }
}, },
"node_modules/@jest/diff-sequences": {
"version": "30.0.1",
"resolved": "https://registry.npmjs.org/@jest/diff-sequences/-/diff-sequences-30.0.1.tgz",
"integrity": "sha512-n5H8QLDJ47QqbCNn5SuFjCRDrOLEZ0h8vAHCK5RL9Ls7Xa8AQLa/YxAc9UjFqoEDM48muwtBGjtMY5cr0PLDCw==",
"dev": true,
"license": "MIT",
"engines": {
"node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0"
}
},
"node_modules/@jest/environment": { "node_modules/@jest/environment": {
"version": "29.7.0", "version": "29.7.0",
"resolved": "https://registry.npmjs.org/@jest/environment/-/environment-29.7.0.tgz", "resolved": "https://registry.npmjs.org/@jest/environment/-/environment-29.7.0.tgz",
@@ -1699,6 +1618,16 @@
"node": "^14.15.0 || ^16.10.0 || >=18.0.0" "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
} }
}, },
"node_modules/@jest/get-type": {
"version": "30.1.0",
"resolved": "https://registry.npmjs.org/@jest/get-type/-/get-type-30.1.0.tgz",
"integrity": "sha512-eMbZE2hUnx1WV0pmURZY9XoXPkUYjpc55mb0CrhtdWLtzMQPFvu/rZkTLZFTsdaVQa+Tr4eWAteqcUzoawq/uA==",
"dev": true,
"license": "MIT",
"engines": {
"node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0"
}
},
"node_modules/@jest/globals": { "node_modules/@jest/globals": {
"version": "29.7.0", "version": "29.7.0",
"resolved": "https://registry.npmjs.org/@jest/globals/-/globals-29.7.0.tgz", "resolved": "https://registry.npmjs.org/@jest/globals/-/globals-29.7.0.tgz",
@@ -1715,6 +1644,30 @@
"node": "^14.15.0 || ^16.10.0 || >=18.0.0" "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
} }
}, },
"node_modules/@jest/pattern": {
"version": "30.0.1",
"resolved": "https://registry.npmjs.org/@jest/pattern/-/pattern-30.0.1.tgz",
"integrity": "sha512-gWp7NfQW27LaBQz3TITS8L7ZCQ0TLvtmI//4OwlQRx4rnWxcPNIYjxZpDcN4+UlGxgm3jS5QPz8IPTCkb59wZA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*",
"jest-regex-util": "30.0.1"
},
"engines": {
"node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0"
}
},
"node_modules/@jest/pattern/node_modules/jest-regex-util": {
"version": "30.0.1",
"resolved": "https://registry.npmjs.org/jest-regex-util/-/jest-regex-util-30.0.1.tgz",
"integrity": "sha512-jHEQgBXAgc+Gh4g0p3bCevgRCVRkB4VB70zhoAE48gxeSr1hfUOsM/C2WoJgVL7Eyg//hudYENbm3Ne+/dRVVA==",
"dev": true,
"license": "MIT",
"engines": {
"node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0"
}
},
"node_modules/@jest/reporters": { "node_modules/@jest/reporters": {
"version": "29.7.0", "version": "29.7.0",
"resolved": "https://registry.npmjs.org/@jest/reporters/-/reporters-29.7.0.tgz", "resolved": "https://registry.npmjs.org/@jest/reporters/-/reporters-29.7.0.tgz",
@@ -3103,6 +3056,230 @@
"@types/istanbul-lib-report": "*" "@types/istanbul-lib-report": "*"
} }
}, },
"node_modules/@types/jest": {
"version": "30.0.0",
"resolved": "https://registry.npmjs.org/@types/jest/-/jest-30.0.0.tgz",
"integrity": "sha512-XTYugzhuwqWjws0CVz8QpM36+T+Dz5mTEBKhNs/esGLnCIlGdRy+Dq78NRjd7ls7r8BC8ZRMOrKlkO1hU0JOwA==",
"dev": true,
"license": "MIT",
"dependencies": {
"expect": "^30.0.0",
"pretty-format": "^30.0.0"
}
},
"node_modules/@types/jest/node_modules/@jest/expect-utils": {
"version": "30.2.0",
"resolved": "https://registry.npmjs.org/@jest/expect-utils/-/expect-utils-30.2.0.tgz",
"integrity": "sha512-1JnRfhqpD8HGpOmQp180Fo9Zt69zNtC+9lR+kT7NVL05tNXIi+QC8Csz7lfidMoVLPD3FnOtcmp0CEFnxExGEA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jest/get-type": "30.1.0"
},
"engines": {
"node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0"
}
},
"node_modules/@types/jest/node_modules/@jest/schemas": {
"version": "30.0.5",
"resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-30.0.5.tgz",
"integrity": "sha512-DmdYgtezMkh3cpU8/1uyXakv3tJRcmcXxBOcO0tbaozPwpmh4YMsnWrQm9ZmZMfa5ocbxzbFk6O4bDPEc/iAnA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@sinclair/typebox": "^0.34.0"
},
"engines": {
"node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0"
}
},
"node_modules/@types/jest/node_modules/@jest/types": {
"version": "30.2.0",
"resolved": "https://registry.npmjs.org/@jest/types/-/types-30.2.0.tgz",
"integrity": "sha512-H9xg1/sfVvyfU7o3zMfBEjQ1gcsdeTMgqHoYdN79tuLqfTtuu7WckRA1R5whDwOzxaZAeMKTYWqP+WCAi0CHsg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jest/pattern": "30.0.1",
"@jest/schemas": "30.0.5",
"@types/istanbul-lib-coverage": "^2.0.6",
"@types/istanbul-reports": "^3.0.4",
"@types/node": "*",
"@types/yargs": "^17.0.33",
"chalk": "^4.1.2"
},
"engines": {
"node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0"
}
},
"node_modules/@types/jest/node_modules/@sinclair/typebox": {
"version": "0.34.41",
"resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.41.tgz",
"integrity": "sha512-6gS8pZzSXdyRHTIqoqSVknxolr1kzfy4/CeDnrzsVz8TTIWUbOBr6gnzOmTYJ3eXQNh4IYHIGi5aIL7sOZ2G/g==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/jest/node_modules/ansi-styles": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz",
"integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
"node_modules/@types/jest/node_modules/ci-info": {
"version": "4.3.1",
"resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.3.1.tgz",
"integrity": "sha512-Wdy2Igu8OcBpI2pZePZ5oWjPC38tmDVx5WKUXKwlLYkA0ozo85sLsLvkBbBn/sZaSCMFOGZJ14fvW9t5/d7kdA==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/sibiraj-s"
}
],
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/@types/jest/node_modules/expect": {
"version": "30.2.0",
"resolved": "https://registry.npmjs.org/expect/-/expect-30.2.0.tgz",
"integrity": "sha512-u/feCi0GPsI+988gU2FLcsHyAHTU0MX1Wg68NhAnN7z/+C5wqG+CY8J53N9ioe8RXgaoz0nBR/TYMf3AycUuPw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jest/expect-utils": "30.2.0",
"@jest/get-type": "30.1.0",
"jest-matcher-utils": "30.2.0",
"jest-message-util": "30.2.0",
"jest-mock": "30.2.0",
"jest-util": "30.2.0"
},
"engines": {
"node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0"
}
},
"node_modules/@types/jest/node_modules/jest-diff": {
"version": "30.2.0",
"resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-30.2.0.tgz",
"integrity": "sha512-dQHFo3Pt4/NLlG5z4PxZ/3yZTZ1C7s9hveiOj+GCN+uT109NC2QgsoVZsVOAvbJ3RgKkvyLGXZV9+piDpWbm6A==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jest/diff-sequences": "30.0.1",
"@jest/get-type": "30.1.0",
"chalk": "^4.1.2",
"pretty-format": "30.2.0"
},
"engines": {
"node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0"
}
},
"node_modules/@types/jest/node_modules/jest-matcher-utils": {
"version": "30.2.0",
"resolved": "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-30.2.0.tgz",
"integrity": "sha512-dQ94Nq4dbzmUWkQ0ANAWS9tBRfqCrn0bV9AMYdOi/MHW726xn7eQmMeRTpX2ViC00bpNaWXq+7o4lIQ3AX13Hg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jest/get-type": "30.1.0",
"chalk": "^4.1.2",
"jest-diff": "30.2.0",
"pretty-format": "30.2.0"
},
"engines": {
"node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0"
}
},
"node_modules/@types/jest/node_modules/jest-message-util": {
"version": "30.2.0",
"resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-30.2.0.tgz",
"integrity": "sha512-y4DKFLZ2y6DxTWD4cDe07RglV88ZiNEdlRfGtqahfbIjfsw1nMCPx49Uev4IA/hWn3sDKyAnSPwoYSsAEdcimw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/code-frame": "^7.27.1",
"@jest/types": "30.2.0",
"@types/stack-utils": "^2.0.3",
"chalk": "^4.1.2",
"graceful-fs": "^4.2.11",
"micromatch": "^4.0.8",
"pretty-format": "30.2.0",
"slash": "^3.0.0",
"stack-utils": "^2.0.6"
},
"engines": {
"node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0"
}
},
"node_modules/@types/jest/node_modules/jest-mock": {
"version": "30.2.0",
"resolved": "https://registry.npmjs.org/jest-mock/-/jest-mock-30.2.0.tgz",
"integrity": "sha512-JNNNl2rj4b5ICpmAcq+WbLH83XswjPbjH4T7yvGzfAGCPh1rw+xVNbtk+FnRslvt9lkCcdn9i1oAoKUuFsOxRw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jest/types": "30.2.0",
"@types/node": "*",
"jest-util": "30.2.0"
},
"engines": {
"node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0"
}
},
"node_modules/@types/jest/node_modules/jest-util": {
"version": "30.2.0",
"resolved": "https://registry.npmjs.org/jest-util/-/jest-util-30.2.0.tgz",
"integrity": "sha512-QKNsM0o3Xe6ISQU869e+DhG+4CK/48aHYdJZGlFQVTjnbvgpcKyxpzk29fGiO7i/J8VENZ+d2iGnSsvmuHywlA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jest/types": "30.2.0",
"@types/node": "*",
"chalk": "^4.1.2",
"ci-info": "^4.2.0",
"graceful-fs": "^4.2.11",
"picomatch": "^4.0.2"
},
"engines": {
"node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0"
}
},
"node_modules/@types/jest/node_modules/picomatch": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/@types/jest/node_modules/pretty-format": {
"version": "30.2.0",
"resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.2.0.tgz",
"integrity": "sha512-9uBdv/B4EefsuAL+pWqueZyZS2Ba+LxfFeQ9DN14HU4bN8bhaxKdkpjpB6fs9+pSjIBu+FXQHImEg8j/Lw0+vA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jest/schemas": "30.0.5",
"ansi-styles": "^5.2.0",
"react-is": "^18.3.1"
},
"engines": {
"node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0"
}
},
"node_modules/@types/json-schema": { "node_modules/@types/json-schema": {
"version": "7.0.15", "version": "7.0.15",
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
@@ -3818,6 +3995,19 @@
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
} }
}, },
"node_modules/bs-logger": {
"version": "0.2.6",
"resolved": "https://registry.npmjs.org/bs-logger/-/bs-logger-0.2.6.tgz",
"integrity": "sha512-pd8DCoxmbgc7hyPKOvxtqNcjYoOsABPQdcCUjGp3d42VR2CX1ORhk2A87oqqu5R1kk+76nsxZupkmyd+MVtCog==",
"dev": true,
"license": "MIT",
"dependencies": {
"fast-json-stable-stringify": "2.x"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/bser": { "node_modules/bser": {
"version": "2.1.1", "version": "2.1.1",
"resolved": "https://registry.npmjs.org/bser/-/bser-2.1.1.tgz", "resolved": "https://registry.npmjs.org/bser/-/bser-2.1.1.tgz",
@@ -5882,6 +6072,28 @@
"graphql": "14 - 16" "graphql": "14 - 16"
} }
}, },
"node_modules/handlebars": {
"version": "4.7.8",
"resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.8.tgz",
"integrity": "sha512-vafaFqs8MZkRrSX7sFVUdo3ap/eNiLnb4IakshzvP56X5Nr1iGKAIqdX6tMlm6HcNRIkr6AxO5jFEoJzzpT8aQ==",
"devOptional": true,
"license": "MIT",
"dependencies": {
"minimist": "^1.2.5",
"neo-async": "^2.6.2",
"source-map": "^0.6.1",
"wordwrap": "^1.0.0"
},
"bin": {
"handlebars": "bin/handlebars"
},
"engines": {
"node": ">=0.4.7"
},
"optionalDependencies": {
"uglify-js": "^3.1.4"
}
},
"node_modules/has-flag": { "node_modules/has-flag": {
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
@@ -7688,6 +7900,13 @@
"resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz",
"integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==" "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw=="
}, },
"node_modules/lodash.memoize": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/lodash.memoize/-/lodash.memoize-4.1.2.tgz",
"integrity": "sha512-t7j+NzmgnQzTAYXcsHYLgimltOV1MXHtlOWf6GjL9Kj8GK5FInw5JotxvbOs+IvV1/Dzo04/fCGfLVs7aXb4Ag==",
"dev": true,
"license": "MIT"
},
"node_modules/lodash.merge": { "node_modules/lodash.merge": {
"version": "4.6.2", "version": "4.6.2",
"resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
@@ -8245,6 +8464,13 @@
"node": ">= 0.6" "node": ">= 0.6"
} }
}, },
"node_modules/neo-async": {
"version": "2.6.2",
"resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz",
"integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==",
"devOptional": true,
"license": "MIT"
},
"node_modules/nice-grpc": { "node_modules/nice-grpc": {
"version": "2.1.12", "version": "2.1.12",
"resolved": "https://registry.npmjs.org/nice-grpc/-/nice-grpc-2.1.12.tgz", "resolved": "https://registry.npmjs.org/nice-grpc/-/nice-grpc-2.1.12.tgz",
@@ -8800,9 +9026,9 @@
} }
}, },
"node_modules/picocolors": { "node_modules/picocolors": {
"version": "1.1.0", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.0.tgz", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
"integrity": "sha512-TQ92mBOW0l3LeMeyLV6mzy/kWr8lkd/hp3mTg7wYK7zJhuBStmGMBG0BdeDZS/dZx1IukaX6Bk11zcln25o1Aw==", "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
"dev": true, "dev": true,
"license": "ISC" "license": "ISC"
}, },
@@ -9431,9 +9657,9 @@
"license": "BSD-3-Clause" "license": "BSD-3-Clause"
}, },
"node_modules/semver": { "node_modules/semver": {
"version": "7.7.2", "version": "7.7.3",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz",
"integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==",
"license": "ISC", "license": "ISC",
"bin": { "bin": {
"semver": "bin/semver.js" "semver": "bin/semver.js"
@@ -9789,7 +10015,7 @@
"version": "0.6.1", "version": "0.6.1",
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
"integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==",
"dev": true, "devOptional": true,
"license": "BSD-3-Clause", "license": "BSD-3-Clause",
"engines": { "engines": {
"node": ">=0.10.0" "node": ">=0.10.0"
@@ -10255,6 +10481,72 @@
"integrity": "sha512-tLJxacIQUM82IR7JO1UUkKlYuUTmoY9HBJAmNWFzheSlDS5SPMcNIepejHJa4BpPQLAcbRhRf3GDJzyj6rbKvA==", "integrity": "sha512-tLJxacIQUM82IR7JO1UUkKlYuUTmoY9HBJAmNWFzheSlDS5SPMcNIepejHJa4BpPQLAcbRhRf3GDJzyj6rbKvA==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/ts-jest": {
"version": "29.4.6",
"resolved": "https://registry.npmjs.org/ts-jest/-/ts-jest-29.4.6.tgz",
"integrity": "sha512-fSpWtOO/1AjSNQguk43hb/JCo16oJDnMJf3CdEGNkqsEX3t0KX96xvyX1D7PfLCpVoKu4MfVrqUkFyblYoY4lA==",
"dev": true,
"license": "MIT",
"dependencies": {
"bs-logger": "^0.2.6",
"fast-json-stable-stringify": "^2.1.0",
"handlebars": "^4.7.8",
"json5": "^2.2.3",
"lodash.memoize": "^4.1.2",
"make-error": "^1.3.6",
"semver": "^7.7.3",
"type-fest": "^4.41.0",
"yargs-parser": "^21.1.1"
},
"bin": {
"ts-jest": "cli.js"
},
"engines": {
"node": "^14.15.0 || ^16.10.0 || ^18.0.0 || >=20.0.0"
},
"peerDependencies": {
"@babel/core": ">=7.0.0-beta.0 <8",
"@jest/transform": "^29.0.0 || ^30.0.0",
"@jest/types": "^29.0.0 || ^30.0.0",
"babel-jest": "^29.0.0 || ^30.0.0",
"jest": "^29.0.0 || ^30.0.0",
"jest-util": "^29.0.0 || ^30.0.0",
"typescript": ">=4.3 <6"
},
"peerDependenciesMeta": {
"@babel/core": {
"optional": true
},
"@jest/transform": {
"optional": true
},
"@jest/types": {
"optional": true
},
"babel-jest": {
"optional": true
},
"esbuild": {
"optional": true
},
"jest-util": {
"optional": true
}
}
},
"node_modules/ts-jest/node_modules/type-fest": {
"version": "4.41.0",
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz",
"integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==",
"dev": true,
"license": "(MIT OR CC0-1.0)",
"engines": {
"node": ">=16"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/ts-node": { "node_modules/ts-node": {
"version": "10.9.2", "version": "10.9.2",
"resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz", "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz",
@@ -10449,6 +10741,20 @@
"node": ">=14.17" "node": ">=14.17"
} }
}, },
"node_modules/uglify-js": {
"version": "3.19.3",
"resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.19.3.tgz",
"integrity": "sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ==",
"dev": true,
"license": "BSD-2-Clause",
"optional": true,
"bin": {
"uglifyjs": "bin/uglifyjs"
},
"engines": {
"node": ">=0.8.0"
}
},
"node_modules/uid-safe": { "node_modules/uid-safe": {
"version": "2.1.5", "version": "2.1.5",
"resolved": "https://registry.npmjs.org/uid-safe/-/uid-safe-2.1.5.tgz", "resolved": "https://registry.npmjs.org/uid-safe/-/uid-safe-2.1.5.tgz",
@@ -10749,6 +11055,13 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/wordwrap": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-1.0.0.tgz",
"integrity": "sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==",
"devOptional": true,
"license": "MIT"
},
"node_modules/wrap-ansi": { "node_modules/wrap-ansi": {
"version": "7.0.0", "version": "7.0.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",

View File

@@ -54,6 +54,7 @@
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.17.0", "@eslint/js": "^9.17.0",
"@types/jest": "^30.0.0",
"@types/node": "22.10.2", "@types/node": "22.10.2",
"eslint": "^9.17.0", "eslint": "^9.17.0",
"globals": "^15.14.0", "globals": "^15.14.0",
@@ -61,6 +62,7 @@
"mockingoose": "^2.16.2", "mockingoose": "^2.16.2",
"nodemon": "3.1.9", "nodemon": "3.1.9",
"supertest": "^7.0.0", "supertest": "^7.0.0",
"ts-jest": "^29.4.6",
"ts-node-dev": "2.0.0", "ts-node-dev": "2.0.0",
"typescript": "5.7.3" "typescript": "5.7.3"
} }

View File

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

View File

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

View File

@@ -22,6 +22,7 @@ import connectmeRouter from './routers/connectme'
import questioneerRouter from './routers/questioneer' import questioneerRouter from './routers/questioneer'
import procurementRouter from './routers/procurement' import procurementRouter from './routers/procurement'
import smokeTrackerRouter from './routers/smoke-tracker' import smokeTrackerRouter from './routers/smoke-tracker'
import assessmentToolsRouter from './routers/assessment-tools'
import { setIo } from './io' import { setIo } from './io'
export const app = express() export const app = express()
@@ -109,6 +110,7 @@ const initServer = async () => {
app.use('/questioneer', questioneerRouter) app.use('/questioneer', questioneerRouter)
app.use('/procurement', procurementRouter) app.use('/procurement', procurementRouter)
app.use('/smoke-tracker', smokeTrackerRouter) app.use('/smoke-tracker', smokeTrackerRouter)
app.use('/assessment-tools', assessmentToolsRouter)
app.use(errorHandler) app.use(errorHandler)
// Создаем обычный HTTP сервер // Создаем обычный HTTP сервер

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

@@ -48,6 +48,7 @@ exports.generate = async (req, res) => {
} }
); );
const content = chatResp.data.choices[0].message.content; const content = chatResp.data.choices[0].message.content;
// eslint-disable-next-line no-useless-escape
const match = content.match(/<img src=\"(.*?)\"/); const match = content.match(/<img src=\"(.*?)\"/);
if (!match) { if (!match) {
return res.status(500).json({ error: 'No image generated' }); return res.status(500).json({ error: 'No image generated' });

View File

@@ -1,6 +1,7 @@
const express = require('express') const express = require('express')
const mongoose = require('mongoose') const mongoose = require('mongoose')
const request = require('supertest') const request = require('supertest')
const { describe, it, beforeAll, expect } = require('@jest/globals')
// Mock auth middleware // Mock auth middleware
const mockAuthMiddleware = (req, res, next) => { const mockAuthMiddleware = (req, res, next) => {

View File

@@ -136,6 +136,7 @@ const waitForDatabaseConnection = async () => {
} }
try { try {
// eslint-disable-next-line no-undef
const connection = await connectDB(); const connection = await connectDB();
if (!connection) { if (!connection) {
break; break;
@@ -218,6 +219,7 @@ const initializeTestUser = async () => {
console.error('Error initializing test data:', error.message); console.error('Error initializing test data:', error.message);
if (error?.code === 13 || /auth/i.test(error?.message || '')) { if (error?.code === 13 || /auth/i.test(error?.message || '')) {
try { try {
// eslint-disable-next-line no-undef
await connectDB(); await connectDB();
} catch (connectError) { } catch (connectError) {
if (process.env.DEV === 'true') { if (process.env.DEV === 'true') {

View File

@@ -202,6 +202,7 @@ router.get('/docs/:id/file', async (req, res) => {
} }
const mimeType = mimeTypes[doc.type] || 'application/octet-stream' const mimeType = mimeTypes[doc.type] || 'application/octet-stream'
// eslint-disable-next-line no-useless-escape
const sanitizedName = doc.name.replace(/[^\w\s\-\.]/g, '_') const sanitizedName = doc.name.replace(/[^\w\s\-\.]/g, '_')
res.setHeader('Content-Type', mimeType) res.setHeader('Content-Type', mimeType)

View File

@@ -37,6 +37,7 @@ const storage = multer.diskStorage({
const originalExtension = path.extname(fixedName) || ''; const originalExtension = path.extname(fixedName) || '';
const baseName = path const baseName = path
.basename(fixedName, originalExtension) .basename(fixedName, originalExtension)
// eslint-disable-next-line no-control-regex
.replace(/[<>:"/\\|?*\x00-\x1F]+/g, '_'); // Убираем только недопустимые символы Windows, оставляем кириллицу .replace(/[<>:"/\\|?*\x00-\x1F]+/g, '_'); // Убираем только недопустимые символы Windows, оставляем кириллицу
cb(null, `${Date.now()}_${baseName}${originalExtension}`); cb(null, `${Date.now()}_${baseName}${originalExtension}`);
}, },

View File

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

View File

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

View File

@@ -20,6 +20,7 @@ http://localhost:8044/smoke-tracker
- [Получить список сигарет](#get-cigarettes) - [Получить список сигарет](#get-cigarettes)
3. [Статистика](#статистика) 3. [Статистика](#статистика)
- [Дневная статистика](#get-statsdaily) - [Дневная статистика](#get-statsdaily)
- [Сводная статистика](#get-statssummary)
--- ---
@@ -126,7 +127,7 @@ curl -X POST http://localhost:8044/smoke-tracker/auth/signin \
- `user.id` — уникальный идентификатор пользователя - `user.id` — уникальный идентификатор пользователя
- `user.login` — логин пользователя - `user.login` — логин пользователя
- `user.created` — дата создания аккаунта (ISO 8601) - `user.created` — дата создания аккаунта (ISO 8601)
- `token` — JWT-токен для авторизации (действителен 12 часов) - `token` — JWT-токен для авторизации (без ограничений по времени действия)
**Возможные ошибки**: **Возможные ошибки**:
@@ -399,6 +400,212 @@ const chartData = {
--- ---
### `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 в следующем формате:

View File

@@ -69,10 +69,8 @@ router.post(
{ {
...JSON.parse(JSON.stringify(user.userId)), ...JSON.parse(JSON.stringify(user.userId)),
}, },
SMOKE_TRACKER_TOKEN_KEY, SMOKE_TRACKER_TOKEN_KEY
{ // Для этого проекта токен делаем бессрочным (без поля expiresIn)
expiresIn: '12h',
}
) )
res.json( res.json(

View File

@@ -190,6 +190,43 @@
"description": "Агрегация по дням для графиков. Если from/to не заданы, используется последний месяц." "description": "Агрегация по дням для графиков. Если from/to не заданы, используется последний месяц."
}, },
"response": [] "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": [], "event": [],

View File

@@ -62,6 +62,218 @@ router.get('/daily', async (req, res, next) => {
} }
}) })
// Сводная статистика: среднее в день, по дням недели, общее по всем пользователям
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 module.exports = router

View File

@@ -1,109 +1,109 @@
{ {
"compilerOptions": { "compilerOptions": {
/* Visit https://aka.ms/tsconfig to read more about this file */ /* Посетите https://aka.ms/tsconfig чтобы узнать больше об этом файле */
/* Projects */ /* Проекты */
// "incremental": true, /* Save .tsbuildinfo files to allow for incremental compilation of projects. */ // "incremental": true, /* Сохранять .tsbuildinfo файлы для инкрементальной компиляции проектов. */
// "composite": true, /* Enable constraints that allow a TypeScript project to be used with project references. */ // "composite": true, /* Включить ограничения, которые позволяют использовать проект TypeScript со ссылками на проекты. */
// "tsBuildInfoFile": "./.tsbuildinfo", /* Specify the path to .tsbuildinfo incremental compilation file. */ // "tsBuildInfoFile": "./.tsbuildinfo", /* Указать путь к файлу инкрементальной компиляции .tsbuildinfo. */
// "disableSourceOfProjectReferenceRedirect": true, /* Disable preferring source files instead of declaration files when referencing composite projects. */ // "disableSourceOfProjectReferenceRedirect": true, /* Отключить предпочтение исходных файлов вместо файлов объявлений при ссылке на составные проекты. */
// "disableSolutionSearching": true, /* Opt a project out of multi-project reference checking when editing. */ // "disableSolutionSearching": true, /* Исключить проект из проверки ссылок нескольких проектов при редактировании. */
// "disableReferencedProjectLoad": true, /* Reduce the number of projects loaded automatically by TypeScript. */ // "disableReferencedProjectLoad": true, /* Уменьшить количество проектов, загружаемых автоматически TypeScript. */
/* Language and Environment */ /* Язык и окружение */
"target": "es2018", /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */ "target": "es2018", /* Установить версию языка JavaScript для сгенерированного JavaScript и включить совместимые объявления библиотек. */
// "lib": [], /* Specify a set of bundled library declaration files that describe the target runtime environment. */ // "lib": [], /* Указать набор объединенных файлов объявлений библиотек, описывающих целевое окружение выполнения. */
// "jsx": "preserve", /* Specify what JSX code is generated. */ // "jsx": "preserve", /* Указать, какой JSX код генерируется. */
// "experimentalDecorators": true, /* Enable experimental support for legacy experimental decorators. */ // "experimentalDecorators": true, /* Включить экспериментальную поддержку устаревших экспериментальных декораторов. */
// "emitDecoratorMetadata": true, /* Emit design-type metadata for decorated declarations in source files. */ // "emitDecoratorMetadata": true, /* Генерировать метаданные типов дизайна для декорированных объявлений в исходных файлах. */
// "jsxFactory": "", /* Specify the JSX factory function used when targeting React JSX emit, e.g. 'React.createElement' or 'h'. */ // "jsxFactory": "", /* Указать функцию фабрики JSX, используемую при таргетинге на React JSX, например 'React.createElement' или 'h'. */
// "jsxFragmentFactory": "", /* Specify the JSX Fragment reference used for fragments when targeting React JSX emit e.g. 'React.Fragment' or 'Fragment'. */ // "jsxFragmentFactory": "", /* Указать ссылку на JSX Fragment, используемую для фрагментов при таргетинге на React JSX, например 'React.Fragment' или 'Fragment'. */
// "jsxImportSource": "", /* Specify module specifier used to import the JSX factory functions when using 'jsx: react-jsx*'. */ // "jsxImportSource": "", /* Указать спецификатор модуля, используемый для импорта функций фабрики JSX при использовании 'jsx: react-jsx*'. */
// "reactNamespace": "", /* Specify the object invoked for 'createElement'. This only applies when targeting 'react' JSX emit. */ // "reactNamespace": "", /* Указать объект, вызываемый для 'createElement'. Применяется только при таргетинге на 'react' JSX. */
// "noLib": true, /* Disable including any library files, including the default lib.d.ts. */ // "noLib": true, /* Отключить включение любых файлов библиотек, включая lib.d.ts по умолчанию. */
// "useDefineForClassFields": true, /* Emit ECMAScript-standard-compliant class fields. */ // "useDefineForClassFields": true, /* Генерировать поля классов, совместимые со стандартом ECMAScript. */
// "moduleDetection": "auto", /* Control what method is used to detect module-format JS files. */ // "moduleDetection": "auto", /* Управлять методом, используемым для обнаружения JS файлов формата модулей. */
/* Modules */ /* Модули */
"module": "NodeNext", /* Specify what module code is generated. */ "module": "NodeNext", /* Указать, какой код модулей генерируется. */
"rootDir": ".", /* Specify the root folder within your source files. */ "rootDir": ".", /* Указать корневую папку в ваших исходных файлах. */
"moduleResolution": "nodenext", /* Specify how TypeScript looks up a file from a given module specifier. */ "moduleResolution": "nodenext", /* Указать, как TypeScript ищет файл по заданному спецификатору модуля. */
// "baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */ // "baseUrl": "./", /* Указать базовую директорию для разрешения неотносительных имен модулей. */
// "paths": {}, /* Specify a set of entries that re-map imports to additional lookup locations. */ // "paths": {}, /* Указать набор записей, которые переопределяют импорты для дополнительных мест поиска. */
// "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */ // "rootDirs": [], /* Разрешить обработку нескольких папок как одной при разрешении модулей. */
// "typeRoots": [], /* Specify multiple folders that act like './node_modules/@types'. */ // "typeRoots": [], /* Указать несколько папок, которые действуют как './node_modules/@types'. */
// "types": [], /* Specify type package names to be included without being referenced in a source file. */ // "types": [], /* Указать имена пакетов типов, которые должны быть включены без ссылки в исходном файле. */
// "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */ // "allowUmdGlobalAccess": true, /* Разрешить доступ к UMD глобальным переменным из модулей. */
// "moduleSuffixes": [], /* List of file name suffixes to search when resolving a module. */ // "moduleSuffixes": [], /* Список суффиксов имен файлов для поиска при разрешении модуля. */
// "allowImportingTsExtensions": true, /* Allow imports to include TypeScript file extensions. Requires '--moduleResolution bundler' and either '--noEmit' or '--emitDeclarationOnly' to be set. */ // "allowImportingTsExtensions": true, /* Разрешить импорты с расширениями файлов TypeScript. Требует '--moduleResolution bundler' и либо '--noEmit', либо '--emitDeclarationOnly'. */
"resolvePackageJsonExports": true, /* Use the package.json 'exports' field when resolving package imports. */ "resolvePackageJsonExports": true, /* Использовать поле 'exports' из package.json при разрешении импортов пакетов. */
// "resolvePackageJsonImports": true, /* Use the package.json 'imports' field when resolving imports. */ // "resolvePackageJsonImports": true, /* Использовать поле 'imports' из package.json при разрешении импортов. */
// "customConditions": [], /* Conditions to set in addition to the resolver-specific defaults when resolving imports. */ // "customConditions": [], /* Условия для установки в дополнение к специфичным для резолвера значениям по умолчанию при разрешении импортов. */
"resolveJsonModule": true, /* Enable importing .json files. */ "resolveJsonModule": true, /* Включить импорт .json файлов. */
// "allowArbitraryExtensions": true, /* Enable importing files with any extension, provided a declaration file is present. */ // "allowArbitraryExtensions": true, /* Включить импорт файлов с любым расширением, при условии наличия файла объявлений. */
// "noResolve": true, /* Disallow 'import's, 'require's or '<reference>'s from expanding the number of files TypeScript should add to a project. */ // "noResolve": true, /* Запретить 'import's, 'require's или '<reference>'s от расширения количества файлов, которые TypeScript должен добавить в проект. */
/* JavaScript Support */ /* Поддержка JavaScript */
"allowJs": true, /* Allow JavaScript files to be a part of your program. Use the 'checkJS' option to get errors from these files. */ "allowJs": true, /* Разрешить файлам JavaScript быть частью вашей программы. Используйте опцию 'checkJS' для получения ошибок из этих файлов. */
// "checkJs": true, /* Enable error reporting in type-checked JavaScript files. */ // "checkJs": true, /* Включить сообщения об ошибках в файлах JavaScript с проверкой типов. */
// "maxNodeModuleJsDepth": 1, /* Specify the maximum folder depth used for checking JavaScript files from 'node_modules'. Only applicable with 'allowJs'. */ // "maxNodeModuleJsDepth": 1, /* Указать максимальную глубину папок, используемую для проверки JavaScript файлов из 'node_modules'. Применимо только с 'allowJs'. */
/* Emit */ /* Генерация */
// "declaration": true, /* Generate .d.ts files from TypeScript and JavaScript files in your project. */ // "declaration": true, /* Генерировать .d.ts файлы из файлов TypeScript и JavaScript в вашем проекте. */
// "declarationMap": true, /* Create sourcemaps for d.ts files. */ // "declarationMap": true, /* Создавать sourcemaps для d.ts файлов. */
// "emitDeclarationOnly": true, /* Only output d.ts files and not JavaScript files. */ // "emitDeclarationOnly": true, /* Выводить только d.ts файлы, а не JavaScript файлы. */
// "sourceMap": true, /* Create source map files for emitted JavaScript files. */ // "sourceMap": true, /* Создавать файлы source map для сгенерированных JavaScript файлов. */
// "inlineSourceMap": true, /* Include sourcemap files inside the emitted JavaScript. */ // "inlineSourceMap": true, /* Включать файлы sourcemap внутри сгенерированного JavaScript. */
// "outFile": "./", /* Specify a file that bundles all outputs into one JavaScript file. If 'declaration' is true, also designates a file that bundles all .d.ts output. */ // "outFile": "./", /* Указать файл, который объединяет все выходные данные в один JavaScript файл. Если 'declaration' равно true, также обозначает файл, который объединяет весь .d.ts вывод. */
"outDir": "./dist", /* Specify an output folder for all emitted files. */ "outDir": "./dist", /* Указать выходную папку для всех сгенерированных файлов. */
// "removeComments": true, /* Disable emitting comments. */ // "removeComments": true, /* Отключить генерацию комментариев. */
// "noEmit": true, /* Disable emitting files from a compilation. */ // "noEmit": true, /* Отключить генерацию файлов из компиляции. */
// "importHelpers": true, /* Allow importing helper functions from tslib once per project, instead of including them per-file. */ // "importHelpers": true, /* Разрешить импорт вспомогательных функций из tslib один раз на проект, вместо включения их в каждый файл. */
// "importsNotUsedAsValues": "remove", /* Specify emit/checking behavior for imports that are only used for types. */ // "importsNotUsedAsValues": "remove", /* Указать поведение генерации/проверки для импортов, которые используются только для типов. */
// "downlevelIteration": true, /* Emit more compliant, but verbose and less performant JavaScript for iteration. */ // "downlevelIteration": true, /* Генерировать более совместимый, но многословный и менее производительный JavaScript для итерации. */
// "sourceRoot": "", /* Specify the root path for debuggers to find the reference source code. */ // "sourceRoot": "", /* Указать корневой путь для отладчиков для поиска эталонного исходного кода. */
// "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */ // "mapRoot": "", /* Указать местоположение, где отладчик должен найти map файлы вместо сгенерированных местоположений. */
// "inlineSources": true, /* Include source code in the sourcemaps inside the emitted JavaScript. */ // "inlineSources": true, /* Включать исходный код в sourcemaps внутри сгенерированного JavaScript. */
// "emitBOM": true, /* Emit a UTF-8 Byte Order Mark (BOM) in the beginning of output files. */ // "emitBOM": true, /* Генерировать UTF-8 Byte Order Mark (BOM) в начале выходных файлов. */
// "newLine": "crlf", /* Set the newline character for emitting files. */ // "newLine": "crlf", /* Установить символ новой строки для генерации файлов. */
// "stripInternal": true, /* Disable emitting declarations that have '@internal' in their JSDoc comments. */ // "stripInternal": true, /* Отключить генерацию объявлений, которые имеют '@internal' в их JSDoc комментариях. */
// "noEmitHelpers": true, /* Disable generating custom helper functions like '__extends' in compiled output. */ // "noEmitHelpers": true, /* Отключить генерацию пользовательских вспомогательных функций, таких как '__extends' в скомпилированном выводе. */
// "noEmitOnError": true, /* Disable emitting files if any type checking errors are reported. */ // "noEmitOnError": true, /* Отключить генерацию файлов, если сообщается о любых ошибках проверки типов. */
// "preserveConstEnums": true, /* Disable erasing 'const enum' declarations in generated code. */ // "preserveConstEnums": true, /* Отключить стирание объявлений 'const enum' в сгенерированном коде. */
// "declarationDir": "./", /* Specify the output directory for generated declaration files. */ // "declarationDir": "./", /* Указать выходную директорию для сгенерированных файлов объявлений. */
// "preserveValueImports": true, /* Preserve unused imported values in the JavaScript output that would otherwise be removed. */ // "preserveValueImports": true, /* Сохранять неиспользуемые импортированные значения в выводе JavaScript, которые в противном случае были бы удалены. */
/* Interop Constraints */ /* Ограничения взаимодействия */
// "isolatedModules": true, /* Ensure that each file can be safely transpiled without relying on other imports. */ "isolatedModules": true, /* Обеспечить, чтобы каждый файл мог быть безопасно транслирован без зависимости от других импортов. */
// "verbatimModuleSyntax": true, /* Do not transform or elide any imports or exports not marked as type-only, ensuring they are written in the output file's format based on the 'module' setting. */ // "verbatimModuleSyntax": true, /* Не преобразовывать или опускать любые импорты или экспорты, не помеченные как только для типов, обеспечивая их запись в формате выходного файла на основе настройки 'module'. */
// "allowSyntheticDefaultImports": true, /* Allow 'import x from y' when a module doesn't have a default export. */ // "allowSyntheticDefaultImports": true, /* Разрешить 'import x from y' когда модуль не имеет экспорта по умолчанию. */
"esModuleInterop": true, /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables 'allowSyntheticDefaultImports' for type compatibility. */ "esModuleInterop": true, /* Генерировать дополнительный JavaScript для упрощения поддержки импорта модулей CommonJS. Это включает 'allowSyntheticDefaultImports' для совместимости типов. */
// "preserveSymlinks": true, /* Disable resolving symlinks to their realpath. This correlates to the same flag in node. */ // "preserveSymlinks": true, /* Отключить разрешение символических ссылок к их реальному пути. Соответствует тому же флагу в node. */
"forceConsistentCasingInFileNames": true, /* Ensure that casing is correct in imports. */ "forceConsistentCasingInFileNames": true, /* Обеспечить правильный регистр в импортах. */
/* Type Checking */ /* Проверка типов */
"strict": false, /* Enable all strict type-checking options. */ "strict": false, /* Включить все строгие опции проверки типов. */
"noImplicitAny": false, /* Enable error reporting for expressions and declarations with an implied 'any' type. */ "noImplicitAny": false, /* Включить сообщения об ошибках для выражений и объявлений с подразумеваемым типом 'any'. */
// "strictNullChecks": true, /* When type checking, take into account 'null' and 'undefined'. */ // "strictNullChecks": true, /* При проверке типов учитывать 'null' и 'undefined'. */
// "strictFunctionTypes": true, /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */ // "strictFunctionTypes": true, /* При присваивании функций проверять, чтобы параметры и возвращаемые значения были совместимы по подтипам. */
// "strictBindCallApply": true, /* Check that the arguments for 'bind', 'call', and 'apply' methods match the original function. */ // "strictBindCallApply": true, /* Проверять, что аргументы для методов 'bind', 'call' и 'apply' соответствуют исходной функции. */
// "strictPropertyInitialization": true, /* Check for class properties that are declared but not set in the constructor. */ // "strictPropertyInitialization": true, /* Проверять свойства классов, которые объявлены, но не установлены в конструкторе. */
// "noImplicitThis": true, /* Enable error reporting when 'this' is given the type 'any'. */ // "noImplicitThis": true, /* Включить сообщения об ошибках, когда 'this' получает тип 'any'. */
// "useUnknownInCatchVariables": true, /* Default catch clause variables as 'unknown' instead of 'any'. */ // "useUnknownInCatchVariables": true, /* Переменные предложения catch по умолчанию как 'unknown' вместо 'any'. */
// "alwaysStrict": true, /* Ensure 'use strict' is always emitted. */ // "alwaysStrict": true, /* Обеспечить, чтобы 'use strict' всегда генерировался. */
// "noUnusedLocals": true, /* Enable error reporting when local variables aren't read. */ // "noUnusedLocals": true, /* Включить сообщения об ошибках, когда локальные переменные не читаются. */
// "noUnusedParameters": true, /* Raise an error when a function parameter isn't read. */ // "noUnusedParameters": true, /* Вызывать ошибку, когда параметр функции не читается. */
// "exactOptionalPropertyTypes": true, /* Interpret optional property types as written, rather than adding 'undefined'. */ // "exactOptionalPropertyTypes": true, /* Интерпретировать типы необязательных свойств как написано, а не добавлять 'undefined'. */
// "noImplicitReturns": true, /* Enable error reporting for codepaths that do not explicitly return in a function. */ // "noImplicitReturns": true, /* Включить сообщения об ошибках для путей кода, которые не возвращают явно в функции. */
// "noFallthroughCasesInSwitch": true, /* Enable error reporting for fallthrough cases in switch statements. */ // "noFallthroughCasesInSwitch": true, /* Включить сообщения об ошибках для случаев провала в операторах switch. */
// "noUncheckedIndexedAccess": true, /* Add 'undefined' to a type when accessed using an index. */ // "noUncheckedIndexedAccess": true, /* Добавлять 'undefined' к типу при доступе с использованием индекса. */
// "noImplicitOverride": true, /* Ensure overriding members in derived classes are marked with an override modifier. */ // "noImplicitOverride": true, /* Обеспечить, чтобы переопределяющие члены в производных классах были помечены модификатором override. */
// "noPropertyAccessFromIndexSignature": true, /* Enforces using indexed accessors for keys declared using an indexed type. */ // "noPropertyAccessFromIndexSignature": true, /* Принуждает использовать индексированные аксессоры для ключей, объявленных с использованием индексированного типа. */
// "allowUnusedLabels": true, /* Disable error reporting for unused labels. */ // "allowUnusedLabels": true, /* Отключить сообщения об ошибках для неиспользуемых меток. */
// "allowUnreachableCode": true, /* Disable error reporting for unreachable code. */ // "allowUnreachableCode": true, /* Отключить сообщения об ошибках для недостижимого кода. */
/* Completeness */ /* Полнота */
// "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */ // "skipDefaultLibCheck": true, /* Пропускать проверку типов .d.ts файлов, которые включены в TypeScript. */
"skipLibCheck": true /* Skip type checking all .d.ts files. */ "skipLibCheck": true /* Пропускать проверку типов всех .d.ts файлов. */
}, },
"exclude": [ "exclude": [
"node_modules", "node_modules",