Compare commits
251 Commits
kfu-m-24-1
...
dd75c54b32
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dd75c54b32 | ||
|
|
f6f9163c3f | ||
|
|
4c166a8d33 | ||
| 284be82e1e | |||
| 41b5cb6fae | |||
| c4664edd7e | |||
| 69eddf47db | |||
| 71f3f353ab | |||
| 0d1dcf21c1 | |||
| 35493a09b5 | |||
| 390d97e6d5 | |||
| eca5cba858 | |||
| 6c190b80fb | |||
| a6065dd95c | |||
| 99127c42e2 | |||
| 599ccd1582 | |||
| 2b5e5564c8 | |||
| 7937be469b | |||
| 9f72d5885e | |||
| f65fd175ca | |||
|
|
d049c29f93 | ||
|
|
351ea75072 | ||
|
|
34163788f3 | ||
|
|
4ef4dd3c1b | ||
|
|
80498a0ff0 | ||
|
|
00386cc135 | ||
|
|
f5faae7907 | ||
|
|
659f9fd684 | ||
|
|
256de78e64 | ||
|
|
1500486cd8 | ||
|
|
63a825f153 | ||
|
|
1383e360a1 | ||
|
|
ca01d1c538 | ||
|
|
a315c8d4ef | ||
|
|
5ac9559b8f | ||
|
|
7b9e7d0a99 | ||
|
|
63b25928ff | ||
|
|
7d3b563759 | ||
|
|
baba20c028 | ||
|
|
87a9b8b02d | ||
|
|
cc41fa73cd | ||
|
|
ba923b9f91 | ||
|
|
cede47157e | ||
|
|
279c4fc86d | ||
|
|
c2f8d6ecee | ||
|
|
b4858efa73 | ||
|
|
6154932d9e | ||
|
|
d4cc85f644 | ||
|
|
82e8b785c4 | ||
|
|
5785e50cc5 | ||
|
|
de101348fc | ||
|
|
f442544912 | ||
|
|
d09dbcb697 | ||
|
|
f25bae1a08 | ||
|
|
800b60fb6d | ||
|
|
36558dfb85 | ||
|
|
c11bcd5d26 | ||
|
|
8450cc2d4d | ||
|
|
b1a9ee1403 | ||
|
|
80b9d9c8c8 | ||
|
|
db6665736a | ||
|
|
81980fa011 | ||
|
|
ac5f3eee96 | ||
|
|
9d87f7479c | ||
|
|
3639524fc7 | ||
|
|
f66114b22f | ||
|
|
8090de8031 | ||
|
|
081d663711 | ||
|
|
4fe16e5aa8 | ||
|
|
1fd5495570 | ||
|
|
9d68ee735a | ||
|
|
076e51c53a | ||
|
|
409a315a25 | ||
|
|
7a3264d43d | ||
|
|
effa320fa8 | ||
|
|
cc2a66367d | ||
|
|
989b5b010e | ||
|
|
f0e7ba94d2 | ||
|
|
3739fc8449 | ||
|
|
a74d191b30 | ||
|
|
a391cc88c9 | ||
|
|
12f8e63390 | ||
|
|
37238a1385 | ||
|
|
48cd044131 | ||
|
|
5665c4bf1e | ||
|
|
ad35d47ff5 | ||
|
|
f13cdd82df | ||
|
|
d6ebe10421 | ||
|
|
6e59e801b0 | ||
|
|
5dafd60299 | ||
|
|
825d7f1dd2 | ||
|
|
a3ea53c2f0 | ||
|
|
f37f34d803 | ||
|
|
bd0b11dc4a | ||
|
|
b36106cc8c | ||
|
|
07d35c4516 | ||
|
|
471cbacb66 | ||
|
|
229b181972 | ||
|
|
72615c7b98 | ||
|
|
45cafbee91 | ||
|
|
580651094f | ||
|
|
0ee92e98b2 | ||
|
|
3d8d9ee171 | ||
|
|
bde67dc7c3 | ||
|
|
a7be793608 | ||
|
|
ca81e19d14 | ||
|
|
7bd82fedce | ||
|
|
1aeb62d490 | ||
|
|
5886270e29 | ||
|
|
8f544d5c99 | ||
|
|
8dd8ec8930 | ||
|
|
3af82f7478 | ||
|
|
39a62818e9 | ||
|
|
24ff712306 | ||
|
|
ec6b30e220 | ||
|
|
548dbfcc9d | ||
|
|
09174abaa4 | ||
|
|
7ecb73ac6e | ||
|
|
8ade320440 | ||
|
|
bffa3fa2a3 | ||
|
|
4cf29c97b9 | ||
|
|
9377771531 | ||
|
|
0a96a87f94 | ||
|
|
5c14212429 | ||
|
|
e49d38657d | ||
|
|
1c7d1fc1ae | ||
|
|
7503d076e8 | ||
|
|
04f70aaa45 | ||
|
|
7b2b7b477f | ||
|
|
da7e25d339 | ||
|
|
b9f6e4d7aa | ||
|
|
396633932b | ||
|
|
46ad6ea9f3 | ||
|
|
1fa09ecac3 | ||
|
|
18b33ae10a | ||
|
|
e4e00184a5 | ||
|
|
9177765e8c | ||
|
|
0c0c62fe1b | ||
|
|
a0c9c5bab1 | ||
|
|
01b6e4ae72 | ||
|
|
2e36ee6e8b | ||
|
|
18cfa427d2 | ||
|
|
904a227adb | ||
|
|
23e532b770 | ||
|
|
f658e1f828 | ||
|
|
0500497fc1 | ||
|
|
ea691536ac | ||
|
|
c251a640b6 | ||
|
|
8031938b2f | ||
|
|
ca4bfdade4 | ||
|
|
b5f6f6d30f | ||
|
|
36107afbc2 | ||
|
|
539b1d2277 | ||
|
|
a9490da5a6 | ||
|
|
845e57d688 | ||
|
|
6835c84cc4 | ||
|
|
337e3ee2bf | ||
|
|
72d298ef2f | ||
|
|
d410164941 | ||
|
|
6b5ae7bce1 | ||
|
|
d80c4efb49 | ||
|
|
ddcf27b022 | ||
|
|
26c53e7455 | ||
|
|
0fbbe33e8a | ||
|
|
687508d26f | ||
|
|
f89729dbeb | ||
|
|
d90fee82d5 | ||
|
|
bde6ab4c7a | ||
|
|
2d0b97be44 | ||
|
|
3c22354130 | ||
|
|
ab555cd70e | ||
|
|
95bcaf3c5e | ||
|
|
48167530fd | ||
|
|
f909d90b6f | ||
|
|
e7d114a9d9 | ||
|
|
b83e0d603c | ||
|
|
7f57b2a4d3 | ||
|
|
c8f7e47181 | ||
|
|
e5d6b7cecd | ||
|
|
8a1868482c | ||
|
|
1bf68cea08 | ||
|
|
110e8300a1 | ||
| f3566361fb | |||
| a63a229b64 | |||
| 8944508308 | |||
| 775f24cffa | |||
|
|
78b72b0edc | ||
|
|
333fe79c8b | ||
|
|
9d10c8501a | ||
|
|
d64ece382a | ||
|
|
f91f821f86 | ||
|
|
b5301f948a | ||
|
|
dd589790c2 | ||
|
|
1fcc5ed70d | ||
|
|
41dbe81001 | ||
|
|
7b685ad99e | ||
| 2f1e1dc040 | |||
|
|
70e8a6877c | ||
|
|
87fd3121f9 | ||
|
|
4f9434163e | ||
|
|
350d452a7b | ||
| 9a0669df13 | |||
|
|
c0883fc2bc | ||
| 566bce4663 | |||
| c828718498 | |||
|
|
69c280b266 | ||
| 6794b01ac8 | |||
| 1cb586f55a | |||
|
|
df21879c0d | ||
|
|
30c9c86c93 | ||
|
|
2925d0f17b | ||
|
|
752dabd015 | ||
|
|
815f11d5bc | ||
| 02eb0e60b7 | |||
| a64ac93935 | |||
| 66a48d1c7e | |||
| 26c66f16b4 | |||
| 02e50bb2f9 | |||
| fadc62c8f0 | |||
| 4759f6f7ee | |||
| 14f2164a82 | |||
| 14ef1f9bad | |||
| dc99318ff0 | |||
| d2fc5f4d5c | |||
| 938bd48fff | |||
| 96f819dc91 | |||
| 25eee8adf5 | |||
| d2b2a29d3d | |||
| 1cf71261d1 | |||
| 88552eb04f | |||
| ab92c99321 | |||
| 02963de893 | |||
| 48550416d9 | |||
| 878c5ffd68 | |||
|
|
6e37fe93f7 | ||
|
|
72a2667549 | ||
|
|
39db7b4d26 | ||
| ff25c0ecb9 | |||
|
|
f1a93bffb5 | ||
| aa231d4f43 | |||
|
|
f254d57db4 | ||
| 106f835934 | |||
| f9b30a4cfd | |||
| 5e4a99529d | |||
| 4d585002d7 | |||
| b073fe3fdf | |||
| 312cc229d8 | |||
| 11b1d670d0 | |||
| 771f75ef08 | |||
|
|
edf9b2c82b | ||
| a88d3657bf |
9
.env.example
Normal file
9
.env.example
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
# Application settings
|
||||||
|
TZ=Europe/Moscow
|
||||||
|
APP_PORT=8044
|
||||||
|
|
||||||
|
MONGO_INITDB_ROOT_USERNAME=qqq
|
||||||
|
MONGO_INITDB_ROOT_PASSWORD=qqq
|
||||||
|
|
||||||
|
# MongoDB connection string
|
||||||
|
MONGO_ADDR=mongodb://qqq:qqq@127.0.0.1:27018
|
||||||
34
Dockerfile
34
Dockerfile
@@ -1,16 +1,38 @@
|
|||||||
FROM node:20
|
FROM node:22 AS builder
|
||||||
|
|
||||||
|
WORKDIR /usr/src/app/
|
||||||
|
|
||||||
|
# Сначала копируем только файлы, необходимые для установки зависимостей
|
||||||
|
COPY ./package.json /usr/src/app/package.json
|
||||||
|
COPY ./package-lock.json /usr/src/app/package-lock.json
|
||||||
|
|
||||||
|
# Устанавливаем все зависимости
|
||||||
|
RUN npm ci
|
||||||
|
|
||||||
|
# Затем копируем исходный код проекта и файлы конфигурации
|
||||||
|
COPY ./tsconfig.json /usr/src/app/tsconfig.json
|
||||||
|
COPY ./server /usr/src/app/server
|
||||||
|
|
||||||
|
# Сборка проекта
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
# Вторая стадия - рабочий образ
|
||||||
|
FROM node:22
|
||||||
|
|
||||||
RUN mkdir -p /usr/src/app/server/log/
|
RUN mkdir -p /usr/src/app/server/log/
|
||||||
WORKDIR /usr/src/app/
|
WORKDIR /usr/src/app/
|
||||||
|
|
||||||
COPY ./server /usr/src/app/server
|
# Копирование только package.json/package-lock.json для продакшн зависимостей
|
||||||
COPY ./package.json /usr/src/app/package.json
|
COPY ./package.json /usr/src/app/package.json
|
||||||
COPY ./package-lock.json /usr/src/app/package-lock.json
|
COPY ./package-lock.json /usr/src/app/package-lock.json
|
||||||
COPY ./.serverrc.js /usr/src/app/.serverrc.js
|
|
||||||
# COPY ./.env /usr/src/app/.env
|
|
||||||
|
|
||||||
# RUN npm i --omit=dev
|
# Установка только продакшн зависимостей
|
||||||
RUN npm ci
|
RUN npm ci --production
|
||||||
|
|
||||||
|
# Копирование собранного приложения из билдера
|
||||||
|
COPY --from=builder /usr/src/app/dist /usr/src/app/dist
|
||||||
|
COPY --from=builder /usr/src/app/server /usr/src/app/server
|
||||||
|
|
||||||
EXPOSE 8044
|
EXPOSE 8044
|
||||||
|
|
||||||
CMD ["npm", "run", "up:prod"]
|
CMD ["npm", "run", "up:prod"]
|
||||||
|
|||||||
@@ -1,6 +1,12 @@
|
|||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
|
|
||||||
docker stop ms-mongo
|
docker stop ms-mongo
|
||||||
docker volume remove ms_volume
|
docker volume remove ms_volume8
|
||||||
docker volume create ms_volume
|
docker volume create ms_volume8
|
||||||
docker run --rm -v ms_volume:/data/db --name ms-mongo -p 27017:27017 -d mongo:8.0.3
|
docker run --rm \
|
||||||
|
-v ms_volume8:/data/db \
|
||||||
|
--name ms-mongo \
|
||||||
|
-p 27018:27017 \
|
||||||
|
-e MONGO_INITDB_ROOT_USERNAME=qqq \
|
||||||
|
-e MONGO_INITDB_ROOT_PASSWORD=qqq \
|
||||||
|
-d mongo:8.0.3
|
||||||
|
|||||||
@@ -1,25 +0,0 @@
|
|||||||
version: "3"
|
|
||||||
|
|
||||||
volumes:
|
|
||||||
ms_volume8:
|
|
||||||
ms_logs:
|
|
||||||
|
|
||||||
services:
|
|
||||||
mongoDb:
|
|
||||||
image: mongo:8.0.3
|
|
||||||
volumes:
|
|
||||||
- ms_volume8:/data/db
|
|
||||||
restart: always
|
|
||||||
# ports:
|
|
||||||
# - 27017:27017
|
|
||||||
multy-stubs:
|
|
||||||
# build: .
|
|
||||||
image: bro.js/ms/bh:$TAG
|
|
||||||
restart: always
|
|
||||||
volumes:
|
|
||||||
- ms_logs:/usr/src/app/server/log
|
|
||||||
ports:
|
|
||||||
- 8044:8044
|
|
||||||
environment:
|
|
||||||
- TZ=Europe/Moscow
|
|
||||||
- MONGO_ADDR=mongodb
|
|
||||||
30
docker-compose.yml
Normal file
30
docker-compose.yml
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
version: "3"
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
ms_volume8:
|
||||||
|
ms_logs:
|
||||||
|
|
||||||
|
services:
|
||||||
|
multy-stubs:
|
||||||
|
image: bro.js/ms/bh:$TAG
|
||||||
|
restart: always
|
||||||
|
volumes:
|
||||||
|
- ms_logs:/usr/src/app/server/log
|
||||||
|
ports:
|
||||||
|
- 8044:8044
|
||||||
|
environment:
|
||||||
|
- TZ=Europe/Moscow
|
||||||
|
- MONGO_ADDR=${MONGO_ADDR}
|
||||||
|
# depends_on:
|
||||||
|
# mongoDb:
|
||||||
|
# condition: service_started
|
||||||
|
# mongoDb:
|
||||||
|
# image: mongo:8.0.3
|
||||||
|
# volumes:
|
||||||
|
# - ms_volume8:/data/db
|
||||||
|
# restart: always
|
||||||
|
# environment:
|
||||||
|
# - MONGO_INITDB_ROOT_USERNAME=${MONGO_INITDB_ROOT_USERNAME}
|
||||||
|
# - MONGO_INITDB_ROOT_PASSWORD=${MONGO_INITDB_ROOT_PASSWORD}
|
||||||
|
# ports:
|
||||||
|
# - 27018:27017
|
||||||
@@ -4,7 +4,7 @@ import pluginJs from "@eslint/js";
|
|||||||
|
|
||||||
export default [
|
export default [
|
||||||
{ ignores: ['server/routers/old/*'] },
|
{ ignores: ['server/routers/old/*'] },
|
||||||
{ files: ["**/*.js"], languageOptions: { sourceType: "commonjs" } },
|
{ files: ["**/*.js"], languageOptions: { } },
|
||||||
{ languageOptions: { globals: globals.node } },
|
{ languageOptions: { globals: globals.node } },
|
||||||
pluginJs.configs.recommended,
|
pluginJs.configs.recommended,
|
||||||
{
|
{
|
||||||
|
|||||||
2972
package-lock.json
generated
2972
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
34
package.json
34
package.json
@@ -1,15 +1,13 @@
|
|||||||
{
|
{
|
||||||
"name": "multi-stub",
|
"name": "multi-stub",
|
||||||
"version": "1.2.0",
|
"version": "2.0.0",
|
||||||
"description": "",
|
"description": "",
|
||||||
"main": "index.js",
|
"main": "server/index.ts",
|
||||||
|
"type": "commonjs",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "cross-env PORT=8033 npx nodemon ./server",
|
"start": "cross-env NODE_ENV=\"development\" ts-node-dev .",
|
||||||
"up:prod": "cross-env NODE_ENV=\"production\" node ./server",
|
"build": "tsc",
|
||||||
"deploy:d:stop": "docker compose down",
|
"up:prod": "node dist/server/index.js",
|
||||||
"deploy:d:build": "docker compose build",
|
|
||||||
"deploy:d:up": "docker compose up -d",
|
|
||||||
"redeploy": "npm run deploy:d:stop && npm run deploy:d:build && npm run deploy:d:up",
|
|
||||||
"eslint": "npx eslint ./server",
|
"eslint": "npx eslint ./server",
|
||||||
"eslint:fix": "npx eslint ./server --fix",
|
"eslint:fix": "npx eslint ./server --fix",
|
||||||
"test": "jest"
|
"test": "jest"
|
||||||
@@ -23,9 +21,13 @@
|
|||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"homepage": "https://bitbucket.org/online-mentor/multi-stub#readme",
|
"homepage": "https://bitbucket.org/online-mentor/multi-stub#readme",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@langchain/community": "^0.3.56",
|
||||||
|
"@langchain/core": "^0.3.77",
|
||||||
|
"@langchain/langgraph": "^0.4.9",
|
||||||
"ai": "^4.1.13",
|
"ai": "^4.1.13",
|
||||||
"axios": "^1.7.7",
|
"axios": "^1.7.7",
|
||||||
"bcrypt": "^5.1.0",
|
"bcrypt": "^5.1.0",
|
||||||
|
"bcryptjs": "^3.0.3",
|
||||||
"body-parser": "^1.19.0",
|
"body-parser": "^1.19.0",
|
||||||
"cookie-parser": "^1.4.5",
|
"cookie-parser": "^1.4.5",
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
@@ -35,16 +37,20 @@
|
|||||||
"express": "5.0.1",
|
"express": "5.0.1",
|
||||||
"express-jwt": "^8.5.1",
|
"express-jwt": "^8.5.1",
|
||||||
"express-session": "^1.18.1",
|
"express-session": "^1.18.1",
|
||||||
|
"gigachat": "^0.0.16",
|
||||||
"jsdom": "^25.0.1",
|
"jsdom": "^25.0.1",
|
||||||
"jsonwebtoken": "^9.0.2",
|
"jsonwebtoken": "^9.0.2",
|
||||||
"mongodb": "^6.12.0",
|
"langchain": "^0.3.34",
|
||||||
"mongoose": "^8.9.2",
|
"langchain-gigachat": "^0.0.14",
|
||||||
|
"mongodb": "^6.20.0",
|
||||||
|
"mongoose": "^8.18.2",
|
||||||
"mongoose-sequence": "^6.0.1",
|
"mongoose-sequence": "^6.0.1",
|
||||||
"morgan": "^1.10.0",
|
"morgan": "^1.10.1",
|
||||||
|
"multer": "^1.4.5-lts.1",
|
||||||
"pbkdf2-password": "^1.2.1",
|
"pbkdf2-password": "^1.2.1",
|
||||||
"rotating-file-stream": "^3.2.5",
|
"rotating-file-stream": "^3.2.5",
|
||||||
"socket.io": "^4.8.1",
|
"socket.io": "^4.8.1",
|
||||||
"uuid": "^11.0.3"
|
"zod": "^3.24.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.17.0",
|
"@eslint/js": "^9.17.0",
|
||||||
@@ -54,6 +60,8 @@
|
|||||||
"jest": "^29.7.0",
|
"jest": "^29.7.0",
|
||||||
"mockingoose": "^2.16.2",
|
"mockingoose": "^2.16.2",
|
||||||
"nodemon": "3.1.9",
|
"nodemon": "3.1.9",
|
||||||
"supertest": "^7.0.0"
|
"supertest": "^7.0.0",
|
||||||
|
"ts-node-dev": "2.0.0",
|
||||||
|
"typescript": "5.7.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
87
rules.md
Normal file
87
rules.md
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
## Правила оформления студенческих бэкендов в `multi-stub`
|
||||||
|
|
||||||
|
Этот документ описывает, как подключать новый студенческий бэкенд к общему серверу и как работать с JSON‑заглушками. Правила написаны так, чтобы их мог автоматически выполнять помощник Cursor.
|
||||||
|
|
||||||
|
### 1. Общая структура проекта студента
|
||||||
|
|
||||||
|
- **Размещение проекта**
|
||||||
|
- Каждый студенческий бэкенд живёт в своей подпапке в `server/routers/<project-name>`.
|
||||||
|
- В корне подпапки должен быть основной файл роутера `index.js` (или `index.ts`), который экспортирует `express.Router()`.
|
||||||
|
- Подключение к общему серверу выполняется в `server/index.ts` через импорт и `app.use(<mountPath>, <router>)`.
|
||||||
|
|
||||||
|
- **Использование JSON‑заглушек**
|
||||||
|
- Если проект переносится из фронтенд‑репозитория и должен только отдавать данные, то в подпапке проекта должна быть папка `json/` со всеми нужными `.json` файлами.
|
||||||
|
- HTTP‑обработчики в роутере могут просто читать и возвращать содержимое этих файлов (например, через `require('./json/...')` или `import data from './json/...json'` с включённым `resolveJsonModule` / соответствующей конфигурацией bundler'а).
|
||||||
|
|
||||||
|
### 2. Правила для Cursor при указании директории заглушек
|
||||||
|
|
||||||
|
Когда пользователь явно указывает директорию с заглушками (например: `server/routers/<project-name>/json`), помощник Cursor должен последовательно выполнить следующие шаги.
|
||||||
|
|
||||||
|
- **2.1. Проверка валидности импортов JSON‑файлов**
|
||||||
|
- Найти все `.js` / `.ts` файлы внутри подпапки проекта.
|
||||||
|
- В каждом таком файле найти импорты/require, которые ссылаются на `.json` файлы (относительные пути вроде `'./json/.../file.json'`).
|
||||||
|
- Для каждого такого импорта:
|
||||||
|
- **Проверить, что файл реально существует** по указанному пути относительно файла-импортёра.
|
||||||
|
- **Проверить расширение**: путь должен заканчиваться на `.json` (без опечаток).
|
||||||
|
- **Проверить регистр и точное совпадение имени файла** (важно для кросс‑платформенности, даже если локально используется Windows).
|
||||||
|
- Если найдены ошибки (файл не существует, опечатка в имени, неправильный относительный путь и т.п.):
|
||||||
|
- Сформировать понятный список проблем: в каком файле, какая строка/импорт и что именно не так.
|
||||||
|
- Предложить автоматически исправить пути (если по контексту можно однозначно угадать нужный `*.json` файл).
|
||||||
|
|
||||||
|
- **2.2. Проверка подключения основного роутера проекта**
|
||||||
|
- Определить основной файл роутера проекта:
|
||||||
|
- По умолчанию это `server/routers/<project-name>/index.js` (или `index.ts`).
|
||||||
|
- Открыть `server/index.ts` и убедиться, что:
|
||||||
|
- Есть импорт роутера из соответствующей подпапки, например:
|
||||||
|
- `import <SomeUniqueName>Router from './routers/<project-name>'`
|
||||||
|
- или `const <SomeUniqueName>Router = require('./routers/<project-name>')`
|
||||||
|
- Имя переменной роутера **уникально** среди всех импортов роутеров (нет другого импорта с таким же именем).
|
||||||
|
- Есть вызов `app.use('<mount-path>', <SomeUniqueName>Router)`:
|
||||||
|
- `<mount-path>` должен быть осмысленным, совпадать с названием проекта или оговариваться пользователем.
|
||||||
|
- Если импорт или `app.use` отсутствуют:
|
||||||
|
- Сформировать предложение по добавлению корректного импорта и `app.use(...)`.
|
||||||
|
- Убедиться, что используемое имя роутера не конфликтует с уже существующими.
|
||||||
|
- Если обнаружен конфликт имён:
|
||||||
|
- Предложить переименовать новый роутер в уникальное имя и обновить соответствующие места в `server/index.ts`.
|
||||||
|
|
||||||
|
### 3. Предложение «оживить» JSON‑заглушки
|
||||||
|
|
||||||
|
После того как проверка импортов и подключения роутера завершена, помощник Cursor должен **задать пользователю вопрос**, не хочет ли он превратить заглушки в полноценный бэкенд.
|
||||||
|
|
||||||
|
- **3.1. Формулировка предложения**
|
||||||
|
- Спросить у пользователя примерно так:
|
||||||
|
- «Обнаружены JSON‑заглушки в директории `<указанная-папка>`. Хотите, чтобы я попытался автоматически:
|
||||||
|
1) построить модели данных (mongoose‑схемы) на основе структуры JSON;
|
||||||
|
2) создать CRUD‑эндпоинты и/или более сложные маршруты, опираясь на существующие данные;
|
||||||
|
3) заменить прямую отдачу `*.json` файлов на работу через базу данных?»
|
||||||
|
|
||||||
|
- **3.2. Поведение при согласии пользователя**
|
||||||
|
- Проанализировать структуру JSON‑файлов:
|
||||||
|
- Определить основные сущности и поля.
|
||||||
|
- Выделить типы полей (строки, числа, даты, массивы, вложенные объекты и т.п.).
|
||||||
|
- На основе анализа предложить:
|
||||||
|
- Набор `mongoose`‑схем (`models`) с аккуратной сериализацией (виртуальное поле `id`, скрытие `_id` и `__v`).
|
||||||
|
- Набор маршрутов `express` для работы с этими моделями (минимум: чтение списков и элементов; по возможности — создание/обновление/удаление).
|
||||||
|
- Перед внесением изменений:
|
||||||
|
- Показать пользователю краткий план того, какие файлы будут созданы/изменены.
|
||||||
|
- Выполнить изменения только после явного подтверждения пользователя.
|
||||||
|
|
||||||
|
### 4. Минимальные требования к новому студенческому бэкенду
|
||||||
|
|
||||||
|
- **Обязательные элементы**
|
||||||
|
- Подпапка в `server/routers/<project-name>`.
|
||||||
|
- Основной роутер `index.js` / `index.ts`, экспортирующий `express.Router()`.
|
||||||
|
- Подключение к общему серверу в `server/index.ts` (импорт + `app.use()` с уникальным именем роутера).
|
||||||
|
|
||||||
|
- **Если используются JSON‑заглушки**
|
||||||
|
- Папка `json/` внутри проекта.
|
||||||
|
- Все пути в импортирующих файлах должны указывать на реально существующие `*.json` файлы.
|
||||||
|
- Не должно быть «магических» абсолютных путей; только относительные пути от файла до нужного JSON.
|
||||||
|
|
||||||
|
- **Если проект «оживлён»**
|
||||||
|
- Папка `model/` с моделью(ями) данных (например, через `mongoose`).
|
||||||
|
- Роуты, которые вместо прямой отдачи файлов работают с моделями и, при необходимости, с внешними сервисами.
|
||||||
|
|
||||||
|
Следуя этим правилам, можно подключать новые студенческие проекты в единый бэкенд, минимизировать типичные ошибки с путями к JSON и упростить автоматическое развитие заглушек до полноценного API.
|
||||||
|
|
||||||
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
const noToken = 'No authorization token was found'
|
|
||||||
|
|
||||||
module.exports = (err, req, res, next) => {
|
|
||||||
if (err.message === noToken) {
|
|
||||||
res.status(400).send({
|
|
||||||
success: false, error: 'Токен авторизации не найден',
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
res.status(400).send({
|
|
||||||
success: false, error: err.message || 'Что-то пошло не так',
|
|
||||||
})
|
|
||||||
}
|
|
||||||
28
server/error.ts
Normal file
28
server/error.ts
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import { ErrorLog } from './models/ErrorLog'
|
||||||
|
|
||||||
|
const noToken = 'No authorization token was found'
|
||||||
|
|
||||||
|
export const errorHandler = (err, req, res, next) => {
|
||||||
|
// Сохраняем ошибку в базу данных
|
||||||
|
const errorLog = new ErrorLog({
|
||||||
|
message: err.message || 'Неизвестная ошибка',
|
||||||
|
stack: err.stack,
|
||||||
|
path: req.path,
|
||||||
|
method: req.method,
|
||||||
|
query: req.query,
|
||||||
|
body: req.body
|
||||||
|
})
|
||||||
|
|
||||||
|
errorLog.save()
|
||||||
|
.catch(saveErr => console.error('Ошибка при сохранении лога ошибки:', saveErr))
|
||||||
|
|
||||||
|
if (err.message === noToken) {
|
||||||
|
res.status(400).send({
|
||||||
|
success: false, error: 'Токен авторизации не найден',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
res.status(400).send({
|
||||||
|
success: false, error: err.message || 'Что-то пошло не так',
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -1,98 +0,0 @@
|
|||||||
const express = require("express")
|
|
||||||
const bodyParser = require("body-parser")
|
|
||||||
const cookieParser = require("cookie-parser")
|
|
||||||
const session = require("express-session")
|
|
||||||
const morgan = require("morgan")
|
|
||||||
const path = require("path")
|
|
||||||
const rfs = require("rotating-file-stream")
|
|
||||||
|
|
||||||
const app = express()
|
|
||||||
require("dotenv").config()
|
|
||||||
exports.app = app
|
|
||||||
|
|
||||||
const accessLogStream = rfs.createStream("access.log", {
|
|
||||||
size: "10M",
|
|
||||||
interval: "1d",
|
|
||||||
compress: "gzip",
|
|
||||||
path: path.join(__dirname, "log"),
|
|
||||||
})
|
|
||||||
|
|
||||||
const errorLogStream = rfs.createStream("error.log", {
|
|
||||||
size: "10M",
|
|
||||||
interval: "1d",
|
|
||||||
compress: "gzip",
|
|
||||||
path: path.join(__dirname, "log"),
|
|
||||||
})
|
|
||||||
|
|
||||||
const config = require("../.serverrc")
|
|
||||||
const { setIo } = require("./io")
|
|
||||||
|
|
||||||
app.use(cookieParser())
|
|
||||||
app.use(
|
|
||||||
morgan("combined", {
|
|
||||||
stream: accessLogStream,
|
|
||||||
skip: function (req, res) {
|
|
||||||
return res.statusCode >= 400
|
|
||||||
},
|
|
||||||
})
|
|
||||||
)
|
|
||||||
|
|
||||||
// log all requests to access.log
|
|
||||||
app.use(
|
|
||||||
morgan("combined", {
|
|
||||||
stream: errorLogStream,
|
|
||||||
skip: function (req, res) {
|
|
||||||
console.log('statusCode', res.statusCode, res.statusCode <= 400)
|
|
||||||
return res.statusCode < 400
|
|
||||||
},
|
|
||||||
})
|
|
||||||
)
|
|
||||||
|
|
||||||
const server = setIo(app)
|
|
||||||
|
|
||||||
const sess = {
|
|
||||||
secret: "super-secret-key",
|
|
||||||
resave: true,
|
|
||||||
saveUninitialized: true,
|
|
||||||
cookie: {},
|
|
||||||
}
|
|
||||||
if (app.get("env") === "production") {
|
|
||||||
app.set("trust proxy", 1)
|
|
||||||
sess.cookie.secure = true
|
|
||||||
}
|
|
||||||
app.use(session(sess))
|
|
||||||
|
|
||||||
app.use(
|
|
||||||
bodyParser.json({
|
|
||||||
limit: "50mb",
|
|
||||||
})
|
|
||||||
)
|
|
||||||
app.use(
|
|
||||||
bodyParser.urlencoded({
|
|
||||||
limit: "50mb",
|
|
||||||
extended: true,
|
|
||||||
})
|
|
||||||
)
|
|
||||||
app.use(require("./root"))
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Добавляйте сюда свои routers.
|
|
||||||
*/
|
|
||||||
app.use("/kfu-m-24-1", require("./routers/kfu-m-24-1"))
|
|
||||||
app.use("/epja-2024-1", require("./routers/epja-2024-1"))
|
|
||||||
app.use("/v1/todo", require("./routers/todo"))
|
|
||||||
app.use("/dogsitters-finder", require("./routers/dogsitters-finder"))
|
|
||||||
app.use("/kazan-explore", require("./routers/kazan-explore"))
|
|
||||||
app.use("/edateam", require("./routers/edateam-legacy"))
|
|
||||||
app.use("/dry-wash", require("./routers/dry-wash"))
|
|
||||||
app.use("/freetracker", require("./routers/freetracker"))
|
|
||||||
app.use("/dhs-testing", require("./routers/dhs-testing"))
|
|
||||||
app.use("/gamehub", require("./routers/gamehub"))
|
|
||||||
app.use("/esc", require("./routers/esc"))
|
|
||||||
app.use('/connectme', require('./routers/connectme'))
|
|
||||||
|
|
||||||
app.use(require("./error"))
|
|
||||||
|
|
||||||
server.listen(config.port, () =>
|
|
||||||
console.log(`Listening on http://localhost:${config.port}`)
|
|
||||||
)
|
|
||||||
155
server/index.ts
Normal file
155
server/index.ts
Normal file
@@ -0,0 +1,155 @@
|
|||||||
|
import express from 'express'
|
||||||
|
import cookieParser from 'cookie-parser'
|
||||||
|
import session from 'express-session'
|
||||||
|
import morgan from 'morgan'
|
||||||
|
import path from 'path'
|
||||||
|
import 'dotenv/config'
|
||||||
|
|
||||||
|
import root from './server'
|
||||||
|
import { errorHandler } from './error'
|
||||||
|
import kfuM241Router from './routers/kfu-m-24-1'
|
||||||
|
import epja20241Router from './routers/epja-2024-1'
|
||||||
|
import todoRouter from './routers/todo'
|
||||||
|
import dogsittersFinderRouter from './routers/dogsitters-finder'
|
||||||
|
import kazanExploreRouter from './routers/kazan-explore'
|
||||||
|
import edateamRouter from './routers/edateam-legacy'
|
||||||
|
import dryWashRouter from './routers/dry-wash'
|
||||||
|
import freetrackerRouter from './routers/freetracker'
|
||||||
|
import dhsTestingRouter from './routers/dhs-testing'
|
||||||
|
import gamehubRouter from './routers/gamehub'
|
||||||
|
import escRouter from './routers/esc'
|
||||||
|
import connectmeRouter from './routers/connectme'
|
||||||
|
import questioneerRouter from './routers/questioneer'
|
||||||
|
import procurementRouter from './routers/procurement'
|
||||||
|
import smokeTrackerRouter from './routers/smoke-tracker'
|
||||||
|
import { setIo } from './io'
|
||||||
|
|
||||||
|
export const app = express()
|
||||||
|
|
||||||
|
// Динамический импорт rotating-file-stream
|
||||||
|
const initServer = async () => {
|
||||||
|
const rfs = await import('rotating-file-stream')
|
||||||
|
const accessLogStream = rfs.createStream("access.log", {
|
||||||
|
size: "10M",
|
||||||
|
interval: "1d",
|
||||||
|
compress: "gzip",
|
||||||
|
path: path.join(__dirname, "log"),
|
||||||
|
})
|
||||||
|
|
||||||
|
const errorLogStream = rfs.createStream("error.log", {
|
||||||
|
size: "10M",
|
||||||
|
interval: "1d",
|
||||||
|
compress: "gzip",
|
||||||
|
path: path.join(__dirname, "log"),
|
||||||
|
})
|
||||||
|
|
||||||
|
app.use(cookieParser())
|
||||||
|
app.use(
|
||||||
|
morgan("combined", {
|
||||||
|
stream: accessLogStream,
|
||||||
|
skip: function (req, res) {
|
||||||
|
return res.statusCode >= 400
|
||||||
|
},
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
// log all requests to access.log
|
||||||
|
app.use(
|
||||||
|
morgan("combined", {
|
||||||
|
stream: errorLogStream,
|
||||||
|
skip: function (req, res) {
|
||||||
|
console.log('statusCode', res.statusCode, res.statusCode <= 400)
|
||||||
|
return res.statusCode < 400
|
||||||
|
},
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
console.log('warming up 🔥')
|
||||||
|
|
||||||
|
const sess = {
|
||||||
|
secret: "super-secret-key",
|
||||||
|
resave: true,
|
||||||
|
saveUninitialized: true,
|
||||||
|
cookie: {},
|
||||||
|
}
|
||||||
|
if (app.get("env") !== "development") {
|
||||||
|
app.set("trust proxy", 1)
|
||||||
|
}
|
||||||
|
app.use(session(sess))
|
||||||
|
|
||||||
|
app.use(
|
||||||
|
express.json({
|
||||||
|
limit: "50mb",
|
||||||
|
})
|
||||||
|
)
|
||||||
|
app.use(
|
||||||
|
express.urlencoded({
|
||||||
|
limit: "50mb",
|
||||||
|
extended: true,
|
||||||
|
})
|
||||||
|
)
|
||||||
|
app.use(root)
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Добавляйте сюда свои routers.
|
||||||
|
*/
|
||||||
|
app.use("/kfu-m-24-1", kfuM241Router)
|
||||||
|
app.use("/epja-2024-1", epja20241Router)
|
||||||
|
app.use("/v1/todo", todoRouter)
|
||||||
|
app.use("/dogsitters-finder", dogsittersFinderRouter)
|
||||||
|
app.use("/kazan-explore", kazanExploreRouter)
|
||||||
|
app.use("/edateam", edateamRouter)
|
||||||
|
app.use("/dry-wash", dryWashRouter)
|
||||||
|
app.use("/freetracker", freetrackerRouter)
|
||||||
|
app.use("/dhs-testing", dhsTestingRouter)
|
||||||
|
app.use("/gamehub", gamehubRouter)
|
||||||
|
app.use("/esc", escRouter)
|
||||||
|
app.use('/connectme', connectmeRouter)
|
||||||
|
app.use('/questioneer', questioneerRouter)
|
||||||
|
app.use('/procurement', procurementRouter)
|
||||||
|
app.use('/smoke-tracker', smokeTrackerRouter)
|
||||||
|
app.use(errorHandler)
|
||||||
|
|
||||||
|
// Создаем обычный HTTP сервер
|
||||||
|
const server = app.listen(process.env.PORT ?? 8044, () => {
|
||||||
|
console.log(`🚀 Сервер запущен на http://localhost:${process.env.PORT ?? 8044}`)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Обработка сигналов завершения процесса
|
||||||
|
process.on('SIGTERM', () => {
|
||||||
|
console.log('🛑 Получен сигнал SIGTERM. Выполняется корректное завершение...')
|
||||||
|
server.close(() => {
|
||||||
|
console.log('✅ Сервер успешно остановлен')
|
||||||
|
process.exit(0)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
process.on('SIGINT', () => {
|
||||||
|
console.log('🛑 Получен сигнал SIGINT. Выполняется корректное завершение...')
|
||||||
|
server.close(() => {
|
||||||
|
console.log('✅ Сервер успешно остановлен')
|
||||||
|
process.exit(0)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// Обработка необработанных исключений
|
||||||
|
process.on('uncaughtException', (err) => {
|
||||||
|
console.error('❌ Необработанное исключение:', err)
|
||||||
|
server.close(() => {
|
||||||
|
process.exit(1)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// Обработка необработанных отклонений промисов
|
||||||
|
process.on('unhandledRejection', (reason, promise) => {
|
||||||
|
console.error('⚠️ Необработанное отклонение промиса:', reason)
|
||||||
|
server.close(() => {
|
||||||
|
process.exit(1)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
return server
|
||||||
|
}
|
||||||
|
|
||||||
|
initServer().catch(console.error)
|
||||||
13
server/io.js
13
server/io.js
@@ -1,13 +0,0 @@
|
|||||||
const { Server } = require('socket.io')
|
|
||||||
const { createServer } = require('http')
|
|
||||||
|
|
||||||
let io = null
|
|
||||||
|
|
||||||
module.exports.setIo = (app) => {
|
|
||||||
const server = createServer(app)
|
|
||||||
io = new Server(server, {})
|
|
||||||
|
|
||||||
return server
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports.getIo = () => io
|
|
||||||
13
server/io.ts
Normal file
13
server/io.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { Server } from 'socket.io'
|
||||||
|
import { createServer } from 'http'
|
||||||
|
|
||||||
|
let io = null
|
||||||
|
|
||||||
|
export const setIo = (app) => {
|
||||||
|
const server = createServer(app)
|
||||||
|
io = new Server(server, {})
|
||||||
|
|
||||||
|
return server
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getIo = () => io
|
||||||
16
server/models/ErrorLog.ts
Normal file
16
server/models/ErrorLog.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import mongoose from 'mongoose'
|
||||||
|
|
||||||
|
const ErrorLogSchema = new mongoose.Schema({
|
||||||
|
message: { type: String, required: true },
|
||||||
|
stack: { type: String },
|
||||||
|
path: { type: String },
|
||||||
|
method: { type: String },
|
||||||
|
query: { type: Object },
|
||||||
|
body: { type: Object },
|
||||||
|
createdAt: { type: Date, default: Date.now },
|
||||||
|
})
|
||||||
|
|
||||||
|
// Индекс для быстрого поиска по дате создания
|
||||||
|
ErrorLogSchema.index({ createdAt: 1 })
|
||||||
|
|
||||||
|
export const ErrorLog = mongoose.model('ErrorLog', ErrorLogSchema)
|
||||||
55
server/models/questionnaire.ts
Normal file
55
server/models/questionnaire.ts
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
const mongoose = require('mongoose');
|
||||||
|
|
||||||
|
// Типы вопросов
|
||||||
|
export const QUESTION_TYPES = {
|
||||||
|
SINGLE_CHOICE: 'single_choice', // Один вариант
|
||||||
|
MULTIPLE_CHOICE: 'multiple_choice', // Несколько вариантов
|
||||||
|
TEXT: 'text', // Текстовый ответ
|
||||||
|
RATING: 'rating', // Оценка по шкале
|
||||||
|
TAG_CLOUD: 'tag_cloud' // Облако тегов
|
||||||
|
};
|
||||||
|
|
||||||
|
// Типы отображения
|
||||||
|
export const DISPLAY_TYPES = {
|
||||||
|
DEFAULT: 'default',
|
||||||
|
TAG_CLOUD: 'tag_cloud',
|
||||||
|
VOTING: 'voting',
|
||||||
|
POLL: 'poll'
|
||||||
|
};
|
||||||
|
|
||||||
|
// Схема варианта ответа
|
||||||
|
const optionSchema = new mongoose.Schema({
|
||||||
|
text: { type: String, required: true },
|
||||||
|
count: { type: Number, default: 0 } // счетчик голосов
|
||||||
|
});
|
||||||
|
|
||||||
|
// Схема вопроса
|
||||||
|
const questionSchema = new mongoose.Schema({
|
||||||
|
text: { type: String, required: true },
|
||||||
|
type: {
|
||||||
|
type: String,
|
||||||
|
enum: Object.values(QUESTION_TYPES),
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
options: [optionSchema],
|
||||||
|
required: { type: Boolean, default: false }
|
||||||
|
});
|
||||||
|
|
||||||
|
// Схема опроса
|
||||||
|
const questionnaireSchema = new mongoose.Schema({
|
||||||
|
title: { type: String, required: true },
|
||||||
|
description: { type: String },
|
||||||
|
questions: [questionSchema],
|
||||||
|
displayType: {
|
||||||
|
type: String,
|
||||||
|
enum: Object.values(DISPLAY_TYPES),
|
||||||
|
default: DISPLAY_TYPES.DEFAULT
|
||||||
|
},
|
||||||
|
createdAt: { type: Date, default: Date.now },
|
||||||
|
updatedAt: { type: Date, default: Date.now },
|
||||||
|
adminLink: { type: String, required: true }, // ссылка для редактирования
|
||||||
|
publicLink: { type: String, required: true } // ссылка для голосования
|
||||||
|
});
|
||||||
|
|
||||||
|
export const Questionnaire = mongoose.model('Questionnaire', questionnaireSchema);
|
||||||
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
const fs = require('fs')
|
|
||||||
const path = require('path')
|
|
||||||
const router = require('express').Router()
|
|
||||||
const mongoose = require('mongoose')
|
|
||||||
|
|
||||||
const pkg = require('../package.json')
|
|
||||||
|
|
||||||
require('./utils/mongoose')
|
|
||||||
const folderPath = path.resolve(__dirname, './routers')
|
|
||||||
const folders = fs.readdirSync(folderPath)
|
|
||||||
|
|
||||||
router.get('/', async (req, res) => {
|
|
||||||
// throw new Error('check error message')
|
|
||||||
res.send(`
|
|
||||||
<h1>multy stub is working v${pkg.version}</h1>
|
|
||||||
<ul>
|
|
||||||
${folders.map((f) => `<li>${f}</li>`).join('')}
|
|
||||||
</ul>
|
|
||||||
|
|
||||||
<h2>models</h2>
|
|
||||||
<ul>${
|
|
||||||
(await Promise.all(
|
|
||||||
(await mongoose.modelNames()).map(async (name) => {
|
|
||||||
const count = await mongoose.model(name).countDocuments()
|
|
||||||
return `<li>${name} - ${count}</li>`
|
|
||||||
}
|
|
||||||
)
|
|
||||||
)).map(t => t).join(' ')
|
|
||||||
}</ul>
|
|
||||||
`)
|
|
||||||
})
|
|
||||||
|
|
||||||
module.exports = router
|
|
||||||
2
server/routers/dogsitters-finder/const.js
Normal file
2
server/routers/dogsitters-finder/const.js
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
exports.DSF_AUTH_USER_MODEL_NAME = 'DSF_AUTH_USER'
|
||||||
|
exports.DSF_INTERACTION_MODEL_NAME = 'DSF_INTERACTION'
|
||||||
@@ -7,29 +7,29 @@ router.get("/users", (request, response) => {
|
|||||||
router.post("/auth", (request, response) => {
|
router.post("/auth", (request, response) => {
|
||||||
const { phoneNumber, password } = request.body;
|
const { phoneNumber, password } = request.body;
|
||||||
console.log(phoneNumber, password);
|
console.log(phoneNumber, password);
|
||||||
if (phoneNumber === "89999999999") {
|
if (phoneNumber === "89999999999" || phoneNumber === "89559999999") {
|
||||||
response.send(require("./json/auth/dogsitter.success.json"));
|
response.send(require("./json/auth/success.json"));
|
||||||
} else if (phoneNumber === "89555555555") {
|
|
||||||
response.status(400).send(require("./json/auth/error.json"));
|
|
||||||
} else {
|
} else {
|
||||||
response.send(require("./json/auth/owner.success.json"));
|
response.status(401).send(require("./json/auth/error.json"));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
router.post("/auth/2fa", (request, response) => {
|
router.post("/auth/2fa", (request, response) => {
|
||||||
const { code } = request.body;
|
const { phoneNumber, code } = request.body;
|
||||||
if (code === "0000") {
|
if (code === "0000" && phoneNumber === "89999999999") {
|
||||||
response.send(require("./json/2fa/success.json"));
|
response.send(require("./json/2fa/dogsitter.success.json"));
|
||||||
|
} else if (code === "0000" && phoneNumber === "89559999999") {
|
||||||
|
response.send(require("./json/2fa/owner.success.json"));
|
||||||
} else {
|
} else {
|
||||||
response.status(400).send(require("./json/2fa/error.json"));
|
response.status(401).send(require("./json/2fa/error.json"));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
router.post("/register", (request, response) => {
|
router.post("/register", (request, response) => {
|
||||||
const { firstName, secondName, phoneNumber, password, role } = request.body;
|
const { firstName, secondName, phoneNumber, password, role } = request.body;
|
||||||
console.log(phoneNumber, password, role);
|
console.log(phoneNumber, password, role);
|
||||||
if (phoneNumber === "89283244141" || phoneNumber === "89872855893") {
|
if (phoneNumber === "89999999999" || phoneNumber === "89559999999") {
|
||||||
response.status(400).send(require("./json/register/error.json"));
|
response.status(401).send(require("./json/register/error.json"));
|
||||||
} else if (role === "dogsitter") {
|
} else if (role === "dogsitter") {
|
||||||
response.send(require("./json/register/dogsitter.success.json"));
|
response.send(require("./json/register/dogsitter.success.json"));
|
||||||
} else {
|
} else {
|
||||||
@@ -37,4 +37,192 @@ router.post("/register", (request, response) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
module.exports = router;
|
router.get("/auth/session", (request, response) => {
|
||||||
|
const authHeader = request.headers.authorization;
|
||||||
|
|
||||||
|
if (!authHeader) {
|
||||||
|
return response.status(401).json({ error: "Authorization header missing" });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Берём сам токен из заголовка
|
||||||
|
const token = authHeader.split(" ")[1];
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
return response.status(401).json({ error: "Bearer token missing" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const jwt = require("jsonwebtoken");
|
||||||
|
const secretKey = "secret";
|
||||||
|
|
||||||
|
try {
|
||||||
|
const decoded = jwt.verify(token, secretKey);
|
||||||
|
|
||||||
|
if (decoded.role === "dogsitter") {
|
||||||
|
response.send(require("./json/role/dogsitter.success.json"));
|
||||||
|
} else {
|
||||||
|
response.send(require("./json/role/owner.success.json"));
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.log("token e:", e);
|
||||||
|
return response.status(403).json({ error: "Invalid token" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
// Проверка взаимодействия между пользователем и догситтером
|
||||||
|
router.get("/interactions/check", (req, res) => {
|
||||||
|
const { owner_id, dogsitter_id } = req.query;
|
||||||
|
|
||||||
|
const usersFilePath = path.resolve(__dirname, "./json/users/users.json");
|
||||||
|
|
||||||
|
delete require.cache[require.resolve(usersFilePath)];
|
||||||
|
const usersFile = require(usersFilePath);
|
||||||
|
|
||||||
|
const interactions = usersFile.interactions || [];
|
||||||
|
|
||||||
|
const exists = interactions.some(
|
||||||
|
(interaction) =>
|
||||||
|
interaction.owner_id === Number(owner_id) &&
|
||||||
|
interaction.dogsitter_id === Number(dogsitter_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
res.json({ exists });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Добавление нового взаимодействия
|
||||||
|
router.post("/interactions", (req, res) => {
|
||||||
|
const { owner_id, dogsitter_id, interaction_type } = req.body;
|
||||||
|
|
||||||
|
if (!owner_id || !dogsitter_id || !interaction_type) {
|
||||||
|
return res.status(400).json({ error: "Missing required fields" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const usersFilePath = path.resolve(__dirname, "./json/users/users.json");
|
||||||
|
|
||||||
|
delete require.cache[require.resolve(usersFilePath)];
|
||||||
|
const usersFile = require(usersFilePath);
|
||||||
|
|
||||||
|
if (!usersFile.interactions) {
|
||||||
|
usersFile.interactions = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Проверяем, существует ли уже такое взаимодействие
|
||||||
|
const exists = usersFile.interactions.some(
|
||||||
|
(interaction) =>
|
||||||
|
interaction.owner_id === Number(owner_id) &&
|
||||||
|
interaction.dogsitter_id === Number(dogsitter_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!exists) {
|
||||||
|
usersFile.interactions.push({
|
||||||
|
owner_id: Number(owner_id),
|
||||||
|
dogsitter_id: Number(dogsitter_id),
|
||||||
|
interaction_type,
|
||||||
|
});
|
||||||
|
|
||||||
|
fs.writeFileSync(
|
||||||
|
usersFilePath,
|
||||||
|
JSON.stringify(usersFile, null, 2),
|
||||||
|
"utf8"
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`Добавлено взаимодействие: owner_id=${owner_id}, dogsitter_id=${dogsitter_id}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ success: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get("/dogsitter-viewing", (req, res) => {
|
||||||
|
const { id } = req.query;
|
||||||
|
console.log(`Получен запрос для dogsitter с ID: ${id}`);
|
||||||
|
|
||||||
|
const usersFile = require("./json/users/users.json");
|
||||||
|
const users = usersFile.data; // Извлекаем массив из свойства "data"
|
||||||
|
|
||||||
|
const user = users.find((user) => user.id === Number(id));
|
||||||
|
|
||||||
|
if (user) {
|
||||||
|
res.json(user); // Возвращаем найденного пользователя
|
||||||
|
} else {
|
||||||
|
res.status(404).json({ error: "User not found" }); // Если пользователь не найден
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
|
||||||
|
router.post('/dogsitter-viewing/rating/:id', (req, res) => {
|
||||||
|
const { id } = req.params;
|
||||||
|
const { rating } = req.body;
|
||||||
|
|
||||||
|
if (!rating || rating < 1 || rating > 5) {
|
||||||
|
return res.status(400).json({ error: 'Некорректная оценка' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const usersFilePath = path.resolve(__dirname, "./json/users/users.json");
|
||||||
|
|
||||||
|
delete require.cache[require.resolve(usersFilePath)];
|
||||||
|
const usersFile = require(usersFilePath);
|
||||||
|
const users = usersFile.data;
|
||||||
|
|
||||||
|
const userIndex = users.findIndex(user => user.id === Number(id));
|
||||||
|
if (userIndex === -1) {
|
||||||
|
return res.status(404).json({ error: 'Догситтер не найден' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!users[userIndex].ratings) {
|
||||||
|
users[userIndex].ratings = [];
|
||||||
|
}
|
||||||
|
users[userIndex].ratings.push(rating);
|
||||||
|
|
||||||
|
if (users[userIndex].ratings.length > 100) {
|
||||||
|
users[userIndex].ratings.shift();
|
||||||
|
}
|
||||||
|
|
||||||
|
const total = users[userIndex].ratings.reduce((sum, r) => sum + r, 0);
|
||||||
|
users[userIndex].rating = parseFloat((total / users[userIndex].ratings.length).toFixed(2));
|
||||||
|
|
||||||
|
fs.writeFileSync(usersFilePath, JSON.stringify({ data: users }, null, 2), 'utf8');
|
||||||
|
|
||||||
|
console.log(`Обновлен рейтинг догситтера ${id}: ${users[userIndex].rating}`);
|
||||||
|
|
||||||
|
res.json({ rating: users[userIndex].rating, ratings: users[userIndex].ratings });
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
router.patch('/users/:id', (req, res) => {
|
||||||
|
const { id } = req.params;
|
||||||
|
const updateData = req.body;
|
||||||
|
|
||||||
|
console.log('Полученные данные для обновления:', updateData);
|
||||||
|
|
||||||
|
|
||||||
|
const usersFilePath = path.resolve(__dirname, "./json/users/users.json");
|
||||||
|
|
||||||
|
delete require.cache[require.resolve(usersFilePath)];
|
||||||
|
const usersFile = require(usersFilePath);
|
||||||
|
const users = usersFile.data;
|
||||||
|
|
||||||
|
const userIndex = users.findIndex((user) => user.id === Number(id));
|
||||||
|
if (userIndex === -1) {
|
||||||
|
return res.status(404).json({ error: 'User not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
users[userIndex] = { ...users[userIndex], ...updateData };
|
||||||
|
|
||||||
|
fs.writeFileSync(
|
||||||
|
usersFilePath,
|
||||||
|
JSON.stringify({ data: users }, null, 2),
|
||||||
|
'utf8'
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log('Обновлённые данные пользователя:', users[userIndex]);
|
||||||
|
|
||||||
|
res.json(users[userIndex]);
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
module.exports = router
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6MSwicm9sZSI6ImRvZ3NpdHRlciIsImlhdCI6MTUxNjIzOTAyMn0.7q66wTNyLZp3TGFYF_JdU-yhlWViJulTxP_PCQzO4OI"
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
{
|
{
|
||||||
"status": "error",
|
"status": "error",
|
||||||
"message": "Invalid code."
|
"message": "Invalid code",
|
||||||
|
"statusCode": 401
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6Mywicm9sZSI6Im93bmVyIiwiaWF0IjoxNTE2MjM5MDIyfQ.sI9839YXveTpEWhdpr5QbCYllt6hHYO7NsrQDcrXZIQ"
|
||||||
|
}
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
{
|
|
||||||
"status": "success",
|
|
||||||
"message": "Two-factor authentication passed."
|
|
||||||
}
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
{
|
|
||||||
"data": {
|
|
||||||
"id": 1,
|
|
||||||
"phoneNumber": 89283244141,
|
|
||||||
"firstName": "Вася",
|
|
||||||
"secondName": "Пупкин",
|
|
||||||
"role": "dogsitter",
|
|
||||||
"location": "Россия, республика Татарстан, Казань, улица Пушкина, 12",
|
|
||||||
"price": 1500,
|
|
||||||
"aboutMe": "Я люблю собак"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
{
|
{
|
||||||
"error": "Пользователь не найден"
|
"message": "Неверный логин или пароль",
|
||||||
}
|
"error": "Unauthorized",
|
||||||
|
"statusCode": 401
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,9 +0,0 @@
|
|||||||
{
|
|
||||||
"data": {
|
|
||||||
"id": 3,
|
|
||||||
"phoneNumber": 89872855893,
|
|
||||||
"firstName": "Гадий",
|
|
||||||
"secondName": "Петрович",
|
|
||||||
"role": "owner"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
5
server/routers/dogsitters-finder/json/auth/success.json
Normal file
5
server/routers/dogsitters-finder/json/auth/success.json
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"status": "success",
|
||||||
|
"message": "Первый фактор аутентификации пройден",
|
||||||
|
"statusCode": 200
|
||||||
|
}
|
||||||
@@ -1,12 +1,3 @@
|
|||||||
{
|
{
|
||||||
"data": {
|
"access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6NSwicm9sZSI6ImRvZ3NpdHRlciIsImlhdCI6MTUxNjIzOTAyMn0.T9V3-f3rD1deA5a2J-tYNw0cACEpzKHbhMPkc7gh8c0"
|
||||||
"id": 5,
|
|
||||||
"phoneNumber": 89555555555,
|
|
||||||
"firstName": "Масяня",
|
|
||||||
"secondName": "Карлова",
|
|
||||||
"role": "dogsitter",
|
|
||||||
"location": "Россия, республика Татарстан, Казань, улица Пушкина, 12",
|
|
||||||
"price": 100,
|
|
||||||
"aboutMe": "Все на свете - собаки"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
{
|
{
|
||||||
"error": "Пользователь с таким номером телефона уже существует"
|
"message": "Такой пользователь уже был зарегистрирован",
|
||||||
|
"error": "Unauthorized",
|
||||||
|
"statusCode": 401
|
||||||
}
|
}
|
||||||
@@ -1,9 +1,3 @@
|
|||||||
{
|
{
|
||||||
"data": {
|
"access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6Niwicm9sZSI6Im93bmVyIiwiaWF0IjoxNTE2MjM5MDIyfQ.qgOhk9tNcaMRbarRWISTgvGx5Eq_X8fcA5lhdVs2tQI"
|
||||||
"id": 6,
|
|
||||||
"phoneNumber": 89888888888,
|
|
||||||
"firstName": "Генадий",
|
|
||||||
"secondName": "Паровозов",
|
|
||||||
"role": "owner"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"role": "dogsitter"
|
||||||
|
}
|
||||||
5
server/routers/dogsitters-finder/json/role/error.json
Normal file
5
server/routers/dogsitters-finder/json/role/error.json
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"message": "Неверный jwt token",
|
||||||
|
"error": "Forbidden",
|
||||||
|
"statusCode": 403
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"id": 3,
|
||||||
|
"role": "owner"
|
||||||
|
}
|
||||||
@@ -1,39 +1,69 @@
|
|||||||
[
|
{
|
||||||
{
|
"data": [
|
||||||
"id": 1,
|
{
|
||||||
"phone_number": 89283244141,
|
"id": 1,
|
||||||
"first_name": "Вася",
|
"phone_number": "89999999999",
|
||||||
"second_name": "Пупкин",
|
"first_name": "Вася",
|
||||||
"role": "dogsitter",
|
"second_name": "Пупкин",
|
||||||
"location": "Россия, республика Татарстан, Казань, улица Пушкина, 12",
|
"role": "dogsitter",
|
||||||
"price": 1500,
|
"location": "Россия, республика Татарстан, Казань, Пушкина, 12",
|
||||||
"about_me": "Я люблю собак"
|
"price": "1500",
|
||||||
},
|
"about_me": "Я люблю собак!",
|
||||||
{
|
"rating": 5,
|
||||||
"id": 2,
|
"ratings": [
|
||||||
"phone_number": 89272844541,
|
5,
|
||||||
"first_name": "Ваня",
|
5
|
||||||
"second_name": "Пуськин",
|
],
|
||||||
"role": "dogsitter",
|
"tg": "jullllllie"
|
||||||
"location": "Россия, республика Татарстан, Казань, улица Абсалямова, 19",
|
},
|
||||||
"price": 1000000,
|
{
|
||||||
"about_me": "Я не люблю собак. И вообще я котоман."
|
"id": 2,
|
||||||
},
|
"phone_number": 89272844541,
|
||||||
{
|
"first_name": "Ваня",
|
||||||
"id": 3,
|
"second_name": "Пуськин",
|
||||||
"phone_number": 89872855893,
|
"role": "dogsitter",
|
||||||
"first_name": "Гадий",
|
"location": "Россия, республика Татарстан, Казань, улица Абсалямова, 19",
|
||||||
"second_name": "Петрович",
|
"price": 2000,
|
||||||
"role": "owner"
|
"about_me": "Я не люблю собак. И вообще я котоман.",
|
||||||
},
|
"rating": 4,
|
||||||
{
|
"ratings": [
|
||||||
"id": 4,
|
4,
|
||||||
"phone_number": 89872844591,
|
4
|
||||||
"first_name": "Галкин",
|
],
|
||||||
"second_name": "Максим",
|
"tg": "vanya006"
|
||||||
"role": "dogsitter",
|
},
|
||||||
"location": "Россия, республика Татарстан, Казань, проспект Ямашева, 83",
|
{
|
||||||
"price": 1000000,
|
"id": 3,
|
||||||
"about_me": "Миллион алых роз"
|
"phone_number": 89559999999,
|
||||||
}
|
"first_name": "Гадий",
|
||||||
]
|
"second_name": "Петрович",
|
||||||
|
"role": "owner"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 4,
|
||||||
|
"phone_number": 89872844591,
|
||||||
|
"first_name": "Галкин",
|
||||||
|
"second_name": "Максим",
|
||||||
|
"role": "dogsitter",
|
||||||
|
"location": "Россия, республика Татарстан, Казань, проспект Ямашева, 83",
|
||||||
|
"price": 1750,
|
||||||
|
"about_me": "Миллион алых роз",
|
||||||
|
"rating": 4.5,
|
||||||
|
"ratings": [
|
||||||
|
4,
|
||||||
|
5
|
||||||
|
],
|
||||||
|
"tg": "maks100500"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"interactions": [
|
||||||
|
{
|
||||||
|
"owner_id": 3,
|
||||||
|
"dogsitter_id": 4
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"owner_id": 1,
|
||||||
|
"dogsitter_id": 2
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
24
server/routers/dogsitters-finder/model/interaction.js
Normal file
24
server/routers/dogsitters-finder/model/interaction.js
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
const { Schema, model } = require("mongoose");
|
||||||
|
|
||||||
|
const { DSF_AUTH_USER_MODEL_NAME, DSF_INTERACTION_MODEL_NAME } = require("../../const");
|
||||||
|
|
||||||
|
const interactionSchema = new Schema({
|
||||||
|
owner_id: {
|
||||||
|
type: Schema.Types.ObjectId,
|
||||||
|
ref: DSF_AUTH_USER_MODEL_NAME,
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
dogsitter_id: {
|
||||||
|
type: Schema.Types.ObjectId,
|
||||||
|
ref: DSF_AUTH_USER_MODEL_NAME,
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
timestamp: {
|
||||||
|
type: Date,
|
||||||
|
default: Date.now
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
interactionSchema.index({ owner_id: 1, dogsitter_id: 1 });
|
||||||
|
|
||||||
|
module.exports.Interaction = model(DSF_INTERACTION_MODEL_NAME, interactionSchema);
|
||||||
83
server/routers/dogsitters-finder/model/user.js
Normal file
83
server/routers/dogsitters-finder/model/user.js
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
const { Schema, model } = require("mongoose");
|
||||||
|
|
||||||
|
const { DSF_AUTH_USER_MODEL_NAME } = require("../../const");
|
||||||
|
|
||||||
|
const userSchema = new Schema({
|
||||||
|
phone_number: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
unique: true,
|
||||||
|
match: /^\+?\d{10,15}$/
|
||||||
|
},
|
||||||
|
first_name: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
trim: true
|
||||||
|
},
|
||||||
|
second_name: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
trim: true
|
||||||
|
},
|
||||||
|
role: {
|
||||||
|
type: String,
|
||||||
|
enum: ["dogsitter", "owner"],
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
location: {
|
||||||
|
type: String,
|
||||||
|
required: function() {
|
||||||
|
return this.role === "dogsitter";
|
||||||
|
}
|
||||||
|
},
|
||||||
|
price: {
|
||||||
|
type: Number,
|
||||||
|
min: 0,
|
||||||
|
required: function() {
|
||||||
|
return this.role === "dogsitter";
|
||||||
|
}
|
||||||
|
},
|
||||||
|
about_me: {
|
||||||
|
type: String,
|
||||||
|
maxlength: 500
|
||||||
|
},
|
||||||
|
rating: {
|
||||||
|
type: Number,
|
||||||
|
min: 0,
|
||||||
|
max: 5,
|
||||||
|
default: 0
|
||||||
|
},
|
||||||
|
ratings: {
|
||||||
|
type: [Number],
|
||||||
|
default: [],
|
||||||
|
validate: {
|
||||||
|
validator: function(arr) {
|
||||||
|
return arr.every(v => v >= 0 && v <= 5);
|
||||||
|
},
|
||||||
|
message: "Рейтинг должен быть в диапазоне от 0 до 5!"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
tg: {
|
||||||
|
type: String,
|
||||||
|
match: /^[a-zA-Z0-9_]{5,32}$/
|
||||||
|
},
|
||||||
|
created: {
|
||||||
|
type: Date,
|
||||||
|
default: Date.now
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
userSchema.virtual("id").get(function() {
|
||||||
|
return this._id.toHexString();
|
||||||
|
});
|
||||||
|
|
||||||
|
userSchema.set("toJSON", {
|
||||||
|
virtuals: true,
|
||||||
|
versionKey: false,
|
||||||
|
transform: function(doc, ret) {
|
||||||
|
delete ret._id;
|
||||||
|
delete ret.__v;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports.User = model(DSF_AUTH_USER_MODEL_NAME, userSchema);
|
||||||
149
server/routers/dogsitters-finder/routes.js
Normal file
149
server/routers/dogsitters-finder/routes.js
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
const { Router } = require('express')
|
||||||
|
const { expressjwt } = require('express-jwt')
|
||||||
|
|
||||||
|
const { getAnswer } = require('../../utils/common')
|
||||||
|
const { User, Interaction } = require('./model')
|
||||||
|
const { TOKEN_KEY } = require('./const')
|
||||||
|
const { requiredValidate } = require('./utils')
|
||||||
|
|
||||||
|
const router = Router()
|
||||||
|
|
||||||
|
// Получение списка пользователей
|
||||||
|
router.get('/users', async (req, res) => {
|
||||||
|
|
||||||
|
const users = await User.find()
|
||||||
|
.select('-__v -ratings -phone_number')
|
||||||
|
.lean()
|
||||||
|
|
||||||
|
console.log('get users successfull')
|
||||||
|
|
||||||
|
res.send(getAnswer(null, users))
|
||||||
|
})
|
||||||
|
|
||||||
|
// Получение конкретного пользователя
|
||||||
|
router.get('/dogsitter-viewing', async (req, res) => {
|
||||||
|
const { userId } = req.params
|
||||||
|
|
||||||
|
const user = await User.findById(userId)
|
||||||
|
.select('-__v -ratings')
|
||||||
|
.lean()
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return res.status(404).send(getAnswer(new Error('Пользователь не найден')))
|
||||||
|
}
|
||||||
|
|
||||||
|
res.send(getAnswer(null, user))
|
||||||
|
})
|
||||||
|
|
||||||
|
router.use(expressjwt({ secret: TOKEN_KEY, algorithms: ['HS256'] }))
|
||||||
|
|
||||||
|
// Добавление оценки пользователю
|
||||||
|
router.post('/dogsitter-viewing/rating', requiredValidate('value'), async (req, res) => {
|
||||||
|
const { userId } = req.params
|
||||||
|
const { value } = req.body
|
||||||
|
const authUserId = req.auth.id
|
||||||
|
|
||||||
|
try {
|
||||||
|
const user = await User.findById(userId)
|
||||||
|
if (!user) throw new Error('Пользователь не найден')
|
||||||
|
if (user.role !== 'dogsitter') throw new Error('Нельзя оценивать этого пользователя')
|
||||||
|
if (user.id === authUserId) throw new Error('Нельзя оценивать самого себя')
|
||||||
|
|
||||||
|
user.ratings.push(Number(value))
|
||||||
|
user.rating = user.ratings.reduce((a, b) => a + b, 0) / user.ratings.length
|
||||||
|
|
||||||
|
const updatedUser = await user.save()
|
||||||
|
|
||||||
|
res.send(getAnswer(null, {
|
||||||
|
id: updatedUser.id,
|
||||||
|
rating: updatedUser.rating.toFixed(1),
|
||||||
|
totalRatings: updatedUser.ratings.length
|
||||||
|
}))
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
res.status(400).send(getAnswer(error))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Обновление информации пользователя
|
||||||
|
router.patch('/users', async (req, res) => {
|
||||||
|
const { userId } = req.params
|
||||||
|
const updates = req.body
|
||||||
|
|
||||||
|
try {
|
||||||
|
const user = await User.findByIdAndUpdate(userId, updates, { new: true })
|
||||||
|
.select('-__v -ratings')
|
||||||
|
|
||||||
|
if (!user) throw new Error('Пользователь не найден')
|
||||||
|
res.send(getAnswer(null, user))
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
res.status(400).send(getAnswer(error))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Создание объекта взаимодействия
|
||||||
|
router.post('/interactions',
|
||||||
|
expressjwt({ secret: TOKEN_KEY, algorithms: ['HS256'] }),
|
||||||
|
requiredValidate('dogsitter_id'),
|
||||||
|
async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { dogsitter_id } = req.body
|
||||||
|
const owner_id = req.auth.id // ID из JWT токена
|
||||||
|
|
||||||
|
// Проверка существования пользователей
|
||||||
|
const [owner, dogsitter] = await Promise.all([
|
||||||
|
User.findById(owner_id),
|
||||||
|
User.findById(dogsitter_id)
|
||||||
|
])
|
||||||
|
|
||||||
|
if (!owner || owner.role !== 'owner') {
|
||||||
|
throw new Error('Владелец не найден или имеет неверную роль')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!dogsitter || dogsitter.role !== 'dogsitter') {
|
||||||
|
throw new Error('Догситтер не найден или имеет неверную роль')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Создание взаимодействия
|
||||||
|
const interaction = await Interaction.create({
|
||||||
|
owner_id,
|
||||||
|
dogsitter_id
|
||||||
|
})
|
||||||
|
|
||||||
|
res.send(getAnswer(null, {
|
||||||
|
id: interaction.id,
|
||||||
|
timestamp: interaction.timestamp
|
||||||
|
}))
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
res.status(400).send(getAnswer(error))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
router.get('/interactions/check', async (req, res) => {
|
||||||
|
const { owner_id, dogsitter_id } = req.query;
|
||||||
|
|
||||||
|
if (!owner_id || !dogsitter_id) {
|
||||||
|
return res.status(400).send(getAnswer('Missing owner_id or dogsitter_id'));
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Поиск взаимодействий по owner_id и dogsitter_id
|
||||||
|
const interactions = await Interaction.find({ owner_id, dogsitter_id })
|
||||||
|
.select('-__v') // Выбираем только нужные поля
|
||||||
|
.lean();
|
||||||
|
|
||||||
|
if (interactions.length === 0) {
|
||||||
|
return res.status(404).send(getAnswer('No interactions found'));
|
||||||
|
}
|
||||||
|
|
||||||
|
res.send(getAnswer(null, interactions));
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error checking interactions:', error);
|
||||||
|
res.status(500).send(getAnswer('Internal Server Error'));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router
|
||||||
@@ -1,111 +1,117 @@
|
|||||||
const router = require('express').Router()
|
const router = require("express").Router();
|
||||||
const {MasterModel} = require('./model/master')
|
const { MasterModel } = require("./model/master");
|
||||||
const mongoose = require("mongoose")
|
const mongoose = require("mongoose");
|
||||||
const {OrderModel} = require("./model/order")
|
const { OrderModel } = require("./model/order");
|
||||||
|
|
||||||
|
router.post("/masters/list", async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const { startDate, endDate } = req.body;
|
||||||
|
|
||||||
router.get("/masters", async (req, res, next) => {
|
if (!startDate || !endDate) {
|
||||||
try {
|
throw new Error("Missing startDate or endDate");
|
||||||
const masters = await MasterModel.find({});
|
|
||||||
const orders = await OrderModel.find({});
|
|
||||||
|
|
||||||
const mastersWithOrders = masters.map((master) => {
|
|
||||||
const masterOrders = orders.filter((order) => {
|
|
||||||
return (
|
|
||||||
order?.master && order.master.toString() === master._id.toString()
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
const schedule = masterOrders.map((order) => ({
|
|
||||||
id: order._id,
|
|
||||||
startWashTime: order.startWashTime,
|
|
||||||
endWashTime: order.endWashTime,
|
|
||||||
}));
|
|
||||||
|
|
||||||
return {
|
|
||||||
id: master._id,
|
|
||||||
name: master.name,
|
|
||||||
schedule: schedule,
|
|
||||||
phone: master.phone,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
res.status(200).send({ success: true, body: mastersWithOrders });
|
|
||||||
} catch (error) {
|
|
||||||
next(error);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
router.delete('/masters/:id', async (req, res,next) => {
|
|
||||||
const { id } = req.params;
|
|
||||||
|
|
||||||
if (!mongoose.Types.ObjectId.isValid(id)){
|
|
||||||
throw new Error('ID is required')
|
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
const start = new Date(startDate);
|
||||||
const master = await MasterModel.findByIdAndDelete(id, {
|
const end = new Date(endDate);
|
||||||
new: true,
|
const masters = await MasterModel.find({});
|
||||||
});
|
|
||||||
if (!master) {
|
|
||||||
throw new Error('master not found')
|
|
||||||
}
|
|
||||||
res.status(200).send({success: true, body: master})
|
|
||||||
} catch (error) {
|
|
||||||
next(error)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
|
const orders = await OrderModel.find({
|
||||||
|
$or: [
|
||||||
|
{ startWashTime: { $lt: end }, endWashTime: { $gt: start } }
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
router.post('/masters', async (req, res,next) => {
|
const mastersWithOrders = masters.map((master) => {
|
||||||
|
const masterOrders = orders.filter((order) => {
|
||||||
const {name, phone} = req.body
|
return (
|
||||||
|
order?.master && order.master.toString() === master._id.toString()
|
||||||
if (!name || !phone ){
|
|
||||||
throw new Error('Enter name and phone')
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const master = await MasterModel.create({name, phone})
|
|
||||||
res.status(200).send({success: true, body: master})
|
|
||||||
} catch (error) {
|
|
||||||
next(error)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
router.patch('/masters/:id', async (req, res, next) => {
|
|
||||||
const { id } = req.params;
|
|
||||||
|
|
||||||
if (!mongoose.Types.ObjectId.isValid(id)) {
|
|
||||||
throw new Error('ID is required')
|
|
||||||
}
|
|
||||||
|
|
||||||
const { name, phone } = req.body;
|
|
||||||
|
|
||||||
if (!name && !phone) {
|
|
||||||
throw new Error('Enter name and phone')
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
try {
|
|
||||||
const updateData = {};
|
|
||||||
if (name) updateData.name = name;
|
|
||||||
if (phone) updateData.phone = phone;
|
|
||||||
|
|
||||||
const master = await MasterModel.findByIdAndUpdate(
|
|
||||||
id,
|
|
||||||
updateData,
|
|
||||||
{ new: true }
|
|
||||||
);
|
);
|
||||||
|
});
|
||||||
|
|
||||||
if (!master) {
|
const schedule = masterOrders.map((order) => ({
|
||||||
throw new Error('master not found')
|
id: order._id,
|
||||||
}
|
startWashTime: order.startWashTime,
|
||||||
|
endWashTime: order.endWashTime,
|
||||||
|
}));
|
||||||
|
|
||||||
res.status(200).send({ success: true, body: master });
|
return {
|
||||||
} catch (error) {
|
id: master._id,
|
||||||
next(error);
|
name: master.name,
|
||||||
}
|
schedule: schedule,
|
||||||
|
phone: master.phone,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
res.status(200).send({ success: true, body: mastersWithOrders });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
module.exports = router
|
router.delete("/masters/:id", async (req, res, next) => {
|
||||||
|
const { id } = req.params;
|
||||||
|
|
||||||
|
if (!mongoose.Types.ObjectId.isValid(id)) {
|
||||||
|
throw new Error("ID is required");
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const master = await MasterModel.findByIdAndDelete(id, {
|
||||||
|
new: true,
|
||||||
|
});
|
||||||
|
if (!master) {
|
||||||
|
throw new Error("master not found");
|
||||||
|
}
|
||||||
|
res.status(200).send({ success: true, body: master });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post("/masters", async (req, res, next) => {
|
||||||
|
const { name, phone } = req.body;
|
||||||
|
|
||||||
|
if (!name || !phone) {
|
||||||
|
throw new Error("Enter name and phone");
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const master = await MasterModel.create({ name, phone });
|
||||||
|
res.status(200).send({ success: true, body: master });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.patch("/masters/:id", async (req, res, next) => {
|
||||||
|
const { id } = req.params;
|
||||||
|
|
||||||
|
if (!mongoose.Types.ObjectId.isValid(id)) {
|
||||||
|
throw new Error("ID is required");
|
||||||
|
}
|
||||||
|
|
||||||
|
const { name, phone } = req.body;
|
||||||
|
|
||||||
|
if (!name && !phone) {
|
||||||
|
throw new Error("Enter name and phone");
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const updateData = {};
|
||||||
|
if (name) updateData.name = name;
|
||||||
|
if (phone) updateData.phone = phone;
|
||||||
|
|
||||||
|
const master = await MasterModel.findByIdAndUpdate(id, updateData, {
|
||||||
|
new: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!master) {
|
||||||
|
throw new Error("master not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
res.status(200).send({ success: true, body: master });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
|
|||||||
23
server/routers/dry-wash/get-token.js
Normal file
23
server/routers/dry-wash/get-token.js
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
const getGigaToken = async () => {
|
||||||
|
const response = await fetch('https://admin.bro-js.ru/api/config/v1/dev')
|
||||||
|
const data = await response.json()
|
||||||
|
return data.features['dry-wash-bh'].GIGA_TOKEN.value
|
||||||
|
}
|
||||||
|
|
||||||
|
const getSystemPrompt = async () => {
|
||||||
|
const response = await fetch('https://admin.bro-js.ru/api/config/v1/dev')
|
||||||
|
const data = await response.json()
|
||||||
|
return data.features['dry-wash-bh'].SYSTEM_PROMPT.value
|
||||||
|
}
|
||||||
|
|
||||||
|
const getGigaChatModel = async () => {
|
||||||
|
const response = await fetch('https://admin.bro-js.ru/api/config/v1/dev')
|
||||||
|
const data = await response.json()
|
||||||
|
return data.features['dry-wash-bh'].GIGA_CHAT_MODEL.value
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
getGigaToken,
|
||||||
|
getSystemPrompt,
|
||||||
|
getGigaChatModel
|
||||||
|
}
|
||||||
29
server/routers/dry-wash/model/order.car-img.js
Normal file
29
server/routers/dry-wash/model/order.car-img.js
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
const { Schema, model } = require('mongoose')
|
||||||
|
|
||||||
|
const schema = new Schema({
|
||||||
|
image: String,
|
||||||
|
imageRating: String,
|
||||||
|
imageDescription: String,
|
||||||
|
orderId: {
|
||||||
|
type: Schema.Types.ObjectId,
|
||||||
|
ref: 'dry-wash-order'
|
||||||
|
},
|
||||||
|
created: {
|
||||||
|
type: Date,
|
||||||
|
default: () => new Date().toISOString(),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
schema.set('toJSON', {
|
||||||
|
virtuals: true,
|
||||||
|
versionKey: false,
|
||||||
|
transform(_doc, ret) {
|
||||||
|
delete ret._id
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
schema.virtual('id').get(function () {
|
||||||
|
return this._id.toHexString()
|
||||||
|
})
|
||||||
|
|
||||||
|
exports.OrderCarImgModel = model('dry-wash-order-car-image', schema)
|
||||||
@@ -15,7 +15,7 @@ const schema = new Schema({
|
|||||||
type: Number,
|
type: Number,
|
||||||
required: true
|
required: true
|
||||||
},
|
},
|
||||||
carColor: String,
|
carColor: Schema.Types.Mixed,
|
||||||
startWashTime: {
|
startWashTime: {
|
||||||
type: Date,
|
type: Date,
|
||||||
required: true
|
required: true
|
||||||
|
|||||||
@@ -1,13 +1,23 @@
|
|||||||
const mongoose = require("mongoose")
|
const mongoose = require("mongoose")
|
||||||
const router = require('express').Router()
|
const router = require('express').Router()
|
||||||
|
const multer = require('multer')
|
||||||
const { MasterModel } = require('./model/master')
|
const { MasterModel } = require('./model/master')
|
||||||
const { OrderModel } = require('./model/order')
|
const { OrderModel } = require('./model/order')
|
||||||
|
const { OrderCarImgModel } = require('./model/order.car-img')
|
||||||
const { orderStatus } = require('./model/const')
|
const { orderStatus } = require('./model/const')
|
||||||
|
const { getGigaToken, getSystemPrompt, getGigaChatModel } = require('./get-token')
|
||||||
|
|
||||||
const isValidPhoneNumber = (value) => /^(\+)?\d{9,15}/.test(value)
|
const isValidPhoneNumber = (value) => /^(\+)?\d{9,15}/.test(value)
|
||||||
const isValidCarNumber = (value) => /^[авекмнорстух][0-9]{3}[авекмнорстух]{2}[0-9]{2,3}$/i.test(value)
|
const isValidCarNumber = (value) => /^[авекмнорстух][0-9]{3}[авекмнорстух]{2}[0-9]{2,3}$/i.test(value)
|
||||||
const isValidCarBodyType = (value) => typeof value === 'number' && value > 0 && value < 100
|
const isValidCarBodyType = (value) => typeof value === 'number' && value > 0 && value < 100
|
||||||
const isValidCarColor = (value) => value.length < 50 && /^[#a-z0-9а-я-\s,.()]+$/i.test(value)
|
const isValidCarColor = (value) => {
|
||||||
|
if (typeof value === 'number') {
|
||||||
|
return value >= 0 && value <= 7
|
||||||
|
} else if (typeof value === 'string') {
|
||||||
|
return /^[#a-z0-9а-я-\s,.()]+$/i.test(value)
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
const isValidISODate = (value) => /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:.\d{1,3})?Z$/.test(value)
|
const isValidISODate = (value) => /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:.\d{1,3})?Z$/.test(value)
|
||||||
|
|
||||||
const latitudeRe = /^(-?[1-8]?\d(?:\.\d{1,18})?|90(?:\.0{1,18})?)$/
|
const latitudeRe = /^(-?[1-8]?\d(?:\.\d{1,18})?|90(?:\.0{1,18})?)$/
|
||||||
@@ -26,6 +36,9 @@ const isValidLocation = (value) => {
|
|||||||
const isValidOrderStatus = (value) => Object.values(orderStatus).includes(value)
|
const isValidOrderStatus = (value) => Object.values(orderStatus).includes(value)
|
||||||
const isValidOrderNotes = (value) => value.length < 500
|
const isValidOrderNotes = (value) => value.length < 500
|
||||||
|
|
||||||
|
const allowedMimeTypes = ['image/jpeg', 'image/png']
|
||||||
|
const sizeLimitInMegaBytes = 15
|
||||||
|
|
||||||
const VALIDATION_MESSAGES = {
|
const VALIDATION_MESSAGES = {
|
||||||
order: {
|
order: {
|
||||||
notFound: 'Order not found'
|
notFound: 'Order not found'
|
||||||
@@ -60,6 +73,13 @@ const VALIDATION_MESSAGES = {
|
|||||||
carColor: {
|
carColor: {
|
||||||
invalid: 'Invalid car color'
|
invalid: 'Invalid car color'
|
||||||
},
|
},
|
||||||
|
carImg: {
|
||||||
|
required: 'Car image file is required',
|
||||||
|
invalid: {
|
||||||
|
type: `Invalid car image file type. Allowed types: ${allowedMimeTypes}`,
|
||||||
|
size: `Invalid car image file size. Limit is ${sizeLimitInMegaBytes}MB`
|
||||||
|
}
|
||||||
|
},
|
||||||
washingBegin: {
|
washingBegin: {
|
||||||
required: 'Begin time of washing is required',
|
required: 'Begin time of washing is required',
|
||||||
invalid: 'Invalid begin time of washing'
|
invalid: 'Invalid begin time of washing'
|
||||||
@@ -143,17 +163,21 @@ router.post('/create', async (req, res, next) => {
|
|||||||
|
|
||||||
router.get('/:id', async (req, res, next) => {
|
router.get('/:id', async (req, res, next) => {
|
||||||
const { id } = req.params
|
const { id } = req.params
|
||||||
|
|
||||||
if (!mongoose.Types.ObjectId.isValid(id)) {
|
if (!mongoose.Types.ObjectId.isValid(id)) {
|
||||||
throw new Error(VALIDATION_MESSAGES.orderId.invalid)
|
throw new Error(VALIDATION_MESSAGES.orderId.invalid)
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const order = await OrderModel.findById(id)
|
const order = await OrderModel.findById(id)
|
||||||
|
|
||||||
if (!order) {
|
if (!order) {
|
||||||
throw new Error(VALIDATION_MESSAGES.order.notFound)
|
throw new Error(VALIDATION_MESSAGES.order.notFound)
|
||||||
}
|
}
|
||||||
|
|
||||||
res.status(200).send({ success: true, body: order })
|
const imgProps = await OrderCarImgModel.findOne({ orderId: order.id })
|
||||||
|
|
||||||
|
res.status(200).send({ success: true, body: { ...order.toObject(), ...imgProps?.toObject() } })
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
next(error)
|
next(error)
|
||||||
}
|
}
|
||||||
@@ -248,4 +272,191 @@ router.delete('/:id', async (req, res, next) => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
module.exports = router
|
const storage = multer.memoryStorage()
|
||||||
|
const upload = multer({
|
||||||
|
storage: storage,
|
||||||
|
limits: { fileSize: sizeLimitInMegaBytes * 1024 * 1024 },
|
||||||
|
fileFilter: (req, file, cb) => {
|
||||||
|
if (allowedMimeTypes.includes(file.mimetype)) {
|
||||||
|
cb(null, true)
|
||||||
|
} else {
|
||||||
|
cb(new Error(VALIDATION_MESSAGES.carImg.invalid.type), false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const { v4: uuidv4 } = require("uuid")
|
||||||
|
const axios = require('axios')
|
||||||
|
|
||||||
|
const GIGA_CHAT_OAUTH = 'https://ngw.devices.sberbank.ru:9443/api/v2/oauth'
|
||||||
|
const GIGA_CHAT_API = 'https://gigachat.devices.sberbank.ru/api/v1'
|
||||||
|
|
||||||
|
const getToken = async (req, res) => {
|
||||||
|
const gigaToken = await getGigaToken()
|
||||||
|
|
||||||
|
const rqUID = uuidv4()
|
||||||
|
const body = new URLSearchParams({
|
||||||
|
scope: "GIGACHAT_API_PERS",
|
||||||
|
})
|
||||||
|
|
||||||
|
const response = await fetch(GIGA_CHAT_OAUTH, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
Authorization: `Basic ${gigaToken}`,
|
||||||
|
"Content-Type": "application/x-www-form-urlencoded",
|
||||||
|
Accept: "application/json",
|
||||||
|
RqUID: rqUID,
|
||||||
|
},
|
||||||
|
body,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorData = await response.json()
|
||||||
|
console.error("Ошибка запроса: ", errorData)
|
||||||
|
return res.status(response.status).json(errorData)
|
||||||
|
}
|
||||||
|
|
||||||
|
return await response.json()
|
||||||
|
}
|
||||||
|
|
||||||
|
const uploadImage = async (file, accessToken) => {
|
||||||
|
const formData = new FormData()
|
||||||
|
const blob = new Blob([file.buffer], { type: file.mimetype })
|
||||||
|
formData.append('file', blob, file.originalname)
|
||||||
|
formData.append('purpose', 'general')
|
||||||
|
|
||||||
|
const config = {
|
||||||
|
maxBodyLength: Infinity,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'multipart/form-data',
|
||||||
|
'Accept': 'application/json',
|
||||||
|
'Authorization': `Bearer ${accessToken}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await axios.post(`${GIGA_CHAT_API}/files`, formData, config)
|
||||||
|
return response.data.id
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const COLORS_MAP = ['white', 'black', 'silver', 'gray', 'beige-brown', 'red', 'blue', 'green']
|
||||||
|
|
||||||
|
const getColorName = (colorKey) => {
|
||||||
|
if (typeof colorKey === 'number' && COLORS_MAP[colorKey]) {
|
||||||
|
return COLORS_MAP[colorKey]
|
||||||
|
}
|
||||||
|
return colorKey
|
||||||
|
}
|
||||||
|
|
||||||
|
const analyzeImage = async (fileId, token, imgProps) => {
|
||||||
|
const response = await fetch(`${GIGA_CHAT_API}/chat/completions`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
Accept: "application/json",
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
model: (await getGigaChatModel()) ?? "GigaChat-Max",
|
||||||
|
stream: false,
|
||||||
|
update_interval: 0,
|
||||||
|
messages: [
|
||||||
|
{
|
||||||
|
role: "system",
|
||||||
|
content: (await getSystemPrompt()) ?? `Ты эксперт по оценке степени загрязнения автомобилей. Твоя задача — анализировать фотографии машин и определять степень их загрязнения.
|
||||||
|
Тебе предоставляют фотографию, где явно выделяется одна машина (например, она расположена в центре и имеет больший размер в кадре по сравнению с остальными).
|
||||||
|
ВАЖНО: Твой ответ ДОЛЖЕН быть СТРОГО в формате JSON и содержать ТОЛЬКО следующие поля:
|
||||||
|
{
|
||||||
|
"value": число от 0 до 10 (целое или с одним знаком после запятой),
|
||||||
|
"description": "текстовое описание на русском языке"
|
||||||
|
}.
|
||||||
|
Правила:
|
||||||
|
1. Поле "value":
|
||||||
|
- Должно быть числом от 0 до 10 (0 = машина абсолютно чистая, 10 = машина максимально грязная) ИЛИ undefined (если не удалось оценить);
|
||||||
|
2. Поле "description":
|
||||||
|
- Должно содержать 2-3 предложения на русском языке;
|
||||||
|
- Обязательно указать конкретные признаки загрязнения;
|
||||||
|
- Объяснить, почему выставлен именно такой балл.
|
||||||
|
- Должно быть связано только с автомобилем.
|
||||||
|
НЕ ДОБАВЛЯЙ никаких дополнительных полей или комментариев вне JSON структуры. НЕ ИСПОЛЬЗУЙ markdown форматирование. ОТВЕТ ДОЛЖЕН БЫТЬ ВАЛИДНЫМ JSON. Если на фотографии нельзя явно выделить одну машину, то ОЦЕНКА ДОЛЖНА ИМЕТЬ ЗНАЧЕНИЕ undefined и в описании должно быть указано, что по фотографии не удалось оценить степень загрязнения автомобиля, при этом НЕ ОПИСЫВАЙ НИЧЕГО ДРУГОГО КРОМЕ АВТОМОБИЛЯ`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
role: "user",
|
||||||
|
content: `Дай оценку для приложенного файла изображения согласно структуре, ответ должен быть на русском языке. Учти, что владелец указал, что исходный цвет машины: ${getColorName(imgProps.color)}`,
|
||||||
|
attachments: [fileId],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
const data = await response.json()
|
||||||
|
console.log(data)
|
||||||
|
|
||||||
|
try {
|
||||||
|
return JSON.parse(data.choices[0].message.content)
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error)
|
||||||
|
return { description: data.choices[0].message.content }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const convertFileToBase64 = (file) => {
|
||||||
|
const base64Image = file.buffer.toString('base64')
|
||||||
|
return `data:${file.mimetype};base64,${base64Image}`
|
||||||
|
}
|
||||||
|
|
||||||
|
process.env["NODE_TLS_REJECT_UNAUTHORIZED"] = "0"
|
||||||
|
|
||||||
|
router.post('/:id/upload-car-img', upload.single('file'), async (req, res) => {
|
||||||
|
const { id: orderId } = req.params
|
||||||
|
if (!mongoose.Types.ObjectId.isValid(orderId)) {
|
||||||
|
throw new Error(VALIDATION_MESSAGES.orderId.invalid)
|
||||||
|
}
|
||||||
|
const order = await OrderModel.findById(orderId)
|
||||||
|
if (!order) {
|
||||||
|
throw new Error(VALIDATION_MESSAGES.order.notFound)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!req.file) {
|
||||||
|
throw new Error(VALIDATION_MESSAGES.carImg.required)
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await OrderCarImgModel.deleteMany({ orderId })
|
||||||
|
|
||||||
|
const { access_token } = await getToken(req, res)
|
||||||
|
|
||||||
|
const fileId = await uploadImage(req.file, access_token)
|
||||||
|
const { value, description } = await analyzeImage(fileId, access_token, { carColor: order.carColor }) ?? {}
|
||||||
|
|
||||||
|
const orderCarImg = await OrderCarImgModel.create({
|
||||||
|
image: convertFileToBase64(req.file),
|
||||||
|
imageRating: value,
|
||||||
|
imageDescription: description,
|
||||||
|
orderId: order.id,
|
||||||
|
created: new Date().toISOString(),
|
||||||
|
})
|
||||||
|
|
||||||
|
res.status(200).send({ success: true, body: orderCarImg })
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
router.use((err, req, res, next) => {
|
||||||
|
if (err instanceof multer.MulterError) {
|
||||||
|
switch (err.message) {
|
||||||
|
case 'File too large':
|
||||||
|
return res.status(400).json({ success: false, error: VALIDATION_MESSAGES.carImg.invalid.size })
|
||||||
|
default:
|
||||||
|
return res.status(400).json({ success: false, error: err.message })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(err.message)
|
||||||
|
})
|
||||||
|
|
||||||
|
module.exports = router
|
||||||
|
|||||||
@@ -1,15 +0,0 @@
|
|||||||
const router = require('express').Router();
|
|
||||||
|
|
||||||
router.get('/recipe-data', (request, response) => {
|
|
||||||
response.send(require('./json/recipe-data/success.json'))
|
|
||||||
})
|
|
||||||
|
|
||||||
router.get('/userpage-data', (req, res)=>{
|
|
||||||
res.send(require('./json/userpage-data/success.json'))
|
|
||||||
})
|
|
||||||
|
|
||||||
router.get('/homepage-data', (req, res)=>{
|
|
||||||
res.send(require('./json/homepage-data/success.json'))
|
|
||||||
})
|
|
||||||
|
|
||||||
module.exports = router;
|
|
||||||
21
server/routers/edateam-legacy/index.ts
Normal file
21
server/routers/edateam-legacy/index.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import { Router } from 'express';
|
||||||
|
|
||||||
|
import recipeData from './json/recipe-data/success.json';
|
||||||
|
import userpageData from './json/userpage-data/success.json';
|
||||||
|
import homepageData from './json/homepage-data/success.json';
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
router.get('/recipe-data', (request, response) => {
|
||||||
|
response.send(recipeData)
|
||||||
|
})
|
||||||
|
|
||||||
|
router.get('/userpage-data', (req, res)=>{
|
||||||
|
res.send(userpageData)
|
||||||
|
})
|
||||||
|
|
||||||
|
router.get('/homepage-data', (req, res)=>{
|
||||||
|
res.send(homepageData)
|
||||||
|
})
|
||||||
|
|
||||||
|
export default router;
|
||||||
@@ -8,22 +8,44 @@ router.get("/update-like", (request, response) => {
|
|||||||
response.send(require("./json/gamepage/success.json"));
|
response.send(require("./json/gamepage/success.json"));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
router.get("/add-to-cart", (request, response) => {
|
||||||
|
response.send(require("./json/home-page-data/games-in-cart.json"));
|
||||||
|
});
|
||||||
|
|
||||||
router.get("/categories", (request, response) => {
|
router.get("/categories", (request, response) => {
|
||||||
response.send(require("./json/home-page-data/all-games.json"));
|
response.send(require("./json/home-page-data/all-games.json"));
|
||||||
});
|
});
|
||||||
|
|
||||||
router.get("/shopping-cart", (request, response) => {
|
router.get("/favourites", (request, response) => {
|
||||||
response.send(require("./json/shopping-cart/success.json"));
|
response.send(require("./json/home-page-data/all-games.json"));
|
||||||
});
|
});
|
||||||
|
|
||||||
router.get("/home", (request, response) => {
|
// router.get("/shopping-cart", (request, response) => {
|
||||||
response.send(require("./json/home-page-data/success.json"));
|
// response.send(require("./json/shopping-cart/success.json"));
|
||||||
|
// });
|
||||||
|
|
||||||
|
router.get("/shopping-cart", (request, response) => {
|
||||||
|
response.send(require("./json/home-page-data/games-in-cart.json"));
|
||||||
|
});
|
||||||
|
|
||||||
|
// Добавляем поддержку разных ответов для /home
|
||||||
|
router.get("/home", (req, res) => {
|
||||||
|
if (stubs.home === "success") {
|
||||||
|
res.send(require("./json/home-page-data/success.json"));
|
||||||
|
} else if (stubs.home === "empty") {
|
||||||
|
res.send({ data: [] }); // Отправляем пустой массив
|
||||||
|
} else {
|
||||||
|
res.status(500).json({ success: false, message: "Server error" });
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
router.get("/all-games", (request, response) => {
|
router.get("/all-games", (request, response) => {
|
||||||
response.send(require("./json/home-page-data/all-games.json"));
|
response.send(require("./json/home-page-data/all-games.json"));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const stubs = {
|
||||||
|
home: "success",
|
||||||
|
};
|
||||||
|
|
||||||
// // Маршрут для обновления лайков
|
// // Маршрут для обновления лайков
|
||||||
// router.post("/update-like", (request, response) => {
|
// router.post("/update-like", (request, response) => {
|
||||||
@@ -38,7 +60,6 @@ router.get("/all-games", (request, response) => {
|
|||||||
// });
|
// });
|
||||||
// });
|
// });
|
||||||
|
|
||||||
|
|
||||||
const fs = require("fs").promises;
|
const fs = require("fs").promises;
|
||||||
const path = require("path");
|
const path = require("path");
|
||||||
|
|
||||||
@@ -49,7 +70,7 @@ const commentsFilePath = path.join(__dirname, "./json/gamepage/success.json");
|
|||||||
async function readComments() {
|
async function readComments() {
|
||||||
const data = await fs.readFile(commentsFilePath, "utf-8");
|
const data = await fs.readFile(commentsFilePath, "utf-8");
|
||||||
const parsedData = JSON.parse(data);
|
const parsedData = JSON.parse(data);
|
||||||
console.log("Прочитанные данные:", parsedData); // Логируем полученные данные
|
console.log("Прочитанные данные:", parsedData); // Логируем полученные данные
|
||||||
return parsedData;
|
return parsedData;
|
||||||
}
|
}
|
||||||
// Write to JSON file
|
// Write to JSON file
|
||||||
@@ -88,5 +109,149 @@ router.post("/update-like", async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Путь к JSON-файлу с корзиной
|
||||||
|
const cartFilePath = path.join(
|
||||||
|
__dirname,
|
||||||
|
"./json/home-page-data/games-in-cart.json"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Функция для чтения JSON-файла
|
||||||
|
async function readCart() {
|
||||||
|
const data = await fs.readFile(cartFilePath, "utf-8");
|
||||||
|
return JSON.parse(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Функция для записи в JSON-файл
|
||||||
|
async function writeCart(data) {
|
||||||
|
await fs.writeFile(cartFilePath, JSON.stringify(data, null, 2), "utf-8");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Маршрут для добавления/удаления товара в корзине
|
||||||
|
router.post("/add-to-cart", async (req, res) => {
|
||||||
|
const { id, action } = req.body;
|
||||||
|
|
||||||
|
// Проверка наличия id и action
|
||||||
|
if (id === undefined || action === undefined) {
|
||||||
|
return res
|
||||||
|
.status(400)
|
||||||
|
.json({ success: false, message: "Invalid id or action" });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const cartData = await readCart();
|
||||||
|
let ids = cartData.data.ids;
|
||||||
|
|
||||||
|
if (action === "add") {
|
||||||
|
// Если action "add", добавляем товар, если его нет в корзине
|
||||||
|
if (!ids?.includes(id)) {
|
||||||
|
ids.push(id);
|
||||||
|
}
|
||||||
|
} else if (action === "remove") {
|
||||||
|
// Если action "remove", удаляем товар, если он есть в корзине
|
||||||
|
if (ids?.includes(id)) {
|
||||||
|
ids = ids.filter((item) => item !== id);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Если action невалиден
|
||||||
|
return res
|
||||||
|
.status(400)
|
||||||
|
.json({ success: false, message: "Invalid action" });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Записываем обновленные данные обратно в файл
|
||||||
|
cartData.data.ids = ids;
|
||||||
|
await writeCart(cartData);
|
||||||
|
|
||||||
|
res.status(200).json({
|
||||||
|
success: true,
|
||||||
|
message: "Cart updated successfully",
|
||||||
|
data: cartData.data, // Возвращаем обновленные данные
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error updating cart:", error);
|
||||||
|
res.status(500).json({ success: false, message: "Server error" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
module.exports = router;
|
module.exports = router;
|
||||||
|
|
||||||
|
const createElement = (key, value, buttonTitle, basePath) => `
|
||||||
|
<label>
|
||||||
|
<input name="${key}" type="radio" ${
|
||||||
|
stubs[key] === value ? "checked" : ""
|
||||||
|
} onclick="fetch('${basePath}/admin/set/${key}/${value}')"/>
|
||||||
|
${buttonTitle || value}
|
||||||
|
</label>
|
||||||
|
`;
|
||||||
|
|
||||||
|
router.get("/admin/home", (request, response) => {
|
||||||
|
const basePath = request.baseUrl; // Получаем базовый путь маршрутизатора
|
||||||
|
response.send(`
|
||||||
|
<div>
|
||||||
|
<fieldset>
|
||||||
|
<legend>Настройка данных для /home</legend>
|
||||||
|
${createElement("home", "success", "Отдать успешный ответ", basePath)}
|
||||||
|
${createElement("home", "empty", "Отдать пустой массив", basePath)}
|
||||||
|
${createElement("home", "error", "Отдать ошибку", basePath)}
|
||||||
|
</fieldset>
|
||||||
|
</div>
|
||||||
|
`);
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get("/admin/game-page", (request, response) => {
|
||||||
|
response.send(`
|
||||||
|
<div>
|
||||||
|
<fieldset>
|
||||||
|
<legend>Настройка данных для /game-page</legend>
|
||||||
|
${createElement(
|
||||||
|
"game-page",
|
||||||
|
"success",
|
||||||
|
"Отдать успешный ответ"
|
||||||
|
)}
|
||||||
|
${createElement("game-page", "empty", "Отдать пустой массив")}
|
||||||
|
${createElement("game-page", "error", "Отдать ошибку")}
|
||||||
|
|
||||||
|
</fieldset>
|
||||||
|
</div>
|
||||||
|
`);
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get("/admin/categories", (request, response) => {
|
||||||
|
response.send(`
|
||||||
|
<div>
|
||||||
|
<fieldset>
|
||||||
|
<legend>Настройка данных для /categories</legend>
|
||||||
|
${createElement(
|
||||||
|
"categories",
|
||||||
|
"success",
|
||||||
|
"Отдать успешный ответ"
|
||||||
|
)}
|
||||||
|
${createElement("categories", "empty", "Отдать пустой массив")}
|
||||||
|
${createElement("categories", "error", "Отдать ошибку")}
|
||||||
|
</fieldset>
|
||||||
|
</div>
|
||||||
|
`);
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get("/admin/favourites", (request, response) => {
|
||||||
|
response.send(`
|
||||||
|
<div>
|
||||||
|
<fieldset>
|
||||||
|
<legend>Настройка данных для /favourites</legend>
|
||||||
|
${createElement(
|
||||||
|
"favourites",
|
||||||
|
"success",
|
||||||
|
"Отдать успешный ответ"
|
||||||
|
)}
|
||||||
|
${createElement("favourites", "empty", "Отдать пустой массив")}
|
||||||
|
${createElement("favourites", "error", "Отдать ошибку")}
|
||||||
|
</fieldset>
|
||||||
|
</div>
|
||||||
|
`);
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get("/admin/set/:key/:value", (request, response) => {
|
||||||
|
const { key, value } = request.params;
|
||||||
|
stubs[key] = value;
|
||||||
|
response.send("Настройки обновлены!");
|
||||||
|
});
|
||||||
@@ -5,28 +5,28 @@
|
|||||||
{
|
{
|
||||||
"username": "Пользователь1",
|
"username": "Пользователь1",
|
||||||
"text": "Текст комментария 1",
|
"text": "Текст комментария 1",
|
||||||
"likes": 11,
|
"likes": 13,
|
||||||
"rating": 8,
|
"rating": 8,
|
||||||
"date": "2025-03-01T10:00:00Z"
|
"date": "2025-03-01T10:00:00Z"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"username": "Пользователь2",
|
"username": "Пользователь2",
|
||||||
"text": "Текст комментария 2",
|
"text": "Текст комментария 2",
|
||||||
"likes": 7,
|
"likes": 10,
|
||||||
"rating": 7,
|
"rating": 7,
|
||||||
"date": "2025-01-01T10:00:00Z"
|
"date": "2025-01-01T10:00:00Z"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"username": "Пользователь3",
|
"username": "Пользователь3",
|
||||||
"text": "Текст комментария 3",
|
"text": "Текст комментария 3",
|
||||||
"likes": 2,
|
"likes": 4,
|
||||||
"rating": 3,
|
"rating": 3,
|
||||||
"date": "2025-02-01T10:00:00Z"
|
"date": "2025-02-01T10:00:00Z"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"username": "Пользователь4",
|
"username": "Пользователь4",
|
||||||
"text": "Текст комментария 4",
|
"text": "Текст комментария 4",
|
||||||
"likes": 15,
|
"likes": 18,
|
||||||
"rating": 2,
|
"rating": 2,
|
||||||
"date": "2025-12-01T10:00:00Z"
|
"date": "2025-12-01T10:00:00Z"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,41 +3,43 @@
|
|||||||
"data": [
|
"data": [
|
||||||
{
|
{
|
||||||
"id": 1,
|
"id": 1,
|
||||||
|
"title": "Elden Ring",
|
||||||
|
"image": "game17",
|
||||||
|
"price": 3295,
|
||||||
|
"old_price": 3599,
|
||||||
|
"imgPath": "img_top_17",
|
||||||
|
"description": "Крупномасштабная RPG, действие которой происходит в обширном открытом мире c богатой мифологией и множеством опасных врагов.",
|
||||||
|
"category": "RPG"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 2,
|
||||||
"title": "The Witcher 3: Wild Hunt",
|
"title": "The Witcher 3: Wild Hunt",
|
||||||
"image": "game1",
|
"image": "game1",
|
||||||
"price": 990,
|
"price": 990,
|
||||||
"old_price": 1200,
|
"old_price": 1200,
|
||||||
"os": "windows",
|
|
||||||
"imgPath": "img_top_1",
|
"imgPath": "img_top_1",
|
||||||
"description": "Эпическая RPG с открытым миром, в которой Геральт из Ривии охотится на монстров и раскрывает политические заговоры.",
|
"description": "Эпическая RPG с открытым миром, в которой Геральт из Ривии охотится на монстров и раскрывает политические заговоры.",
|
||||||
"category": "RPG"
|
"category": "RPG"
|
||||||
,"fav1": "star1",
|
|
||||||
"fav2": "star2"
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 2,
|
"id": 17,
|
||||||
"title": "Red Dead Redemption 2",
|
"title": "Red Dead Redemption 2",
|
||||||
"image": "game2",
|
"image": "game2",
|
||||||
"price": 980,
|
"price": 980,
|
||||||
"old_price": 3800,
|
"old_price": 3800,
|
||||||
"os": "windows",
|
|
||||||
"imgPath": "img_top_2",
|
"imgPath": "img_top_2",
|
||||||
"description": "Приключенческая игра с открытым миром на Диком Западе, рассказывающая историю Артура Моргана.",
|
"description": "Приключенческая игра с открытым миром на Диком Западе, рассказывающая историю Артура Моргана.",
|
||||||
"category": "Adventures"
|
"category": "Adventures"
|
||||||
,"fav1": "star1",
|
|
||||||
"fav2": "star2"
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 3,
|
"id": 3,
|
||||||
"title": "Forza Horizon 5",
|
"title": "Forza Horizon 5",
|
||||||
"image": "game3",
|
"image": "game3",
|
||||||
"price": 1900,
|
"price": 1900,
|
||||||
"os": "windows",
|
|
||||||
"imgPath": "img_top_3",
|
"imgPath": "img_top_3",
|
||||||
"description": "Гоночная игра с огромным открытым миром, действие которой происходит в Мексике.",
|
"description": "Гоночная игра с огромным открытым миром, действие которой происходит в Мексике.",
|
||||||
"category": "Race"
|
"category": "Race"
|
||||||
,"fav1": "star1",
|
|
||||||
"fav2": "star2"
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 4,
|
"id": 4,
|
||||||
@@ -45,72 +47,66 @@
|
|||||||
"image": "game4",
|
"image": "game4",
|
||||||
"price": 1200,
|
"price": 1200,
|
||||||
"old_price": 2500,
|
"old_price": 2500,
|
||||||
"os": "windows",
|
|
||||||
"imgPath": "img_top_4",
|
"imgPath": "img_top_4",
|
||||||
"description": "Экшен-шутер с элементами RPG, разворачивающийся в альтернативной Советской России.",
|
"description": "Экшен-шутер с элементами RPG, разворачивающийся в альтернативной Советской России.",
|
||||||
"category": "Shooters"
|
"category": "Shooters"
|
||||||
,"fav1": "star1",
|
|
||||||
"fav2": "star2"
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 5,
|
"id": 5,
|
||||||
"title": "Counter-Strike 2",
|
"title": "Counter-Strike 2",
|
||||||
"image": "game5",
|
"image": "game5",
|
||||||
"price": 479,
|
"price": 479,
|
||||||
"os": "windows",
|
|
||||||
"imgPath": "img_top_5",
|
"imgPath": "img_top_5",
|
||||||
"description": "Популярный онлайн-шутер с соревновательным геймплеем и тактическими элементами.",
|
"description": "Популярный онлайн-шутер с соревновательным геймплеем и тактическими элементами.",
|
||||||
"category": "Shooters"
|
"category": "Shooters"
|
||||||
,"fav1": "star1",
|
|
||||||
"fav2": "star2"
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 6,
|
"id": 6,
|
||||||
"title": "Grand Theft Auto V",
|
"title": "Grand Theft Auto V",
|
||||||
"image": "game6",
|
"image": "game6",
|
||||||
"price": 700,
|
"price": 700,
|
||||||
"os": "windows",
|
|
||||||
"imgPath": "img_top_6",
|
"imgPath": "img_top_6",
|
||||||
"description": "Игра с открытым миром, где можно погрузиться в криминальный мир Лос-Сантоса.",
|
"description": "Игра с открытым миром, где можно погрузиться в криминальный мир Лос-Сантоса.",
|
||||||
"category": "Adventures"
|
"category": "Adventures"
|
||||||
,"fav1": "star1",
|
|
||||||
"fav2": "star2"
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 7,
|
"id": 7,
|
||||||
"title": "Assassin’s Creed IV: Black Flag",
|
"title": "Assassin’s Creed IV: Black Flag",
|
||||||
"image": "game7",
|
"image": "game7",
|
||||||
"price": 1100,
|
"price": 1100,
|
||||||
"os": "windows",
|
|
||||||
"imgPath": "img_top_7",
|
"imgPath": "img_top_7",
|
||||||
"description": "Приключенческая игра о пиратах и морских сражениях в эпоху золотого века пиратства.",
|
"description": "Приключенческая игра о пиратах и морских сражениях в эпоху золотого века пиратства.",
|
||||||
"category": "Adventures"
|
"category": "Adventures"
|
||||||
,"fav1": "star1",
|
|
||||||
"fav2": "star2"
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 8,
|
"id": 8,
|
||||||
"title": "Spider-Man",
|
"title": "Spider-Man",
|
||||||
"image": "game8",
|
"image": "game8",
|
||||||
"price": 3800,
|
"price": 3800,
|
||||||
"os": "windows",
|
|
||||||
"imgPath": "img_top_8",
|
"imgPath": "img_top_8",
|
||||||
"description": "Игра о супергерое Человеке-пауке с захватывающими битвами и паркуром по Нью-Йорку.",
|
"description": "Игра о супергерое Человеке-пауке с захватывающими битвами и паркуром по Нью-Йорку.",
|
||||||
"category": "Action"
|
"category": "Action"
|
||||||
,"fav1": "star1",
|
|
||||||
"fav2": "star2"
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 9,
|
"id": 9,
|
||||||
"title": "Assassin’s Creed Mirage",
|
"title": "Assassin’s Creed Mirage",
|
||||||
"image": "game9",
|
"image": "game9",
|
||||||
"price": 1600,
|
"price": 1600,
|
||||||
"os": "windows",
|
|
||||||
"imgPath": "img_top_9",
|
"imgPath": "img_top_9",
|
||||||
"description": "Приключенческая игра с упором на скрытность, вдохновленная классическими частями серии.",
|
"description": "Приключенческая игра с упором на скрытность, вдохновленная классическими частями серии.",
|
||||||
"category": "Action"
|
"category": "Action"
|
||||||
,"fav1": "star1",
|
|
||||||
"fav2": "star2"
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 10,
|
"id": 10,
|
||||||
@@ -118,79 +114,72 @@
|
|||||||
"image": "game10",
|
"image": "game10",
|
||||||
"price": 800,
|
"price": 800,
|
||||||
"old_price": 2200,
|
"old_price": 2200,
|
||||||
"os": "windows",
|
|
||||||
"imgPath": "img_top_10",
|
"imgPath": "img_top_10",
|
||||||
"description": "RPG с открытым миром о викингах, включающая битвы, исследования и строительство поселений.",
|
"description": "RPG с открытым миром о викингах, включающая битвы, исследования и строительство поселений.",
|
||||||
"category": "RPG"
|
"category": "RPG"
|
||||||
,"fav1": "star1",
|
|
||||||
"fav2": "star2"
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 11,
|
"id": 11,
|
||||||
"title": "ARK: Survival Evolved",
|
"title": "ARK: Survival Evolved",
|
||||||
"image": "game11",
|
"image": "game11",
|
||||||
"price": 790,
|
"price": 790,
|
||||||
"os": "windows",
|
|
||||||
"imgPath": "img_top_11",
|
"imgPath": "img_top_11",
|
||||||
"description": "Выживание в открытом мире с динозаврами, строительством и многопользовательскими элементами.",
|
"description": "Выживание в открытом мире с динозаврами, строительством и многопользовательскими элементами.",
|
||||||
"category": "Simulators"
|
"category": "Simulators"
|
||||||
,"fav1": "star1",
|
|
||||||
"fav2": "star2"
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 12,
|
"id": 12,
|
||||||
"title": "FIFA 23",
|
"title": "FIFA 23",
|
||||||
"image": "game12",
|
"image": "game12",
|
||||||
"price": 3900,
|
"price": 3900,
|
||||||
"os": "windows",
|
|
||||||
"imgPath": "img_top_12",
|
"imgPath": "img_top_12",
|
||||||
"description": "Популярный футбольный симулятор с улучшенной графикой и реалистичным геймплеем.",
|
"description": "Популярный футбольный симулятор с улучшенной графикой и реалистичным геймплеем.",
|
||||||
"category": "Sports"
|
"category": "Sports"
|
||||||
,"fav1": "star1",
|
|
||||||
"fav2": "star2"
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 13,
|
"id": 13,
|
||||||
"title": "Dirt 5",
|
"title": "Dirt 5",
|
||||||
"image": "game13",
|
"image": "game13",
|
||||||
"price": 2300,
|
"price": 2300,
|
||||||
"os": "windows",
|
|
||||||
"imgPath": "img_top_13",
|
"imgPath": "img_top_13",
|
||||||
"description": "Аркадная гоночная игра с фокусом на ралли и внедорожных соревнованиях.",
|
"description": "Аркадная гоночная игра с фокусом на ралли и внедорожных соревнованиях.",
|
||||||
"category": "Race"
|
"category": "Race"
|
||||||
,"fav1": "star1",
|
|
||||||
"fav2": "star2"
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 14,
|
"id": 14,
|
||||||
"title": "Cyberpunk 2077",
|
"title": "Cyberpunk 2077",
|
||||||
"image": "game14",
|
"image": "game14",
|
||||||
"price": 3400,
|
"price": 3400,
|
||||||
"os": "windows",
|
|
||||||
"imgPath": "img_top_14",
|
"imgPath": "img_top_14",
|
||||||
"description": "RPG в киберпанк-сеттинге с нелинейным сюжетом и детализированным открытым миром.",
|
"description": "RPG в киберпанк-сеттинге с нелинейным сюжетом и детализированным открытым миром.",
|
||||||
"category": "RPG"
|
"category": "RPG"
|
||||||
,"fav1": "star1",
|
|
||||||
"fav2": "star2"
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 15,
|
"id": 15,
|
||||||
"title": "Age of Empires IV",
|
"title": "Age of Empires IV",
|
||||||
"image": "game15",
|
"image": "game15",
|
||||||
"price": 3200,
|
"price": 3200,
|
||||||
"os": "windows",
|
|
||||||
"imgPath": "img_top_15",
|
"imgPath": "img_top_15",
|
||||||
"description": "Классическая стратегия в реальном времени с историческими кампаниями.",
|
"description": "Классическая стратегия в реальном времени с историческими кампаниями.",
|
||||||
"category": "Strategies"
|
"category": "Strategies"
|
||||||
,"fav1": "star1",
|
|
||||||
"fav2": "star2"
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 16,
|
"id": 16,
|
||||||
"title": "Civilization VI",
|
"title": "Civilization VI",
|
||||||
"image": "game16",
|
"image": "game16",
|
||||||
"price": 4200,
|
"price": 4200,
|
||||||
"os": "windows",
|
|
||||||
"imgPath": "img_top_16",
|
"imgPath": "img_top_16",
|
||||||
"description": "Глобальная пошаговая стратегия, в которой игроки строят и развивают цивилизации.",
|
"description": "Глобальная пошаговая стратегия, в которой игроки строят и развивают цивилизации.",
|
||||||
"category": "Strategies"
|
"category": "Strategies"
|
||||||
|
|||||||
@@ -105,23 +105,27 @@
|
|||||||
{
|
{
|
||||||
"image": "news1",
|
"image": "news1",
|
||||||
"text": "Разработчики Delta Force: Hawk Ops представили крупномасштабный режим Havoc Warfare",
|
"text": "Разработчики Delta Force: Hawk Ops представили крупномасштабный режим Havoc Warfare",
|
||||||
"imgPath": "img_news_1"
|
"imgPath": "img_news_1",
|
||||||
|
"link": "https://gamemag.ru/news/185583/delta-force-hawk-ops-gameplay-showcase-havoc-warfare"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"image": "news2",
|
"image": "news2",
|
||||||
"text": "Первый трейлер Assassin’s Creed Shadows — с темнокожим самураем в феодальной Японии",
|
"text": "Первый трейлер Assassin’s Creed Shadows — с темнокожим самураем в феодальной Японии",
|
||||||
"imgPath": "img_news_2"
|
"imgPath": "img_news_2",
|
||||||
|
"link": "https://stopgame.ru/newsdata/62686/pervyy_trailer_assassin_s_creed_shadows_s_temnokozhim_samuraem_v_feodalnoy_yaponii"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"image": "news3",
|
"image": "news3",
|
||||||
"text": "Призрак Цусимы» вышел на ПК — и уже ставит рекорды для Sony",
|
"text": "Призрак Цусимы» вышел на ПК — и уже ставит рекорды для Sony",
|
||||||
"imgPath": "img_news_3"
|
"imgPath": "img_news_3",
|
||||||
|
"link": "https://stopgame.ru/newsdata/62706/prizrak_cusimy_vyshel_na_pk_i_uzhe_stavit_rekordy_dlya_sony"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"image": "news4",
|
"image": "news4",
|
||||||
"text": "Авторы Skull and Bones расширяют планы на второй сезо",
|
"text": "Авторы Skull and Bones расширяют планы на второй сезон",
|
||||||
"imgPath": "img_news_4"
|
"imgPath": "img_news_4",
|
||||||
|
"link": "https://stopgame.ru/newsdata/62711/avtory_skull_and_bones_rasshiryayut_plany_na_vtoroy_sezon"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
1
server/routers/kfu-m-24-1/back-new/.env
Normal file
1
server/routers/kfu-m-24-1/back-new/.env
Normal file
@@ -0,0 +1 @@
|
|||||||
|
GIGACHAT_API_KEY=NzgzNTkxMjMtNDQ0Ny00ODFhLTkwMjgtODYxZjUzYjI0ZWQxOjA5NDEwMzEwLTM5YjItNDUzOS1hYWYzLWE4ZDA1MDExNmQ4Nw==
|
||||||
2
server/routers/kfu-m-24-1/back-new/.gitignore
vendored
Normal file
2
server/routers/kfu-m-24-1/back-new/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
node_modules/
|
||||||
|
.env
|
||||||
21
server/routers/kfu-m-24-1/back-new/README.md
Normal file
21
server/routers/kfu-m-24-1/back-new/README.md
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
# back-new
|
||||||
|
|
||||||
|
非Python实现的后端(Node.js + Express)
|
||||||
|
|
||||||
|
## 启动方法
|
||||||
|
|
||||||
|
1. 安装依赖:
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
```
|
||||||
|
2. 启动服务:
|
||||||
|
```bash
|
||||||
|
npm start
|
||||||
|
```
|
||||||
|
|
||||||
|
默认端口:`3002`
|
||||||
|
|
||||||
|
## 支持接口
|
||||||
|
- POST `/api/auth/login` 用户登录
|
||||||
|
- POST `/api/auth/register` 用户注册
|
||||||
|
- GET `/gigachat/prompt?prompt=xxx` 生成图片(返回模拟图片链接)
|
||||||
24
server/routers/kfu-m-24-1/back-new/app.js
Normal file
24
server/routers/kfu-m-24-1/back-new/app.js
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const cors = require('cors');
|
||||||
|
const featuresConfig = require('./features.config');
|
||||||
|
const imageRoutes = require('./features/image/image.routes');
|
||||||
|
|
||||||
|
const app = express();
|
||||||
|
app.use(cors());
|
||||||
|
app.use(express.json());
|
||||||
|
|
||||||
|
if (featuresConfig.auth) {
|
||||||
|
app.use('/api/auth', require('./features/auth/auth.routes'));
|
||||||
|
}
|
||||||
|
if (featuresConfig.user) {
|
||||||
|
app.use('/api/user', require('./features/user/user.routes'));
|
||||||
|
}
|
||||||
|
if (featuresConfig.image) {
|
||||||
|
app.use('/gigachat', imageRoutes);
|
||||||
|
}
|
||||||
|
|
||||||
|
app.get('/api/', (req, res) => {
|
||||||
|
res.json({ message: 'API root' });
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = app;
|
||||||
5
server/routers/kfu-m-24-1/back-new/features.config.js
Normal file
5
server/routers/kfu-m-24-1/back-new/features.config.js
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
module.exports = {
|
||||||
|
auth: true,
|
||||||
|
user: true,
|
||||||
|
image: true, // 关闭为 false
|
||||||
|
};
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
const usersDb = require('../../shared/usersDb');
|
||||||
|
const makeLinks = require('../../shared/hateoas');
|
||||||
|
|
||||||
|
exports.login = (req, res) => {
|
||||||
|
const { username, password, email } = req.body;
|
||||||
|
const user = usersDb.findUser(username, email, password);
|
||||||
|
if (user) {
|
||||||
|
res.json({
|
||||||
|
data: {
|
||||||
|
user: {
|
||||||
|
id: user.id,
|
||||||
|
username: user.username,
|
||||||
|
email: user.email,
|
||||||
|
firstName: user.firstName,
|
||||||
|
lastName: user.lastName
|
||||||
|
},
|
||||||
|
token: 'token-' + user.id,
|
||||||
|
message: 'Login successful'
|
||||||
|
},
|
||||||
|
_links: makeLinks('/api/auth', {
|
||||||
|
self: '/login',
|
||||||
|
profile: '/profile/',
|
||||||
|
logout: '/logout'
|
||||||
|
}),
|
||||||
|
_meta: {}
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
res.status(401).json({ error: 'Invalid credentials' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.register = (req, res) => {
|
||||||
|
const { username, password, email, firstName, lastName } = req.body;
|
||||||
|
if (usersDb.exists(username, email)) {
|
||||||
|
return res.status(409).json({ error: 'User already exists' });
|
||||||
|
}
|
||||||
|
const newUser = usersDb.addUser({ username, password, email, firstName, lastName });
|
||||||
|
res.json({
|
||||||
|
data: {
|
||||||
|
user: {
|
||||||
|
id: newUser.id,
|
||||||
|
username,
|
||||||
|
email,
|
||||||
|
firstName,
|
||||||
|
lastName
|
||||||
|
},
|
||||||
|
token: 'token-' + newUser.id,
|
||||||
|
message: 'Register successful'
|
||||||
|
},
|
||||||
|
_links: makeLinks('/api/auth', {
|
||||||
|
self: '/register',
|
||||||
|
login: '/login',
|
||||||
|
profile: '/profile/'
|
||||||
|
}),
|
||||||
|
_meta: {}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.profile = (req, res) => {
|
||||||
|
const auth = req.headers.authorization;
|
||||||
|
if (!auth || !auth.startsWith('Bearer ')) {
|
||||||
|
return res.status(401).json({ error: 'No token provided' });
|
||||||
|
}
|
||||||
|
const token = auth.replace('Bearer ', '');
|
||||||
|
const id = parseInt(token.replace('token-', ''));
|
||||||
|
const user = usersDb.findById(id);
|
||||||
|
if (!user) {
|
||||||
|
return res.status(401).json({ error: 'Invalid token' });
|
||||||
|
}
|
||||||
|
res.json({
|
||||||
|
data: {
|
||||||
|
id: user.id,
|
||||||
|
username: user.username,
|
||||||
|
email: user.email,
|
||||||
|
firstName: user.firstName,
|
||||||
|
lastName: user.lastName
|
||||||
|
},
|
||||||
|
_links: makeLinks('/api/auth', {
|
||||||
|
self: '/profile/',
|
||||||
|
logout: '/logout'
|
||||||
|
}),
|
||||||
|
_meta: {}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.logout = (req, res) => {
|
||||||
|
res.json({
|
||||||
|
message: 'Logout successful',
|
||||||
|
_links: makeLinks('/api/auth', {
|
||||||
|
self: '/logout',
|
||||||
|
login: '/login'
|
||||||
|
}),
|
||||||
|
_meta: {}
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const router = express.Router();
|
||||||
|
const ctrl = require('./auth.controller');
|
||||||
|
|
||||||
|
router.post('/login', ctrl.login);
|
||||||
|
router.post('/register', ctrl.register);
|
||||||
|
router.get('/profile/', ctrl.profile);
|
||||||
|
router.post('/logout', ctrl.logout);
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
const axios = require('axios');
|
||||||
|
const makeLinks = require('../../shared/hateoas');
|
||||||
|
const path = require('path');
|
||||||
|
const qs = require('qs');
|
||||||
|
const { v4: uuidv4 } = require('uuid');
|
||||||
|
require('dotenv').config({ path: path.resolve(__dirname, '../../.env') });
|
||||||
|
process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0';
|
||||||
|
|
||||||
|
exports.generate = async (req, res) => {
|
||||||
|
const { prompt } = req.query;
|
||||||
|
if (!prompt) {
|
||||||
|
return res.status(400).json({ error: 'Prompt parameter is required' });
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const apiKey = process.env.GIGACHAT_API_KEY;
|
||||||
|
const tokenResp = await axios.post(
|
||||||
|
'https://ngw.devices.sberbank.ru:9443/api/v2/oauth',
|
||||||
|
{
|
||||||
|
'scope':' GIGACHAT_API_PERS',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/x-www-form-urlencoded',
|
||||||
|
'Accept': 'application/json',
|
||||||
|
'Authorization': `Basic ${apiKey}`,
|
||||||
|
'RqUID':'6f0b1291-c7f3-43c6-bb2e-9f3efb2dc98e'
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
const accessToken = tokenResp.data.access_token;
|
||||||
|
const chatResp = await axios.post(
|
||||||
|
'https://gigachat.devices.sberbank.ru/api/v1/chat/completions',
|
||||||
|
{
|
||||||
|
model: "GigaChat",
|
||||||
|
messages: [
|
||||||
|
{ role: "system", content: "Ты — Василий Кандинский" },
|
||||||
|
{ role: "user", content: prompt }
|
||||||
|
],
|
||||||
|
stream: false,
|
||||||
|
function_call: 'auto'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${accessToken}`,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'RqUID': uuidv4(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
const content = chatResp.data.choices[0].message.content;
|
||||||
|
const match = content.match(/<img src=\"(.*?)\"/);
|
||||||
|
if (!match) {
|
||||||
|
return res.status(500).json({ error: 'No image generated' });
|
||||||
|
}
|
||||||
|
const imageId = match[1];
|
||||||
|
const imageResp = await axios.get(
|
||||||
|
`https://gigachat.devices.sberbank.ru/api/v1/files/${imageId}/content`,
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${accessToken}`,
|
||||||
|
'RqUID': uuidv4(),
|
||||||
|
},
|
||||||
|
responseType: 'arraybuffer'
|
||||||
|
}
|
||||||
|
);
|
||||||
|
res.set('Content-Type', 'image/jpeg');
|
||||||
|
res.set('X-HATEOAS', JSON.stringify(makeLinks('/gigachat', { self: '/prompt' })));
|
||||||
|
res.send(imageResp.data);
|
||||||
|
} catch (err) {
|
||||||
|
if (err.response) {
|
||||||
|
console.error('AI生成图片出错:');
|
||||||
|
console.error('status:', err.response.status);
|
||||||
|
console.error('headers:', err.response.headers);
|
||||||
|
console.error('data:', err.response.data);
|
||||||
|
console.error('config:', err.config);
|
||||||
|
} else {
|
||||||
|
console.error('AI生成图片出错:', err.message);
|
||||||
|
}
|
||||||
|
res.status(500).json({ error: err.message });
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const router = express.Router();
|
||||||
|
const ctrl = require('./image.controller');
|
||||||
|
|
||||||
|
router.get('/prompt', ctrl.generate);
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
const usersDb = require('../../shared/usersDb');
|
||||||
|
const makeLinks = require('../../shared/hateoas');
|
||||||
|
|
||||||
|
exports.list = (req, res) => {
|
||||||
|
res.json({
|
||||||
|
data: usersDb.getAll(),
|
||||||
|
_links: makeLinks('/api/user', {
|
||||||
|
self: '/list',
|
||||||
|
}),
|
||||||
|
_meta: {}
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const router = express.Router();
|
||||||
|
const ctrl = require('./user.controller');
|
||||||
|
|
||||||
|
router.get('/list', ctrl.list);
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
5455
server/routers/kfu-m-24-1/back-new/package-lock.json
generated
Normal file
5455
server/routers/kfu-m-24-1/back-new/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
21
server/routers/kfu-m-24-1/back-new/package.json
Normal file
21
server/routers/kfu-m-24-1/back-new/package.json
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"name": "back-new",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "非Python实现的后端,兼容前端接口",
|
||||||
|
"main": "server.js",
|
||||||
|
"scripts": {
|
||||||
|
"start": "node server.js",
|
||||||
|
"test": "jest"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"axios": "^1.10.0",
|
||||||
|
"cors": "^2.8.5",
|
||||||
|
"dotenv": "^17.0.0",
|
||||||
|
"express": "^4.21.2",
|
||||||
|
"qs": "^6.14.0",
|
||||||
|
"uuid": "^11.1.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"jest": "^30.0.3"
|
||||||
|
}
|
||||||
|
}
|
||||||
5
server/routers/kfu-m-24-1/back-new/server.js
Normal file
5
server/routers/kfu-m-24-1/back-new/server.js
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
const app = require('./app');
|
||||||
|
const PORT = process.env.PORT || 3002;
|
||||||
|
app.listen(PORT, () => {
|
||||||
|
console.log(`Mock backend running on https://dev.bro.js.ru/ms/back-new/${PORT}`);
|
||||||
|
});
|
||||||
8
server/routers/kfu-m-24-1/back-new/shared/hateoas.js
Normal file
8
server/routers/kfu-m-24-1/back-new/shared/hateoas.js
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
function makeLinks(base, links) {
|
||||||
|
const result = {};
|
||||||
|
for (const [rel, path] of Object.entries(links)) {
|
||||||
|
result[rel] = { href: base + path };
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
module.exports = makeLinks;
|
||||||
20
server/routers/kfu-m-24-1/back-new/shared/usersDb.js
Normal file
20
server/routers/kfu-m-24-1/back-new/shared/usersDb.js
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
let users = [
|
||||||
|
{ id: 1, username: 'test', password: '123456', email: 'test@example.com', firstName: 'Test', lastName: 'User' }
|
||||||
|
];
|
||||||
|
let nextId = 2;
|
||||||
|
|
||||||
|
exports.findUser = (username, email, password) =>
|
||||||
|
users.find(u => (u.username === username || u.email === email) && u.password === password);
|
||||||
|
|
||||||
|
exports.findById = (id) => users.find(u => u.id === id);
|
||||||
|
|
||||||
|
exports.addUser = ({ username, password, email, firstName, lastName }) => {
|
||||||
|
const newUser = { id: nextId++, username, password, email, firstName, lastName };
|
||||||
|
users.push(newUser);
|
||||||
|
return newUser;
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.exists = (username, email) =>
|
||||||
|
users.some(u => u.username === username || u.email === email);
|
||||||
|
|
||||||
|
exports.getAll = () => users;
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -607,8 +607,7 @@ function createGigachat(options = {}) {
|
|||||||
}
|
}
|
||||||
var gigachat = createGigachat();
|
var gigachat = createGigachat();
|
||||||
// Annotate the CommonJS export names for ESM import in node:
|
// Annotate the CommonJS export names for ESM import in node:
|
||||||
0 && (module.exports = {
|
module.exports = {
|
||||||
createGigachat,
|
createGigachat,
|
||||||
gigachat
|
gigachat
|
||||||
});
|
}
|
||||||
//# sourceMappingURL=index.js.map
|
|
||||||
|
|||||||
@@ -84,7 +84,7 @@ router.use(async (req, res, next) => {
|
|||||||
process.env.GIGACHAT_ACCESS_TOKEN = json.access_token;
|
process.env.GIGACHAT_ACCESS_TOKEN = json.access_token;
|
||||||
process.env.GIGACHAT_EXPIRES_AT = json.expires_at;
|
process.env.GIGACHAT_EXPIRES_AT = json.expires_at;
|
||||||
console.log(JSON.stringify(response.data));
|
console.log(JSON.stringify(response.data));
|
||||||
} catch {
|
} catch (error) {
|
||||||
console.log(error);
|
console.log(error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
113
server/routers/procurement/index.js
Normal file
113
server/routers/procurement/index.js
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const cors = require('cors');
|
||||||
|
const dotenv = require('dotenv');
|
||||||
|
const fs = require('fs');
|
||||||
|
|
||||||
|
// Импортировать mongoose из общего модуля (подключение происходит в server/utils/mongoose.ts)
|
||||||
|
const mongoose = require('../../utils/mongoose');
|
||||||
|
|
||||||
|
// Загрузить переменные окружения
|
||||||
|
dotenv.config();
|
||||||
|
|
||||||
|
// Включить логирование при разработке: установите DEV=true в .env или при запуске
|
||||||
|
// export DEV=true && npm start (для Linux/Mac)
|
||||||
|
// set DEV=true && npm start (для Windows)
|
||||||
|
// По умолчанию логи отключены. Все console.log функции отключаются если DEV !== 'true'
|
||||||
|
if (process.env.DEV === 'true') {
|
||||||
|
console.log('ℹ️ DEBUG MODE ENABLED - All logs are visible');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Импортировать маршруты - прямые пути без path.join и __dirname
|
||||||
|
const authRoutes = require('./routes/auth');
|
||||||
|
const companiesRoutes = require('./routes/companies');
|
||||||
|
const messagesRoutes = require('./routes/messages');
|
||||||
|
const searchRoutes = require('./routes/search');
|
||||||
|
const buyRoutes = require('./routes/buy');
|
||||||
|
const experienceRoutes = require('./routes/experience');
|
||||||
|
const productsRoutes = require('./routes/products');
|
||||||
|
const reviewsRoutes = require('./routes/reviews');
|
||||||
|
const buyProductsRoutes = require('./routes/buyProducts');
|
||||||
|
const requestsRoutes = require('./routes/requests');
|
||||||
|
const homeRoutes = require('./routes/home');
|
||||||
|
const activityRoutes = require('./routes/activity');
|
||||||
|
|
||||||
|
const app = express();
|
||||||
|
|
||||||
|
// Проверить подключение к MongoDB (подключение происходит в server/utils/mongoose.ts)
|
||||||
|
const dbConnected = mongoose.connection.readyState === 1;
|
||||||
|
|
||||||
|
// Middleware
|
||||||
|
app.use(cors());
|
||||||
|
app.use(express.json({ charset: 'utf-8' }));
|
||||||
|
app.use(express.urlencoded({ extended: true, charset: 'utf-8' }));
|
||||||
|
|
||||||
|
// Set UTF-8 encoding for all responses
|
||||||
|
app.use((req, res, next) => {
|
||||||
|
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
|
||||||
|
// CORS headers
|
||||||
|
app.use((req, res, next) => {
|
||||||
|
res.setHeader('Access-Control-Allow-Origin', '*');
|
||||||
|
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
|
||||||
|
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization');
|
||||||
|
if (req.method === 'OPTIONS') {
|
||||||
|
res.sendStatus(200);
|
||||||
|
} else {
|
||||||
|
next();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Задержка для имитации сети (опционально)
|
||||||
|
const delay = (ms = 300) => (req, res, next) => setTimeout(next, ms);
|
||||||
|
app.use(delay());
|
||||||
|
|
||||||
|
// Статика для загруженных файлов
|
||||||
|
const uploadsRoot = 'server/remote-assets/uploads';
|
||||||
|
if (!fs.existsSync(uploadsRoot)) {
|
||||||
|
fs.mkdirSync(uploadsRoot, { recursive: true });
|
||||||
|
}
|
||||||
|
app.use('/uploads', express.static(uploadsRoot));
|
||||||
|
|
||||||
|
// Health check endpoint
|
||||||
|
app.get('/health', (req, res) => {
|
||||||
|
res.json({
|
||||||
|
status: 'ok',
|
||||||
|
api: 'running',
|
||||||
|
database: dbConnected ? 'mongodb' : 'mock',
|
||||||
|
timestamp: new Date().toISOString()
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Маршруты
|
||||||
|
app.use('/auth', authRoutes);
|
||||||
|
app.use('/companies', companiesRoutes);
|
||||||
|
app.use('/messages', messagesRoutes);
|
||||||
|
app.use('/search', searchRoutes);
|
||||||
|
app.use('/buy', buyRoutes);
|
||||||
|
app.use('/buy-products', buyProductsRoutes);
|
||||||
|
app.use('/experience', experienceRoutes);
|
||||||
|
app.use('/products', productsRoutes);
|
||||||
|
app.use('/reviews', reviewsRoutes);
|
||||||
|
app.use('/requests', requestsRoutes);
|
||||||
|
app.use('/home', homeRoutes);
|
||||||
|
app.use('/activities', activityRoutes);
|
||||||
|
|
||||||
|
// Обработка ошибок
|
||||||
|
app.use((err, req, res, next) => {
|
||||||
|
console.error('API Error:', err);
|
||||||
|
res.status(err.status || 500).json({
|
||||||
|
error: err.message || 'Internal server error'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// 404 handler
|
||||||
|
app.use((req, res) => {
|
||||||
|
res.status(404).json({
|
||||||
|
error: 'Not found'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Экспортировать для использования в brojs
|
||||||
|
module.exports = app;
|
||||||
42
server/routers/procurement/middleware/auth.js
Normal file
42
server/routers/procurement/middleware/auth.js
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
const jwt = require('jsonwebtoken');
|
||||||
|
|
||||||
|
const log = (message, data = '') => {
|
||||||
|
if (process.env.DEV === 'true') {
|
||||||
|
if (data) {
|
||||||
|
console.log(message, data);
|
||||||
|
} else {
|
||||||
|
console.log(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const verifyToken = (req, res, next) => {
|
||||||
|
const token = req.headers.authorization?.replace('Bearer ', '');
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
return res.status(401).json({ error: 'No token provided' });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const decoded = jwt.verify(token, process.env.JWT_SECRET || 'your-secret-key');
|
||||||
|
req.userId = decoded.userId;
|
||||||
|
req.companyId = decoded.companyId;
|
||||||
|
req.user = decoded;
|
||||||
|
log('[Auth] Token verified - userId:', decoded.userId, 'companyId:', decoded.companyId);
|
||||||
|
next();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[Auth] Token verification failed:', error.message);
|
||||||
|
return res.status(401).json({ error: 'Invalid token' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const generateToken = (userId, companyId, firstName = '', lastName = '', companyName = '') => {
|
||||||
|
log('[Auth] Generating token for userId:', userId, 'companyId:', companyId);
|
||||||
|
return jwt.sign(
|
||||||
|
{ userId, companyId, firstName, lastName, companyName },
|
||||||
|
process.env.JWT_SECRET || 'your-secret-key',
|
||||||
|
{ expiresIn: '7d' }
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = { verifyToken, generateToken };
|
||||||
61
server/routers/procurement/models/Activity.js
Normal file
61
server/routers/procurement/models/Activity.js
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
const mongoose = require('mongoose');
|
||||||
|
|
||||||
|
const activitySchema = new mongoose.Schema({
|
||||||
|
companyId: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
index: true
|
||||||
|
},
|
||||||
|
userId: {
|
||||||
|
type: String,
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
type: {
|
||||||
|
type: String,
|
||||||
|
enum: [
|
||||||
|
'message_received',
|
||||||
|
'message_sent',
|
||||||
|
'request_received',
|
||||||
|
'request_sent',
|
||||||
|
'request_response',
|
||||||
|
'product_accepted',
|
||||||
|
'review_received',
|
||||||
|
'profile_updated',
|
||||||
|
'product_added',
|
||||||
|
'buy_product_added'
|
||||||
|
],
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
type: String,
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
description: {
|
||||||
|
type: String
|
||||||
|
},
|
||||||
|
relatedCompanyId: {
|
||||||
|
type: String
|
||||||
|
},
|
||||||
|
relatedCompanyName: {
|
||||||
|
type: String
|
||||||
|
},
|
||||||
|
metadata: {
|
||||||
|
type: mongoose.Schema.Types.Mixed
|
||||||
|
},
|
||||||
|
read: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
},
|
||||||
|
createdAt: {
|
||||||
|
type: Date,
|
||||||
|
default: Date.now,
|
||||||
|
index: true
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Индексы для оптимизации
|
||||||
|
activitySchema.index({ companyId: 1, createdAt: -1 });
|
||||||
|
activitySchema.index({ companyId: 1, read: 1, createdAt: -1 });
|
||||||
|
|
||||||
|
module.exports = mongoose.model('Activity', activitySchema);
|
||||||
|
|
||||||
43
server/routers/procurement/models/BuyDocument.js
Normal file
43
server/routers/procurement/models/BuyDocument.js
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
const mongoose = require('mongoose');
|
||||||
|
|
||||||
|
const buyDocumentSchema = new mongoose.Schema({
|
||||||
|
id: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
unique: true,
|
||||||
|
index: true
|
||||||
|
},
|
||||||
|
ownerCompanyId: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
index: true
|
||||||
|
},
|
||||||
|
name: {
|
||||||
|
type: String,
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
type: {
|
||||||
|
type: String,
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
type: Number,
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
filePath: {
|
||||||
|
type: String,
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
acceptedBy: {
|
||||||
|
type: [String],
|
||||||
|
default: []
|
||||||
|
},
|
||||||
|
createdAt: {
|
||||||
|
type: Date,
|
||||||
|
default: Date.now,
|
||||||
|
index: true
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = mongoose.model('BuyDocument', buyDocumentSchema);
|
||||||
|
|
||||||
87
server/routers/procurement/models/BuyProduct.js
Normal file
87
server/routers/procurement/models/BuyProduct.js
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
const mongoose = require('mongoose');
|
||||||
|
|
||||||
|
// Явно определяем схему для файлов
|
||||||
|
const fileSchema = new mongoose.Schema({
|
||||||
|
id: {
|
||||||
|
type: String,
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
name: {
|
||||||
|
type: String,
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
url: {
|
||||||
|
type: String,
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
type: {
|
||||||
|
type: String,
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
type: Number,
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
storagePath: String,
|
||||||
|
uploadedAt: {
|
||||||
|
type: Date,
|
||||||
|
default: Date.now
|
||||||
|
}
|
||||||
|
}, { _id: false });
|
||||||
|
|
||||||
|
const buyProductSchema = new mongoose.Schema({
|
||||||
|
companyId: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
index: true
|
||||||
|
},
|
||||||
|
name: {
|
||||||
|
type: String,
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
description: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
minlength: 10,
|
||||||
|
maxlength: 1000
|
||||||
|
},
|
||||||
|
quantity: {
|
||||||
|
type: String,
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
unit: {
|
||||||
|
type: String,
|
||||||
|
default: 'шт'
|
||||||
|
},
|
||||||
|
files: [fileSchema],
|
||||||
|
acceptedBy: [{
|
||||||
|
companyId: {
|
||||||
|
type: mongoose.Schema.Types.ObjectId,
|
||||||
|
ref: 'Company'
|
||||||
|
},
|
||||||
|
acceptedAt: {
|
||||||
|
type: Date,
|
||||||
|
default: Date.now
|
||||||
|
}
|
||||||
|
}],
|
||||||
|
status: {
|
||||||
|
type: String,
|
||||||
|
enum: ['draft', 'published'],
|
||||||
|
default: 'published'
|
||||||
|
},
|
||||||
|
createdAt: {
|
||||||
|
type: Date,
|
||||||
|
default: Date.now,
|
||||||
|
index: true
|
||||||
|
},
|
||||||
|
updatedAt: {
|
||||||
|
type: Date,
|
||||||
|
default: Date.now
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Индексы для оптимизации поиска
|
||||||
|
buyProductSchema.index({ companyId: 1, createdAt: -1 });
|
||||||
|
buyProductSchema.index({ name: 'text', description: 'text' });
|
||||||
|
|
||||||
|
module.exports = mongoose.model('BuyProduct', buyProductSchema);
|
||||||
76
server/routers/procurement/models/Company.js
Normal file
76
server/routers/procurement/models/Company.js
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
const mongoose = require('mongoose');
|
||||||
|
|
||||||
|
const companySchema = new mongoose.Schema({
|
||||||
|
fullName: {
|
||||||
|
type: String,
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
shortName: String,
|
||||||
|
inn: {
|
||||||
|
type: String,
|
||||||
|
sparse: true
|
||||||
|
},
|
||||||
|
ogrn: String,
|
||||||
|
legalForm: String,
|
||||||
|
industry: String,
|
||||||
|
companySize: String,
|
||||||
|
website: String,
|
||||||
|
phone: String,
|
||||||
|
email: String,
|
||||||
|
slogan: String,
|
||||||
|
description: String,
|
||||||
|
foundedYear: Number,
|
||||||
|
employeeCount: String,
|
||||||
|
revenue: String,
|
||||||
|
legalAddress: String,
|
||||||
|
actualAddress: String,
|
||||||
|
bankDetails: String,
|
||||||
|
logo: String,
|
||||||
|
rating: {
|
||||||
|
type: Number,
|
||||||
|
default: 0,
|
||||||
|
min: 0,
|
||||||
|
max: 5
|
||||||
|
},
|
||||||
|
reviews: {
|
||||||
|
type: Number,
|
||||||
|
default: 0
|
||||||
|
},
|
||||||
|
ownerId: {
|
||||||
|
type: mongoose.Schema.Types.ObjectId,
|
||||||
|
ref: 'User'
|
||||||
|
},
|
||||||
|
platformGoals: [String],
|
||||||
|
productsOffered: String,
|
||||||
|
productsNeeded: String,
|
||||||
|
partnerIndustries: [String],
|
||||||
|
partnerGeography: [String],
|
||||||
|
verified: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
},
|
||||||
|
metrics: {
|
||||||
|
type: {
|
||||||
|
profileViews: { type: Number, default: 0 }
|
||||||
|
},
|
||||||
|
default: {}
|
||||||
|
},
|
||||||
|
createdAt: {
|
||||||
|
type: Date,
|
||||||
|
default: Date.now
|
||||||
|
},
|
||||||
|
updatedAt: {
|
||||||
|
type: Date,
|
||||||
|
default: Date.now
|
||||||
|
}
|
||||||
|
}, {
|
||||||
|
collection: 'companies',
|
||||||
|
minimize: false
|
||||||
|
});
|
||||||
|
|
||||||
|
// Индексы для поиска
|
||||||
|
companySchema.index({ fullName: 'text', shortName: 'text', description: 'text' });
|
||||||
|
companySchema.index({ industry: 1 });
|
||||||
|
companySchema.index({ rating: -1 });
|
||||||
|
|
||||||
|
module.exports = mongoose.model('Company', companySchema);
|
||||||
46
server/routers/procurement/models/Experience.js
Normal file
46
server/routers/procurement/models/Experience.js
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
const mongoose = require('mongoose');
|
||||||
|
|
||||||
|
const experienceSchema = new mongoose.Schema({
|
||||||
|
companyId: {
|
||||||
|
type: mongoose.Schema.Types.ObjectId,
|
||||||
|
ref: 'Company',
|
||||||
|
required: true,
|
||||||
|
index: true
|
||||||
|
},
|
||||||
|
confirmed: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
},
|
||||||
|
customer: {
|
||||||
|
type: String,
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
subject: {
|
||||||
|
type: String,
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
volume: {
|
||||||
|
type: String
|
||||||
|
},
|
||||||
|
contact: {
|
||||||
|
type: String
|
||||||
|
},
|
||||||
|
comment: {
|
||||||
|
type: String
|
||||||
|
},
|
||||||
|
createdAt: {
|
||||||
|
type: Date,
|
||||||
|
default: Date.now,
|
||||||
|
index: true
|
||||||
|
},
|
||||||
|
updatedAt: {
|
||||||
|
type: Date,
|
||||||
|
default: Date.now
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Индексы для оптимизации поиска
|
||||||
|
experienceSchema.index({ companyId: 1, createdAt: -1 });
|
||||||
|
|
||||||
|
module.exports = mongoose.model('Experience', experienceSchema);
|
||||||
|
|
||||||
37
server/routers/procurement/models/Message.js
Normal file
37
server/routers/procurement/models/Message.js
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
const mongoose = require('mongoose');
|
||||||
|
|
||||||
|
const messageSchema = new mongoose.Schema({
|
||||||
|
threadId: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
index: true
|
||||||
|
},
|
||||||
|
senderCompanyId: {
|
||||||
|
type: mongoose.Schema.Types.ObjectId,
|
||||||
|
ref: 'Company',
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
recipientCompanyId: {
|
||||||
|
type: mongoose.Schema.Types.ObjectId,
|
||||||
|
ref: 'Company',
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
text: {
|
||||||
|
type: String,
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
read: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
},
|
||||||
|
timestamp: {
|
||||||
|
type: Date,
|
||||||
|
default: Date.now,
|
||||||
|
index: true
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Индекс для быстрого поиска сообщений потока
|
||||||
|
messageSchema.index({ threadId: 1, timestamp: -1 });
|
||||||
|
|
||||||
|
module.exports = mongoose.model('Message', messageSchema);
|
||||||
57
server/routers/procurement/models/Product.js
Normal file
57
server/routers/procurement/models/Product.js
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
const mongoose = require('mongoose');
|
||||||
|
|
||||||
|
const productSchema = new mongoose.Schema({
|
||||||
|
name: {
|
||||||
|
type: String,
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
category: {
|
||||||
|
type: String,
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
description: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
minlength: 20,
|
||||||
|
maxlength: 500
|
||||||
|
},
|
||||||
|
type: {
|
||||||
|
type: String,
|
||||||
|
enum: ['sell', 'buy'],
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
productUrl: String,
|
||||||
|
companyId: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
index: true
|
||||||
|
},
|
||||||
|
price: String,
|
||||||
|
unit: String,
|
||||||
|
minOrder: String,
|
||||||
|
createdAt: {
|
||||||
|
type: Date,
|
||||||
|
default: Date.now,
|
||||||
|
index: true
|
||||||
|
},
|
||||||
|
updatedAt: {
|
||||||
|
type: Date,
|
||||||
|
default: Date.now
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Индекс для поиска
|
||||||
|
productSchema.index({ companyId: 1, type: 1 });
|
||||||
|
productSchema.index({ name: 'text', description: 'text' });
|
||||||
|
|
||||||
|
// Transform _id to id in JSON output
|
||||||
|
productSchema.set('toJSON', {
|
||||||
|
transform: (doc, ret) => {
|
||||||
|
ret.id = ret._id;
|
||||||
|
delete ret._id;
|
||||||
|
delete ret.__v;
|
||||||
|
return ret;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = mongoose.model('Product', productSchema);
|
||||||
82
server/routers/procurement/models/Request.js
Normal file
82
server/routers/procurement/models/Request.js
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
const mongoose = require('mongoose');
|
||||||
|
|
||||||
|
const requestSchema = new mongoose.Schema({
|
||||||
|
senderCompanyId: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
index: true
|
||||||
|
},
|
||||||
|
recipientCompanyId: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
index: true
|
||||||
|
},
|
||||||
|
subject: {
|
||||||
|
type: String,
|
||||||
|
required: false,
|
||||||
|
trim: true,
|
||||||
|
default: ''
|
||||||
|
},
|
||||||
|
text: {
|
||||||
|
type: String,
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
files: [{
|
||||||
|
id: { type: String },
|
||||||
|
name: { type: String },
|
||||||
|
url: { type: String },
|
||||||
|
type: { type: String },
|
||||||
|
size: { type: Number },
|
||||||
|
storagePath: { type: String },
|
||||||
|
uploadedAt: {
|
||||||
|
type: Date,
|
||||||
|
default: Date.now
|
||||||
|
}
|
||||||
|
}],
|
||||||
|
productId: {
|
||||||
|
type: String,
|
||||||
|
ref: 'BuyProduct'
|
||||||
|
},
|
||||||
|
status: {
|
||||||
|
type: String,
|
||||||
|
enum: ['pending', 'accepted', 'rejected'],
|
||||||
|
default: 'pending'
|
||||||
|
},
|
||||||
|
response: {
|
||||||
|
type: String,
|
||||||
|
default: null
|
||||||
|
},
|
||||||
|
responseFiles: [{
|
||||||
|
id: { type: String },
|
||||||
|
name: { type: String },
|
||||||
|
url: { type: String },
|
||||||
|
type: { type: String },
|
||||||
|
size: { type: Number },
|
||||||
|
storagePath: { type: String },
|
||||||
|
uploadedAt: {
|
||||||
|
type: Date,
|
||||||
|
default: Date.now
|
||||||
|
}
|
||||||
|
}],
|
||||||
|
respondedAt: {
|
||||||
|
type: Date,
|
||||||
|
default: null
|
||||||
|
},
|
||||||
|
createdAt: {
|
||||||
|
type: Date,
|
||||||
|
default: Date.now,
|
||||||
|
index: true
|
||||||
|
},
|
||||||
|
updatedAt: {
|
||||||
|
type: Date,
|
||||||
|
default: Date.now
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Индексы для оптимизации поиска
|
||||||
|
requestSchema.index({ senderCompanyId: 1, createdAt: -1 });
|
||||||
|
requestSchema.index({ recipientCompanyId: 1, createdAt: -1 });
|
||||||
|
requestSchema.index({ senderCompanyId: 1, recipientCompanyId: 1 });
|
||||||
|
requestSchema.index({ subject: 1, createdAt: -1 });
|
||||||
|
|
||||||
|
module.exports = mongoose.model('Request', requestSchema);
|
||||||
58
server/routers/procurement/models/Review.js
Normal file
58
server/routers/procurement/models/Review.js
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
const mongoose = require('mongoose');
|
||||||
|
|
||||||
|
const reviewSchema = new mongoose.Schema({
|
||||||
|
companyId: {
|
||||||
|
type: mongoose.Schema.Types.ObjectId,
|
||||||
|
ref: 'Company',
|
||||||
|
required: true,
|
||||||
|
index: true
|
||||||
|
},
|
||||||
|
authorCompanyId: {
|
||||||
|
type: mongoose.Schema.Types.ObjectId,
|
||||||
|
ref: 'Company',
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
authorName: {
|
||||||
|
type: String,
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
authorCompany: {
|
||||||
|
type: String,
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
rating: {
|
||||||
|
type: Number,
|
||||||
|
required: true,
|
||||||
|
min: 1,
|
||||||
|
max: 5
|
||||||
|
},
|
||||||
|
comment: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
minlength: 10,
|
||||||
|
maxlength: 1000
|
||||||
|
},
|
||||||
|
date: {
|
||||||
|
type: Date,
|
||||||
|
default: Date.now
|
||||||
|
},
|
||||||
|
verified: {
|
||||||
|
type: Boolean,
|
||||||
|
default: true
|
||||||
|
},
|
||||||
|
createdAt: {
|
||||||
|
type: Date,
|
||||||
|
default: Date.now,
|
||||||
|
index: true
|
||||||
|
},
|
||||||
|
updatedAt: {
|
||||||
|
type: Date,
|
||||||
|
default: Date.now
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Индексы для оптимизации поиска
|
||||||
|
reviewSchema.index({ companyId: 1, createdAt: -1 });
|
||||||
|
reviewSchema.index({ authorCompanyId: 1 });
|
||||||
|
|
||||||
|
module.exports = mongoose.model('Review', reviewSchema);
|
||||||
73
server/routers/procurement/models/User.js
Normal file
73
server/routers/procurement/models/User.js
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
const mongoose = require('mongoose');
|
||||||
|
const bcrypt = require('bcryptjs');
|
||||||
|
|
||||||
|
const userSchema = new mongoose.Schema({
|
||||||
|
email: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
unique: true,
|
||||||
|
lowercase: true,
|
||||||
|
trim: true,
|
||||||
|
match: /^[^\s@]+@[^\s@]+\.[^\s@]+$/
|
||||||
|
},
|
||||||
|
password: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
minlength: 8
|
||||||
|
},
|
||||||
|
firstName: {
|
||||||
|
type: String,
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
lastName: {
|
||||||
|
type: String,
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
position: String,
|
||||||
|
phone: String,
|
||||||
|
companyId: {
|
||||||
|
type: mongoose.Schema.Types.ObjectId,
|
||||||
|
ref: 'Company'
|
||||||
|
},
|
||||||
|
createdAt: {
|
||||||
|
type: Date,
|
||||||
|
default: Date.now
|
||||||
|
},
|
||||||
|
updatedAt: {
|
||||||
|
type: Date,
|
||||||
|
default: Date.now
|
||||||
|
}
|
||||||
|
}, {
|
||||||
|
collection: 'users',
|
||||||
|
minimize: false,
|
||||||
|
toObject: { versionKey: false }
|
||||||
|
});
|
||||||
|
|
||||||
|
userSchema.set('toObject', { virtuals: false, versionKey: false });
|
||||||
|
|
||||||
|
// Хешировать пароль перед сохранением
|
||||||
|
userSchema.pre('save', async function(next) {
|
||||||
|
if (!this.isModified('password')) return next();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const salt = await bcrypt.genSalt(10);
|
||||||
|
this.password = await bcrypt.hash(this.password, salt);
|
||||||
|
next();
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Метод для сравнения паролей
|
||||||
|
userSchema.methods.comparePassword = async function(candidatePassword) {
|
||||||
|
return await bcrypt.compare(candidatePassword, this.password);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Скрыть пароль при преобразовании в JSON
|
||||||
|
userSchema.methods.toJSON = function() {
|
||||||
|
const obj = this.toObject();
|
||||||
|
delete obj.password;
|
||||||
|
return obj;
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = mongoose.model('User', userSchema);
|
||||||
239
server/routers/procurement/routes/__tests__/buyProducts.test.js
Normal file
239
server/routers/procurement/routes/__tests__/buyProducts.test.js
Normal file
@@ -0,0 +1,239 @@
|
|||||||
|
const express = require('express')
|
||||||
|
const mongoose = require('mongoose')
|
||||||
|
const request = require('supertest')
|
||||||
|
|
||||||
|
// Mock auth middleware
|
||||||
|
const mockAuthMiddleware = (req, res, next) => {
|
||||||
|
req.user = {
|
||||||
|
companyId: 'test-company-id',
|
||||||
|
id: 'test-user-id',
|
||||||
|
}
|
||||||
|
next()
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Buy Products Routes', () => {
|
||||||
|
let app
|
||||||
|
let router
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
app = express()
|
||||||
|
app.use(express.json())
|
||||||
|
|
||||||
|
// Create a test router with mock middleware
|
||||||
|
router = express.Router()
|
||||||
|
|
||||||
|
// Mock endpoints for testing structure
|
||||||
|
router.get('/company/:companyId', mockAuthMiddleware, (req, res) => {
|
||||||
|
res.json([])
|
||||||
|
})
|
||||||
|
|
||||||
|
router.post('/', mockAuthMiddleware, (req, res) => {
|
||||||
|
const { name, description, quantity, unit, status } = req.body
|
||||||
|
|
||||||
|
if (!name || !description || !quantity) {
|
||||||
|
return res.status(400).json({
|
||||||
|
error: 'name, description, and quantity are required',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (description.trim().length < 10) {
|
||||||
|
return res.status(400).json({
|
||||||
|
error: 'Description must be at least 10 characters',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const product = {
|
||||||
|
_id: 'product-' + Date.now(),
|
||||||
|
companyId: req.user.companyId,
|
||||||
|
name: name.trim(),
|
||||||
|
description: description.trim(),
|
||||||
|
quantity: quantity.trim(),
|
||||||
|
unit: unit || 'шт',
|
||||||
|
status: status || 'published',
|
||||||
|
files: [],
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
}
|
||||||
|
|
||||||
|
res.status(201).json(product)
|
||||||
|
})
|
||||||
|
|
||||||
|
app.use('/buy-products', router)
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('GET /buy-products/company/:companyId', () => {
|
||||||
|
it('should return products list for a company', async () => {
|
||||||
|
const res = await request(app)
|
||||||
|
.get('/buy-products/company/test-company-id')
|
||||||
|
.expect(200)
|
||||||
|
|
||||||
|
expect(Array.isArray(res.body)).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should require authentication', async () => {
|
||||||
|
// This test would fail without proper auth middleware
|
||||||
|
const res = await request(app)
|
||||||
|
.get('/buy-products/company/test-company-id')
|
||||||
|
|
||||||
|
expect(res.status).toBeLessThan(500)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('POST /buy-products', () => {
|
||||||
|
it('should create a new product with valid data', async () => {
|
||||||
|
const productData = {
|
||||||
|
name: 'Test Product',
|
||||||
|
description: 'This is a test product description',
|
||||||
|
quantity: '10',
|
||||||
|
unit: 'шт',
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/buy-products')
|
||||||
|
.send(productData)
|
||||||
|
.expect(201)
|
||||||
|
|
||||||
|
expect(res.body).toHaveProperty('_id')
|
||||||
|
expect(res.body.name).toBe('Test Product')
|
||||||
|
expect(res.body.description).toBe(productData.description)
|
||||||
|
expect(res.body.status).toBe('published')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should reject product without name', async () => {
|
||||||
|
const productData = {
|
||||||
|
description: 'This is a test product description',
|
||||||
|
quantity: '10',
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/buy-products')
|
||||||
|
.send(productData)
|
||||||
|
.expect(400)
|
||||||
|
|
||||||
|
expect(res.body.error).toContain('required')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should reject product without description', async () => {
|
||||||
|
const productData = {
|
||||||
|
name: 'Test Product',
|
||||||
|
quantity: '10',
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/buy-products')
|
||||||
|
.send(productData)
|
||||||
|
.expect(400)
|
||||||
|
|
||||||
|
expect(res.body.error).toContain('required')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should reject product without quantity', async () => {
|
||||||
|
const productData = {
|
||||||
|
name: 'Test Product',
|
||||||
|
description: 'This is a test product description',
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/buy-products')
|
||||||
|
.send(productData)
|
||||||
|
.expect(400)
|
||||||
|
|
||||||
|
expect(res.body.error).toContain('required')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should reject product with description less than 10 characters', async () => {
|
||||||
|
const productData = {
|
||||||
|
name: 'Test Product',
|
||||||
|
description: 'short',
|
||||||
|
quantity: '10',
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/buy-products')
|
||||||
|
.send(productData)
|
||||||
|
.expect(400)
|
||||||
|
|
||||||
|
expect(res.body.error).toContain('10 characters')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should set default unit to "шт" if not provided', async () => {
|
||||||
|
const productData = {
|
||||||
|
name: 'Test Product',
|
||||||
|
description: 'This is a test product description',
|
||||||
|
quantity: '10',
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/buy-products')
|
||||||
|
.send(productData)
|
||||||
|
.expect(201)
|
||||||
|
|
||||||
|
expect(res.body.unit).toBe('шт')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should use provided unit', async () => {
|
||||||
|
const productData = {
|
||||||
|
name: 'Test Product',
|
||||||
|
description: 'This is a test product description',
|
||||||
|
quantity: '10',
|
||||||
|
unit: 'кг',
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/buy-products')
|
||||||
|
.send(productData)
|
||||||
|
.expect(201)
|
||||||
|
|
||||||
|
expect(res.body.unit).toBe('кг')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should set status to "published" by default', async () => {
|
||||||
|
const productData = {
|
||||||
|
name: 'Test Product',
|
||||||
|
description: 'This is a test product description',
|
||||||
|
quantity: '10',
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/buy-products')
|
||||||
|
.send(productData)
|
||||||
|
.expect(201)
|
||||||
|
|
||||||
|
expect(res.body.status).toBe('published')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Data validation', () => {
|
||||||
|
it('should trim whitespace from product data', async () => {
|
||||||
|
const productData = {
|
||||||
|
name: ' Test Product ',
|
||||||
|
description: ' This is a test product description ',
|
||||||
|
quantity: ' 10 ',
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/buy-products')
|
||||||
|
.send(productData)
|
||||||
|
.expect(201)
|
||||||
|
|
||||||
|
expect(res.body.name).toBe('Test Product')
|
||||||
|
expect(res.body.description).toBe('This is a test product description')
|
||||||
|
expect(res.body.quantity).toBe('10')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should include companyId from auth token', async () => {
|
||||||
|
const productData = {
|
||||||
|
name: 'Test Product',
|
||||||
|
description: 'This is a test product description',
|
||||||
|
quantity: '10',
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/buy-products')
|
||||||
|
.send(productData)
|
||||||
|
.expect(201)
|
||||||
|
|
||||||
|
expect(res.body.companyId).toBe('test-company-id')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
101
server/routers/procurement/routes/activity.js
Normal file
101
server/routers/procurement/routes/activity.js
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const router = express.Router();
|
||||||
|
const { verifyToken } = require('../middleware/auth');
|
||||||
|
const Activity = require('../models/Activity');
|
||||||
|
const User = require('../models/User');
|
||||||
|
|
||||||
|
// Получить последние активности компании
|
||||||
|
router.get('/', verifyToken, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const userId = req.userId;
|
||||||
|
const user = await User.findById(userId);
|
||||||
|
|
||||||
|
if (!user || !user.companyId) {
|
||||||
|
return res.json({ activities: [] });
|
||||||
|
}
|
||||||
|
|
||||||
|
const companyId = user.companyId.toString();
|
||||||
|
const limit = parseInt(req.query.limit) || 10;
|
||||||
|
|
||||||
|
const activities = await Activity.find({ companyId })
|
||||||
|
.sort({ createdAt: -1 })
|
||||||
|
.limit(limit)
|
||||||
|
.lean();
|
||||||
|
|
||||||
|
res.json({ activities });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error getting activities:', error);
|
||||||
|
res.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Отметить активность как прочитанную
|
||||||
|
router.patch('/:id/read', verifyToken, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const userId = req.userId;
|
||||||
|
const user = await User.findById(userId);
|
||||||
|
|
||||||
|
if (!user || !user.companyId) {
|
||||||
|
return res.status(403).json({ error: 'Access denied' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const companyId = user.companyId.toString();
|
||||||
|
const activityId = req.params.id;
|
||||||
|
|
||||||
|
const activity = await Activity.findOne({
|
||||||
|
_id: activityId,
|
||||||
|
companyId
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!activity) {
|
||||||
|
return res.status(404).json({ error: 'Activity not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
activity.read = true;
|
||||||
|
await activity.save();
|
||||||
|
|
||||||
|
res.json({ success: true, activity });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error updating activity:', error);
|
||||||
|
res.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Отметить все активности как прочитанные
|
||||||
|
router.post('/mark-all-read', verifyToken, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const userId = req.userId;
|
||||||
|
const user = await User.findById(userId);
|
||||||
|
|
||||||
|
if (!user || !user.companyId) {
|
||||||
|
return res.status(403).json({ error: 'Access denied' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const companyId = user.companyId.toString();
|
||||||
|
|
||||||
|
await Activity.updateMany(
|
||||||
|
{ companyId, read: false },
|
||||||
|
{ $set: { read: true } }
|
||||||
|
);
|
||||||
|
|
||||||
|
res.json({ success: true });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error marking all as read:', error);
|
||||||
|
res.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Создать активность (вспомогательная функция)
|
||||||
|
router.createActivity = async (data) => {
|
||||||
|
try {
|
||||||
|
const activity = new Activity(data);
|
||||||
|
await activity.save();
|
||||||
|
return activity;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error creating activity:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
|
|
||||||
515
server/routers/procurement/routes/auth.js
Normal file
515
server/routers/procurement/routes/auth.js
Normal file
@@ -0,0 +1,515 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const router = express.Router();
|
||||||
|
const { generateToken, verifyToken } = require('../middleware/auth');
|
||||||
|
const User = require('../models/User');
|
||||||
|
const Company = require('../models/Company');
|
||||||
|
const Request = require('../models/Request');
|
||||||
|
const BuyProduct = require('../models/BuyProduct');
|
||||||
|
const Message = require('../models/Message');
|
||||||
|
const Review = require('../models/Review');
|
||||||
|
const mongoose = require('../../../utils/mongoose');
|
||||||
|
const { Types } = mongoose;
|
||||||
|
|
||||||
|
const PRESET_COMPANY_ID = new Types.ObjectId('68fe2ccda3526c303ca06796');
|
||||||
|
const PRESET_USER_EMAIL = 'admin@test-company.ru';
|
||||||
|
|
||||||
|
const changePasswordFlow = async (userId, currentPassword, newPassword) => {
|
||||||
|
if (!currentPassword || !newPassword) {
|
||||||
|
return { status: 400, body: { error: 'Current password and new password are required' } };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof newPassword !== 'string' || newPassword.trim().length < 8) {
|
||||||
|
return { status: 400, body: { error: 'New password must be at least 8 characters long' } };
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await User.findById(userId);
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return { status: 404, body: { error: 'User not found' } };
|
||||||
|
}
|
||||||
|
|
||||||
|
const isMatch = await user.comparePassword(currentPassword);
|
||||||
|
|
||||||
|
if (!isMatch) {
|
||||||
|
return { status: 400, body: { error: 'Current password is incorrect' } };
|
||||||
|
}
|
||||||
|
|
||||||
|
user.password = newPassword;
|
||||||
|
user.updatedAt = new Date();
|
||||||
|
await user.save();
|
||||||
|
|
||||||
|
return { status: 200, body: { message: 'Password updated successfully' } };
|
||||||
|
};
|
||||||
|
|
||||||
|
const deleteAccountFlow = async (userId, password) => {
|
||||||
|
if (!password) {
|
||||||
|
return { status: 400, body: { error: 'Password is required to delete account' } };
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await User.findById(userId);
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return { status: 404, body: { error: 'User not found' } };
|
||||||
|
}
|
||||||
|
|
||||||
|
const validPassword = await user.comparePassword(password);
|
||||||
|
|
||||||
|
if (!validPassword) {
|
||||||
|
return { status: 400, body: { error: 'Password is incorrect' } };
|
||||||
|
}
|
||||||
|
|
||||||
|
const companyId = user.companyId ? user.companyId.toString() : null;
|
||||||
|
const companyObjectId = companyId && Types.ObjectId.isValid(companyId) ? new Types.ObjectId(companyId) : null;
|
||||||
|
|
||||||
|
const cleanupTasks = [];
|
||||||
|
|
||||||
|
if (companyId) {
|
||||||
|
cleanupTasks.push(Request.deleteMany({
|
||||||
|
$or: [{ senderCompanyId: companyId }, { recipientCompanyId: companyId }],
|
||||||
|
}));
|
||||||
|
|
||||||
|
cleanupTasks.push(BuyProduct.deleteMany({ companyId }));
|
||||||
|
|
||||||
|
if (companyObjectId) {
|
||||||
|
cleanupTasks.push(Message.deleteMany({
|
||||||
|
$or: [
|
||||||
|
{ senderCompanyId: companyObjectId },
|
||||||
|
{ recipientCompanyId: companyObjectId },
|
||||||
|
],
|
||||||
|
}));
|
||||||
|
|
||||||
|
cleanupTasks.push(Review.deleteMany({
|
||||||
|
$or: [
|
||||||
|
{ companyId: companyObjectId },
|
||||||
|
{ authorCompanyId: companyObjectId },
|
||||||
|
],
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
cleanupTasks.push(Company.findByIdAndDelete(companyId));
|
||||||
|
}
|
||||||
|
|
||||||
|
cleanupTasks.push(User.findByIdAndDelete(user._id));
|
||||||
|
|
||||||
|
await Promise.all(cleanupTasks);
|
||||||
|
|
||||||
|
return { status: 200, body: { message: 'Account deleted successfully' } };
|
||||||
|
};
|
||||||
|
|
||||||
|
// Функция для логирования с проверкой DEV переменной
|
||||||
|
const log = (message, data = '') => {
|
||||||
|
if (process.env.DEV === 'true') {
|
||||||
|
if (data) {
|
||||||
|
console.log(message, data);
|
||||||
|
} else {
|
||||||
|
console.log(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const waitForDatabaseConnection = async () => {
|
||||||
|
const isAuthFailure = (error) => {
|
||||||
|
if (!error) return false;
|
||||||
|
if (error.code === 13 || error.code === 18) return true;
|
||||||
|
return /auth/i.test(String(error.message || ''));
|
||||||
|
};
|
||||||
|
|
||||||
|
const verifyAuth = async () => {
|
||||||
|
try {
|
||||||
|
await mongoose.connection.db.admin().command({ listDatabases: 1 });
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
if (isAuthFailure(error)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
for (let attempt = 0; attempt < 3; attempt++) {
|
||||||
|
if (mongoose.connection.readyState === 1) {
|
||||||
|
const authed = await verifyAuth();
|
||||||
|
if (authed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await mongoose.connection.close().catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const connection = await connectDB();
|
||||||
|
if (!connection) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
const authed = await verifyAuth();
|
||||||
|
if (authed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await mongoose.connection.close().catch(() => {});
|
||||||
|
} catch (error) {
|
||||||
|
if (!isAuthFailure(error)) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error('Unable to authenticate with MongoDB');
|
||||||
|
};
|
||||||
|
|
||||||
|
// Инициализация тестового пользователя
|
||||||
|
const initializeTestUser = async () => {
|
||||||
|
try {
|
||||||
|
await waitForDatabaseConnection();
|
||||||
|
|
||||||
|
let company = await Company.findById(PRESET_COMPANY_ID);
|
||||||
|
if (!company) {
|
||||||
|
company = await Company.create({
|
||||||
|
_id: PRESET_COMPANY_ID,
|
||||||
|
fullName: 'ООО "Тестовая Компания"',
|
||||||
|
shortName: 'ООО "Тест"',
|
||||||
|
inn: '7707083893',
|
||||||
|
ogrn: '1027700132195',
|
||||||
|
legalForm: 'ООО',
|
||||||
|
industry: 'Производство',
|
||||||
|
companySize: '50-100',
|
||||||
|
partnerGeography: ['moscow', 'russia_all'],
|
||||||
|
website: 'https://test-company.ru',
|
||||||
|
verified: true,
|
||||||
|
rating: 4.5,
|
||||||
|
description: 'Ведущая компания в области производства',
|
||||||
|
slogan: 'Качество и инновация'
|
||||||
|
});
|
||||||
|
log('✅ Test company initialized');
|
||||||
|
} else {
|
||||||
|
await Company.updateOne(
|
||||||
|
{ _id: PRESET_COMPANY_ID },
|
||||||
|
{
|
||||||
|
$set: {
|
||||||
|
fullName: 'ООО "Тестовая Компания"',
|
||||||
|
shortName: 'ООО "Тест"',
|
||||||
|
industry: 'Производство',
|
||||||
|
companySize: '50-100',
|
||||||
|
partnerGeography: ['moscow', 'russia_all'],
|
||||||
|
website: 'https://test-company.ru',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let existingUser = await User.findOne({ email: PRESET_USER_EMAIL });
|
||||||
|
if (!existingUser) {
|
||||||
|
existingUser = await User.create({
|
||||||
|
email: PRESET_USER_EMAIL,
|
||||||
|
password: 'SecurePass123!',
|
||||||
|
firstName: 'Иван',
|
||||||
|
lastName: 'Петров',
|
||||||
|
position: 'Генеральный директор',
|
||||||
|
companyId: PRESET_COMPANY_ID
|
||||||
|
});
|
||||||
|
log('✅ Test user initialized');
|
||||||
|
} else if (!existingUser.companyId || existingUser.companyId.toString() !== PRESET_COMPANY_ID.toString()) {
|
||||||
|
existingUser.companyId = PRESET_COMPANY_ID;
|
||||||
|
existingUser.updatedAt = new Date();
|
||||||
|
await existingUser.save();
|
||||||
|
log('ℹ️ Test user company reference was fixed');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error initializing test data:', error.message);
|
||||||
|
if (error?.code === 13 || /auth/i.test(error?.message || '')) {
|
||||||
|
try {
|
||||||
|
await connectDB();
|
||||||
|
} catch (connectError) {
|
||||||
|
if (process.env.DEV === 'true') {
|
||||||
|
console.error('Failed to re-connect after auth error:', connectError.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
initializeTestUser();
|
||||||
|
|
||||||
|
// Регистрация
|
||||||
|
router.post('/register', async (req, res) => {
|
||||||
|
try {
|
||||||
|
await waitForDatabaseConnection();
|
||||||
|
|
||||||
|
const { email, password, firstName, lastName, position, phone, fullName, inn, ogrn, legalForm, industry, companySize, website } = req.body;
|
||||||
|
|
||||||
|
// Проверка обязательных полей
|
||||||
|
if (!email || !password || !firstName || !lastName || !fullName) {
|
||||||
|
return res.status(400).json({ error: 'Missing required fields' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Проверка существования пользователя
|
||||||
|
const existingUser = await User.findOne({ email });
|
||||||
|
if (existingUser) {
|
||||||
|
return res.status(409).json({ error: 'User already exists' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Создать компанию
|
||||||
|
let company;
|
||||||
|
try {
|
||||||
|
company = new Company({
|
||||||
|
fullName,
|
||||||
|
shortName: fullName.substring(0, 20),
|
||||||
|
inn,
|
||||||
|
ogrn,
|
||||||
|
legalForm,
|
||||||
|
industry,
|
||||||
|
companySize,
|
||||||
|
website,
|
||||||
|
verified: false,
|
||||||
|
rating: 0,
|
||||||
|
description: '',
|
||||||
|
slogan: '',
|
||||||
|
partnerGeography: ['moscow', 'russia_all']
|
||||||
|
});
|
||||||
|
const savedCompany = await company.save();
|
||||||
|
company = savedCompany;
|
||||||
|
log('✅ Company saved:', company._id, 'Result:', savedCompany ? 'Success' : 'Failed');
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Company save error:', err);
|
||||||
|
return res.status(400).json({ error: 'Failed to create company: ' + err.message });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Создать пользователя
|
||||||
|
try {
|
||||||
|
const newUser = await User.create({
|
||||||
|
email,
|
||||||
|
password,
|
||||||
|
firstName,
|
||||||
|
lastName,
|
||||||
|
position: position || '',
|
||||||
|
phone: phone || '',
|
||||||
|
companyId: company._id
|
||||||
|
});
|
||||||
|
|
||||||
|
log('✅ User created:', newUser._id);
|
||||||
|
|
||||||
|
const token = generateToken(newUser._id.toString(), newUser.companyId.toString(), newUser.firstName, newUser.lastName, company.fullName);
|
||||||
|
return res.status(201).json({
|
||||||
|
tokens: {
|
||||||
|
accessToken: token,
|
||||||
|
refreshToken: token
|
||||||
|
},
|
||||||
|
user: {
|
||||||
|
id: newUser._id.toString(),
|
||||||
|
email: newUser.email,
|
||||||
|
firstName: newUser.firstName,
|
||||||
|
lastName: newUser.lastName,
|
||||||
|
position: newUser.position,
|
||||||
|
companyId: newUser.companyId.toString()
|
||||||
|
},
|
||||||
|
company: {
|
||||||
|
id: company._id.toString(),
|
||||||
|
name: company.fullName,
|
||||||
|
inn: company.inn
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error('User creation error:', err);
|
||||||
|
return res.status(400).json({ error: 'Failed to create user: ' + err.message });
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Registration error:', error);
|
||||||
|
res.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Вход
|
||||||
|
router.post('/login', async (req, res) => {
|
||||||
|
try {
|
||||||
|
if (process.env.DEV === 'true') {
|
||||||
|
console.log('[Auth] /login called');
|
||||||
|
}
|
||||||
|
await waitForDatabaseConnection();
|
||||||
|
if (process.env.DEV === 'true') {
|
||||||
|
console.log('[Auth] DB ready, running login query');
|
||||||
|
}
|
||||||
|
|
||||||
|
const { email, password } = req.body;
|
||||||
|
|
||||||
|
if (!email || !password) {
|
||||||
|
return res.status(400).json({ error: 'Email and password required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await User.findOne({ email });
|
||||||
|
if (!user) {
|
||||||
|
return res.status(401).json({ error: 'Invalid credentials' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const isMatch = await user.comparePassword(password);
|
||||||
|
if (!isMatch) {
|
||||||
|
return res.status(401).json({ error: 'Invalid credentials' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
user.email === PRESET_USER_EMAIL &&
|
||||||
|
(!user.companyId || user.companyId.toString() !== PRESET_COMPANY_ID.toString())
|
||||||
|
) {
|
||||||
|
await User.updateOne(
|
||||||
|
{ _id: user._id },
|
||||||
|
{ $set: { companyId: PRESET_COMPANY_ID, updatedAt: new Date() } }
|
||||||
|
);
|
||||||
|
user.companyId = PRESET_COMPANY_ID;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Получить компанию до использования в generateToken
|
||||||
|
let companyData = null;
|
||||||
|
try {
|
||||||
|
companyData = user.companyId ? await Company.findById(user.companyId) : null;
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to fetch company:', err.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (user.email === PRESET_USER_EMAIL) {
|
||||||
|
try {
|
||||||
|
companyData = await Company.findByIdAndUpdate(
|
||||||
|
PRESET_COMPANY_ID,
|
||||||
|
{
|
||||||
|
$set: {
|
||||||
|
fullName: 'ООО "Тестовая Компания"',
|
||||||
|
shortName: 'ООО "Тест"',
|
||||||
|
inn: '7707083893',
|
||||||
|
ogrn: '1027700132195',
|
||||||
|
legalForm: 'ООО',
|
||||||
|
industry: 'Производство',
|
||||||
|
companySize: '50-100',
|
||||||
|
partnerGeography: ['moscow', 'russia_all'],
|
||||||
|
website: 'https://test-company.ru',
|
||||||
|
verified: true,
|
||||||
|
rating: 4.5,
|
||||||
|
description: 'Ведущая компания в области производства',
|
||||||
|
slogan: 'Качество и инновация',
|
||||||
|
updatedAt: new Date(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ upsert: true, new: true, setDefaultsOnInsert: true }
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to ensure preset company:', err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = generateToken(user._id.toString(), user.companyId.toString(), user.firstName, user.lastName, companyData?.fullName || 'Company');
|
||||||
|
log('✅ Token generated for user:', user._id);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
tokens: {
|
||||||
|
accessToken: token,
|
||||||
|
refreshToken: token
|
||||||
|
},
|
||||||
|
user: {
|
||||||
|
id: user._id.toString(),
|
||||||
|
email: user.email,
|
||||||
|
firstName: user.firstName,
|
||||||
|
lastName: user.lastName,
|
||||||
|
position: user.position,
|
||||||
|
companyId: user.companyId.toString()
|
||||||
|
},
|
||||||
|
company: companyData ? {
|
||||||
|
id: companyData._id.toString(),
|
||||||
|
name: companyData.fullName,
|
||||||
|
inn: companyData.inn
|
||||||
|
} : null
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Login error:', error);
|
||||||
|
res.status(500).json({ error: `LOGIN_ERROR: ${error.message}` });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Смена пароля
|
||||||
|
router.post('/change-password', verifyToken, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { currentPassword, newPassword } = req.body || {};
|
||||||
|
const result = await changePasswordFlow(req.userId, currentPassword, newPassword);
|
||||||
|
res.status(result.status).json(result.body);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Change password error:', error);
|
||||||
|
res.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Удаление аккаунта
|
||||||
|
router.delete('/account', verifyToken, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { password } = req.body || {};
|
||||||
|
const result = await deleteAccountFlow(req.userId, password);
|
||||||
|
res.status(result.status).json(result.body);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Delete account error:', error);
|
||||||
|
res.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Обновить профиль / универсальные действия
|
||||||
|
router.patch('/profile', verifyToken, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const rawAction = req.body?.action || req.query?.action || req.body?.type;
|
||||||
|
const payload = req.body?.payload || req.body || {};
|
||||||
|
const action = typeof rawAction === 'string' ? rawAction : '';
|
||||||
|
|
||||||
|
if (action === 'changePassword') {
|
||||||
|
const result = await changePasswordFlow(req.userId, payload.currentPassword, payload.newPassword);
|
||||||
|
return res.status(result.status).json(result.body);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (action === 'deleteAccount') {
|
||||||
|
const result = await deleteAccountFlow(req.userId, payload.password);
|
||||||
|
return res.status(result.status).json(result.body);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (action === 'updateProfile') {
|
||||||
|
await waitForDatabaseConnection();
|
||||||
|
|
||||||
|
const { firstName, lastName, position, phone } = payload;
|
||||||
|
|
||||||
|
if (!firstName && !lastName && !position && !phone) {
|
||||||
|
return res.status(400).json({ error: 'At least one field must be provided' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await User.findById(req.userId);
|
||||||
|
if (!user) {
|
||||||
|
return res.status(404).json({ error: 'User not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (firstName) user.firstName = firstName;
|
||||||
|
if (lastName) user.lastName = lastName;
|
||||||
|
if (position !== undefined) user.position = position;
|
||||||
|
if (phone !== undefined) user.phone = phone;
|
||||||
|
user.updatedAt = new Date();
|
||||||
|
|
||||||
|
await user.save();
|
||||||
|
|
||||||
|
const company = user.companyId ? await Company.findById(user.companyId) : null;
|
||||||
|
|
||||||
|
return res.json({
|
||||||
|
message: 'Profile updated successfully',
|
||||||
|
user: {
|
||||||
|
id: user._id.toString(),
|
||||||
|
email: user.email,
|
||||||
|
firstName: user.firstName,
|
||||||
|
lastName: user.lastName,
|
||||||
|
position: user.position,
|
||||||
|
phone: user.phone,
|
||||||
|
companyId: user.companyId?.toString()
|
||||||
|
},
|
||||||
|
company: company ? {
|
||||||
|
id: company._id.toString(),
|
||||||
|
name: company.fullName,
|
||||||
|
inn: company.inn
|
||||||
|
} : null
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ message: 'Profile endpoint' });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Profile update error:', error);
|
||||||
|
res.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
220
server/routers/procurement/routes/buy.js
Normal file
220
server/routers/procurement/routes/buy.js
Normal file
@@ -0,0 +1,220 @@
|
|||||||
|
const express = require('express')
|
||||||
|
const fs = require('fs')
|
||||||
|
const path = require('path')
|
||||||
|
const router = express.Router()
|
||||||
|
const BuyDocument = require('../models/BuyDocument')
|
||||||
|
|
||||||
|
// Create remote-assets/docs directory if it doesn't exist
|
||||||
|
const docsDir = 'server/routers/remote-assets/docs'
|
||||||
|
if (!fs.existsSync(docsDir)) {
|
||||||
|
fs.mkdirSync(docsDir, { recursive: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateId() {
|
||||||
|
return `${Date.now().toString(36)}${Math.random().toString(36).slice(2, 8)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /buy/docs?ownerCompanyId=...
|
||||||
|
router.get('/docs', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { ownerCompanyId } = req.query
|
||||||
|
console.log('[BUY API] GET /docs', { ownerCompanyId })
|
||||||
|
|
||||||
|
let query = {}
|
||||||
|
if (ownerCompanyId) {
|
||||||
|
query.ownerCompanyId = ownerCompanyId
|
||||||
|
}
|
||||||
|
|
||||||
|
const docs = await BuyDocument.find(query).sort({ createdAt: -1 })
|
||||||
|
|
||||||
|
const result = docs.map(doc => ({
|
||||||
|
...doc.toObject(),
|
||||||
|
url: `/api/buy/docs/${doc.id}/file`
|
||||||
|
}))
|
||||||
|
|
||||||
|
res.json(result)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[BUY API] Error fetching docs:', error)
|
||||||
|
res.status(500).json({ error: 'Failed to fetch documents' })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// POST /buy/docs
|
||||||
|
router.post('/docs', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { ownerCompanyId, name, type, fileData } = req.body || {}
|
||||||
|
console.log('[BUY API] POST /docs', { ownerCompanyId, name, type })
|
||||||
|
|
||||||
|
if (!ownerCompanyId || !name || !type) {
|
||||||
|
return res.status(400).json({ error: 'ownerCompanyId, name and type are required' })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!fileData) {
|
||||||
|
return res.status(400).json({ error: 'fileData is required' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const id = generateId()
|
||||||
|
|
||||||
|
// Save file to disk
|
||||||
|
const binaryData = Buffer.from(fileData, 'base64')
|
||||||
|
const filePath = `${docsDir}/${id}.${type}`
|
||||||
|
fs.writeFileSync(filePath, binaryData)
|
||||||
|
console.log(`[BUY API] File saved to ${filePath}, size: ${binaryData.length} bytes`)
|
||||||
|
|
||||||
|
const size = binaryData.length
|
||||||
|
|
||||||
|
const doc = await BuyDocument.create({
|
||||||
|
id,
|
||||||
|
ownerCompanyId,
|
||||||
|
name,
|
||||||
|
type,
|
||||||
|
size,
|
||||||
|
filePath,
|
||||||
|
acceptedBy: []
|
||||||
|
})
|
||||||
|
|
||||||
|
console.log('[BUY API] Document created:', id)
|
||||||
|
|
||||||
|
res.status(201).json({
|
||||||
|
...doc.toObject(),
|
||||||
|
url: `/api/buy/docs/${doc.id}/file`
|
||||||
|
})
|
||||||
|
} catch (e) {
|
||||||
|
console.error(`[BUY API] Error saving file: ${e.message}`)
|
||||||
|
res.status(500).json({ error: 'Failed to save file' })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
router.post('/docs/:id/accept', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { id } = req.params
|
||||||
|
const { companyId } = req.body || {}
|
||||||
|
console.log('[BUY API] POST /docs/:id/accept', { id, companyId })
|
||||||
|
|
||||||
|
if (!companyId) {
|
||||||
|
return res.status(400).json({ error: 'companyId is required' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const doc = await BuyDocument.findOne({ id })
|
||||||
|
if (!doc) {
|
||||||
|
console.log('[BUY API] Document not found:', id)
|
||||||
|
return res.status(404).json({ error: 'Document not found' })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!doc.acceptedBy.includes(companyId)) {
|
||||||
|
doc.acceptedBy.push(companyId)
|
||||||
|
await doc.save()
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ id: doc.id, acceptedBy: doc.acceptedBy })
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[BUY API] Error accepting document:', error)
|
||||||
|
res.status(500).json({ error: 'Failed to accept document' })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
router.get('/docs/:id/delete', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { id } = req.params
|
||||||
|
console.log('[BUY API] GET /docs/:id/delete', { id })
|
||||||
|
|
||||||
|
const doc = await BuyDocument.findOne({ id })
|
||||||
|
if (!doc) {
|
||||||
|
console.log('[BUY API] Document not found for deletion:', id)
|
||||||
|
return res.status(404).json({ error: 'Document not found' })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete file from disk
|
||||||
|
if (doc.filePath && fs.existsSync(doc.filePath)) {
|
||||||
|
try {
|
||||||
|
fs.unlinkSync(doc.filePath)
|
||||||
|
console.log(`[BUY API] File deleted: ${doc.filePath}`)
|
||||||
|
} catch (e) {
|
||||||
|
console.error(`[BUY API] Error deleting file: ${e.message}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await BuyDocument.deleteOne({ id })
|
||||||
|
|
||||||
|
console.log('[BUY API] Document deleted via GET:', id)
|
||||||
|
res.json({ id: doc.id, success: true })
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[BUY API] Error deleting document:', error)
|
||||||
|
res.status(500).json({ error: 'Failed to delete document' })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
router.delete('/docs/:id', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { id } = req.params
|
||||||
|
console.log('[BUY API] DELETE /docs/:id', { id })
|
||||||
|
|
||||||
|
const doc = await BuyDocument.findOne({ id })
|
||||||
|
if (!doc) {
|
||||||
|
console.log('[BUY API] Document not found for deletion:', id)
|
||||||
|
return res.status(404).json({ error: 'Document not found' })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete file from disk
|
||||||
|
if (doc.filePath && fs.existsSync(doc.filePath)) {
|
||||||
|
try {
|
||||||
|
fs.unlinkSync(doc.filePath)
|
||||||
|
console.log(`[BUY API] File deleted: ${doc.filePath}`)
|
||||||
|
} catch (e) {
|
||||||
|
console.error(`[BUY API] Error deleting file: ${e.message}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await BuyDocument.deleteOne({ id })
|
||||||
|
|
||||||
|
console.log('[BUY API] Document deleted:', id)
|
||||||
|
res.json({ id: doc.id, success: true })
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[BUY API] Error deleting document:', error)
|
||||||
|
res.status(500).json({ error: 'Failed to delete document' })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// GET /buy/docs/:id/file - Serve the file
|
||||||
|
router.get('/docs/:id/file', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { id } = req.params
|
||||||
|
console.log('[BUY API] GET /docs/:id/file', { id })
|
||||||
|
|
||||||
|
const doc = await BuyDocument.findOne({ id })
|
||||||
|
if (!doc) {
|
||||||
|
console.log('[BUY API] Document not found:', id)
|
||||||
|
return res.status(404).json({ error: 'Document not found' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const filePath = `${docsDir}/${id}.${doc.type}`
|
||||||
|
if (!fs.existsSync(filePath)) {
|
||||||
|
console.log('[BUY API] File not found on disk:', filePath)
|
||||||
|
return res.status(404).json({ error: 'File not found on disk' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const fileBuffer = fs.readFileSync(filePath)
|
||||||
|
|
||||||
|
const mimeTypes = {
|
||||||
|
'xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||||
|
'docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||||
|
'pdf': 'application/pdf'
|
||||||
|
}
|
||||||
|
|
||||||
|
const mimeType = mimeTypes[doc.type] || 'application/octet-stream'
|
||||||
|
const sanitizedName = doc.name.replace(/[^\w\s\-\.]/g, '_')
|
||||||
|
|
||||||
|
res.setHeader('Content-Type', mimeType)
|
||||||
|
const encodedFilename = encodeURIComponent(`${doc.name}.${doc.type}`)
|
||||||
|
res.setHeader('Content-Disposition', `attachment; filename="${sanitizedName}.${doc.type}"; filename*=UTF-8''${encodedFilename}`)
|
||||||
|
res.setHeader('Content-Length', fileBuffer.length)
|
||||||
|
|
||||||
|
console.log(`[BUY API] Serving file ${id} from ${filePath} (${fileBuffer.length} bytes)`)
|
||||||
|
res.send(fileBuffer)
|
||||||
|
} catch (e) {
|
||||||
|
console.error(`[BUY API] Error serving file: ${e.message}`)
|
||||||
|
res.status(500).json({ error: 'Error serving file' })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
module.exports = router
|
||||||
502
server/routers/procurement/routes/buyProducts.js
Normal file
502
server/routers/procurement/routes/buyProducts.js
Normal file
@@ -0,0 +1,502 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const router = express.Router();
|
||||||
|
const { verifyToken } = require('../middleware/auth');
|
||||||
|
const BuyProduct = require('../models/BuyProduct');
|
||||||
|
const path = require('path');
|
||||||
|
const fs = require('fs');
|
||||||
|
const multer = require('multer');
|
||||||
|
const UPLOADS_ROOT = 'server/routers/remote-assets/uploads/buy-products';
|
||||||
|
const ensureDirectory = (dirPath) => {
|
||||||
|
if (!fs.existsSync(dirPath)) {
|
||||||
|
fs.mkdirSync(dirPath, { recursive: true });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
ensureDirectory(UPLOADS_ROOT);
|
||||||
|
|
||||||
|
const MAX_FILE_SIZE = 15 * 1024 * 1024; // 15MB
|
||||||
|
const ALLOWED_MIME_TYPES = new Set([
|
||||||
|
'application/pdf',
|
||||||
|
'application/msword',
|
||||||
|
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||||
|
'application/vnd.ms-excel',
|
||||||
|
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||||
|
'text/csv',
|
||||||
|
]);
|
||||||
|
|
||||||
|
const storage = multer.diskStorage({
|
||||||
|
destination: (req, file, cb) => {
|
||||||
|
const productId = req.params.id || 'common';
|
||||||
|
const productDir = `${UPLOADS_ROOT}/${productId}`;
|
||||||
|
ensureDirectory(productDir);
|
||||||
|
cb(null, productDir);
|
||||||
|
},
|
||||||
|
filename: (req, file, cb) => {
|
||||||
|
// Исправляем кодировку имени файла из Latin1 в UTF-8
|
||||||
|
const fixedName = Buffer.from(file.originalname, 'latin1').toString('utf8');
|
||||||
|
const originalExtension = path.extname(fixedName) || '';
|
||||||
|
const baseName = path
|
||||||
|
.basename(fixedName, originalExtension)
|
||||||
|
.replace(/[<>:"/\\|?*\x00-\x1F]+/g, '_'); // Убираем только недопустимые символы Windows, оставляем кириллицу
|
||||||
|
cb(null, `${Date.now()}_${baseName}${originalExtension}`);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const upload = multer({
|
||||||
|
storage,
|
||||||
|
limits: {
|
||||||
|
fileSize: MAX_FILE_SIZE,
|
||||||
|
},
|
||||||
|
fileFilter: (req, file, cb) => {
|
||||||
|
if (ALLOWED_MIME_TYPES.has(file.mimetype)) {
|
||||||
|
cb(null, true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
req.fileValidationError = 'UNSUPPORTED_FILE_TYPE';
|
||||||
|
cb(null, false);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleSingleFileUpload = (req, res, next) => {
|
||||||
|
upload.single('file')(req, res, (err) => {
|
||||||
|
if (err) {
|
||||||
|
console.error('[BuyProducts] Multer error:', err.message);
|
||||||
|
if (err.code === 'LIMIT_FILE_SIZE') {
|
||||||
|
return res.status(400).json({ error: 'File is too large. Maximum size is 15MB.' });
|
||||||
|
}
|
||||||
|
return res.status(400).json({ error: err.message });
|
||||||
|
}
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
// Функция для логирования с проверкой DEV переменной
|
||||||
|
const log = (message, data = '') => {
|
||||||
|
if (process.env.DEV === 'true') {
|
||||||
|
if (data) {
|
||||||
|
console.log(message, data);
|
||||||
|
} else {
|
||||||
|
console.log(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// GET /buy-products/company/:companyId - получить товары компании
|
||||||
|
router.get('/company/:companyId', verifyToken, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { companyId } = req.params;
|
||||||
|
|
||||||
|
log('[BuyProducts] Fetching products for company:', companyId);
|
||||||
|
const products = await BuyProduct.find({ companyId })
|
||||||
|
.sort({ createdAt: -1 })
|
||||||
|
.exec();
|
||||||
|
|
||||||
|
log('[BuyProducts] Found', products.length, 'products for company', companyId);
|
||||||
|
log('[BuyProducts] Products:', products);
|
||||||
|
|
||||||
|
res.json(products);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[BuyProducts] Error fetching products:', error.message);
|
||||||
|
console.error('[BuyProducts] Error stack:', error.stack);
|
||||||
|
res.status(500).json({
|
||||||
|
error: 'Internal server error',
|
||||||
|
message: error.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /buy-products - создать новый товар
|
||||||
|
router.post('/', verifyToken, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { name, description, quantity, unit, status } = req.body;
|
||||||
|
|
||||||
|
log('[BuyProducts] Creating new product:', { name, description, quantity, companyId: req.companyId });
|
||||||
|
|
||||||
|
if (!name || !description || !quantity) {
|
||||||
|
return res.status(400).json({
|
||||||
|
error: 'name, description, and quantity are required',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (description.trim().length < 10) {
|
||||||
|
return res.status(400).json({
|
||||||
|
error: 'Description must be at least 10 characters',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const newProduct = new BuyProduct({
|
||||||
|
companyId: req.companyId,
|
||||||
|
name: name.trim(),
|
||||||
|
description: description.trim(),
|
||||||
|
quantity: quantity.trim(),
|
||||||
|
unit: unit || 'шт',
|
||||||
|
status: status || 'published',
|
||||||
|
files: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
log('[BuyProducts] Attempting to save product to DB...');
|
||||||
|
const savedProduct = await newProduct.save();
|
||||||
|
|
||||||
|
log('[BuyProducts] New product created successfully:', savedProduct._id);
|
||||||
|
log('[BuyProducts] Product data:', savedProduct);
|
||||||
|
|
||||||
|
res.status(201).json(savedProduct);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[BuyProducts] Error creating product:', error.message);
|
||||||
|
console.error('[BuyProducts] Error stack:', error.stack);
|
||||||
|
res.status(500).json({
|
||||||
|
error: 'Internal server error',
|
||||||
|
message: error.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// PUT /buy-products/:id - обновить товар
|
||||||
|
router.put('/:id', verifyToken, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
const { name, description, quantity, unit, status } = req.body;
|
||||||
|
|
||||||
|
const product = await BuyProduct.findById(id);
|
||||||
|
|
||||||
|
if (!product) {
|
||||||
|
return res.status(404).json({ error: 'Product not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Проверить, что товар принадлежит текущей компании
|
||||||
|
if (product.companyId !== req.companyId) {
|
||||||
|
return res.status(403).json({ error: 'Not authorized' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Обновить поля
|
||||||
|
if (name) product.name = name.trim();
|
||||||
|
if (description) product.description = description.trim();
|
||||||
|
if (quantity) product.quantity = quantity.trim();
|
||||||
|
if (unit) product.unit = unit;
|
||||||
|
if (status) product.status = status;
|
||||||
|
product.updatedAt = new Date();
|
||||||
|
|
||||||
|
const updatedProduct = await product.save();
|
||||||
|
|
||||||
|
log('[BuyProducts] Product updated:', id);
|
||||||
|
|
||||||
|
res.json(updatedProduct);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[BuyProducts] Error:', error.message);
|
||||||
|
res.status(500).json({
|
||||||
|
error: 'Internal server error',
|
||||||
|
message: error.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// DELETE /buy-products/:id - удалить товар
|
||||||
|
router.delete('/:id', verifyToken, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
|
||||||
|
const product = await BuyProduct.findById(id);
|
||||||
|
|
||||||
|
if (!product) {
|
||||||
|
return res.status(404).json({ error: 'Product not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (product.companyId.toString() !== req.companyId.toString()) {
|
||||||
|
return res.status(403).json({ error: 'Not authorized' });
|
||||||
|
}
|
||||||
|
|
||||||
|
await BuyProduct.findByIdAndDelete(id);
|
||||||
|
|
||||||
|
log('[BuyProducts] Product deleted:', id);
|
||||||
|
|
||||||
|
res.json({ message: 'Product deleted successfully' });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[BuyProducts] Error:', error.message);
|
||||||
|
res.status(500).json({
|
||||||
|
error: 'Internal server error',
|
||||||
|
message: error.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /buy-products/:id/files - добавить файл к товару
|
||||||
|
router.post('/:id/files', verifyToken, handleSingleFileUpload, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
const product = await BuyProduct.findById(id);
|
||||||
|
|
||||||
|
if (!product) {
|
||||||
|
return res.status(404).json({ error: 'Product not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Только владелец товара может добавить файл
|
||||||
|
const productCompanyId = product.companyId?.toString() || product.companyId;
|
||||||
|
const requestCompanyId = req.companyId?.toString() || req.companyId;
|
||||||
|
|
||||||
|
console.log('[BuyProducts] Comparing company IDs:', {
|
||||||
|
productCompanyId,
|
||||||
|
requestCompanyId,
|
||||||
|
match: productCompanyId === requestCompanyId
|
||||||
|
});
|
||||||
|
|
||||||
|
if (productCompanyId !== requestCompanyId) {
|
||||||
|
return res.status(403).json({ error: 'Not authorized' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (req.fileValidationError) {
|
||||||
|
return res.status(400).json({ error: 'Unsupported file type. Use PDF, DOC, DOCX, XLS, XLSX or CSV.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!req.file) {
|
||||||
|
return res.status(400).json({ error: 'File is required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Исправляем кодировку имени файла из Latin1 в UTF-8
|
||||||
|
const fixedFileName = Buffer.from(req.file.originalname, 'latin1').toString('utf8');
|
||||||
|
|
||||||
|
// Извлекаем timestamp из имени файла, созданного multer (формат: {timestamp}_{name}.ext)
|
||||||
|
const fileTimestamp = req.file.filename.split('_')[0];
|
||||||
|
|
||||||
|
// storagePath относительно UPLOADS_ROOT (который уже включает 'buy-products')
|
||||||
|
const relativePath = `${id}/${req.file.filename}`;
|
||||||
|
const file = {
|
||||||
|
id: `file-${fileTimestamp}`, // Используем тот же timestamp, что и в имени файла
|
||||||
|
name: fixedFileName,
|
||||||
|
url: `/uploads/buy-products/${relativePath}`,
|
||||||
|
type: req.file.mimetype,
|
||||||
|
size: req.file.size,
|
||||||
|
uploadedAt: new Date(),
|
||||||
|
storagePath: relativePath,
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log('[BuyProducts] Adding file to product:', {
|
||||||
|
productId: id,
|
||||||
|
fileName: file.name,
|
||||||
|
fileSize: file.size,
|
||||||
|
filePath: relativePath
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('[BuyProducts] File object:', JSON.stringify(file, null, 2));
|
||||||
|
|
||||||
|
// Используем findByIdAndUpdate вместо save() для избежания проблем с валидацией
|
||||||
|
let updatedProduct;
|
||||||
|
try {
|
||||||
|
console.log('[BuyProducts] Calling findByIdAndUpdate with id:', id);
|
||||||
|
updatedProduct = await BuyProduct.findByIdAndUpdate(
|
||||||
|
id,
|
||||||
|
{
|
||||||
|
$push: { files: file },
|
||||||
|
$set: { updatedAt: new Date() }
|
||||||
|
},
|
||||||
|
{ new: true, runValidators: false }
|
||||||
|
);
|
||||||
|
console.log('[BuyProducts] findByIdAndUpdate completed');
|
||||||
|
} catch (updateError) {
|
||||||
|
console.error('[BuyProducts] findByIdAndUpdate error:', {
|
||||||
|
message: updateError.message,
|
||||||
|
name: updateError.name,
|
||||||
|
code: updateError.code
|
||||||
|
});
|
||||||
|
throw updateError;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!updatedProduct) {
|
||||||
|
throw new Error('Failed to update product with file');
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('[BuyProducts] File added successfully to product:', id);
|
||||||
|
|
||||||
|
log('[BuyProducts] File added to product:', id, file.name);
|
||||||
|
|
||||||
|
res.json(updatedProduct);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[BuyProducts] Error adding file:', error.message);
|
||||||
|
console.error('[BuyProducts] Error stack:', error.stack);
|
||||||
|
console.error('[BuyProducts] Error name:', error.name);
|
||||||
|
if (error.errors) {
|
||||||
|
console.error('[BuyProducts] Validation errors:', JSON.stringify(error.errors, null, 2));
|
||||||
|
}
|
||||||
|
res.status(500).json({
|
||||||
|
error: 'Internal server error',
|
||||||
|
message: error.message,
|
||||||
|
details: error.errors || {},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// DELETE /buy-products/:id/files/:fileId - удалить файл
|
||||||
|
router.delete('/:id/files/:fileId', verifyToken, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { id, fileId } = req.params;
|
||||||
|
|
||||||
|
const product = await BuyProduct.findById(id);
|
||||||
|
|
||||||
|
if (!product) {
|
||||||
|
return res.status(404).json({ error: 'Product not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (product.companyId.toString() !== req.companyId.toString()) {
|
||||||
|
return res.status(403).json({ error: 'Not authorized' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const fileToRemove = product.files.find((f) => f.id === fileId);
|
||||||
|
if (!fileToRemove) {
|
||||||
|
return res.status(404).json({ error: 'File not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
product.files = product.files.filter(f => f.id !== fileId);
|
||||||
|
await product.save();
|
||||||
|
|
||||||
|
const storedPath = fileToRemove.storagePath || fileToRemove.url.replace(/^\/uploads\//, '');
|
||||||
|
const absolutePath = `server/routers/remote-assets/uploads/${storedPath}`;
|
||||||
|
|
||||||
|
fs.promises.unlink(absolutePath).catch((unlinkError) => {
|
||||||
|
if (unlinkError && unlinkError.code !== 'ENOENT') {
|
||||||
|
console.error('[BuyProducts] Failed to remove file from disk:', unlinkError.message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
log('[BuyProducts] File deleted from product:', id, fileId);
|
||||||
|
|
||||||
|
res.json(product);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[BuyProducts] Error deleting file:', error.message);
|
||||||
|
res.status(500).json({
|
||||||
|
error: 'Internal server error',
|
||||||
|
message: error.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /buy-products/:id/accept - акцептировать товар
|
||||||
|
router.post('/:id/accept', verifyToken, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
const companyId = req.companyId;
|
||||||
|
|
||||||
|
const product = await BuyProduct.findById(id);
|
||||||
|
|
||||||
|
if (!product) {
|
||||||
|
return res.status(404).json({ error: 'Product not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Не можем акцептировать собственный товар
|
||||||
|
if (product.companyId.toString() === companyId.toString()) {
|
||||||
|
return res.status(403).json({ error: 'Cannot accept own product' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Проверить, не акцептировал ли уже
|
||||||
|
const alreadyAccepted = product.acceptedBy.some(
|
||||||
|
a => a.companyId.toString() === companyId.toString()
|
||||||
|
);
|
||||||
|
|
||||||
|
if (alreadyAccepted) {
|
||||||
|
return res.status(400).json({ error: 'Already accepted' });
|
||||||
|
}
|
||||||
|
|
||||||
|
product.acceptedBy.push({
|
||||||
|
companyId,
|
||||||
|
acceptedAt: new Date()
|
||||||
|
});
|
||||||
|
|
||||||
|
await product.save();
|
||||||
|
|
||||||
|
log('[BuyProducts] Product accepted by company:', companyId);
|
||||||
|
|
||||||
|
res.json(product);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[BuyProducts] Error accepting product:', error.message);
|
||||||
|
res.status(500).json({
|
||||||
|
error: 'Internal server error',
|
||||||
|
message: error.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /buy-products/:id/acceptances - получить компании которые акцептовали
|
||||||
|
router.get('/:id/acceptances', verifyToken, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
|
||||||
|
const product = await BuyProduct.findById(id).populate('acceptedBy.companyId', 'shortName fullName');
|
||||||
|
|
||||||
|
if (!product) {
|
||||||
|
return res.status(404).json({ error: 'Product not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
log('[BuyProducts] Returned acceptances for product:', id);
|
||||||
|
|
||||||
|
res.json(product.acceptedBy);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[BuyProducts] Error fetching acceptances:', error.message);
|
||||||
|
res.status(500).json({
|
||||||
|
error: 'Internal server error',
|
||||||
|
message: error.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /buy-products/download/:id/:fileId - скачать файл
|
||||||
|
router.get('/download/:id/:fileId', verifyToken, async (req, res) => {
|
||||||
|
try {
|
||||||
|
console.log('[BuyProducts] Download request received:', {
|
||||||
|
productId: req.params.id,
|
||||||
|
fileId: req.params.fileId,
|
||||||
|
userId: req.userId,
|
||||||
|
companyId: req.companyId,
|
||||||
|
headers: req.headers.authorization
|
||||||
|
});
|
||||||
|
|
||||||
|
const { id, fileId } = req.params;
|
||||||
|
const product = await BuyProduct.findById(id);
|
||||||
|
|
||||||
|
if (!product) {
|
||||||
|
return res.status(404).json({ error: 'Product not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const file = product.files.find((f) => f.id === fileId);
|
||||||
|
if (!file) {
|
||||||
|
return res.status(404).json({ error: 'File not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Создаем абсолютный путь к файлу
|
||||||
|
const filePath = path.resolve(UPLOADS_ROOT, file.storagePath);
|
||||||
|
|
||||||
|
console.log('[BuyProducts] Trying to download file:', {
|
||||||
|
fileId: file.id,
|
||||||
|
fileName: file.name,
|
||||||
|
storagePath: file.storagePath,
|
||||||
|
absolutePath: filePath,
|
||||||
|
exists: fs.existsSync(filePath)
|
||||||
|
});
|
||||||
|
|
||||||
|
// Проверяем существование файла
|
||||||
|
if (!fs.existsSync(filePath)) {
|
||||||
|
console.error('[BuyProducts] File not found on disk:', filePath);
|
||||||
|
return res.status(404).json({ error: 'File not found on disk' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Устанавливаем правильные заголовки для скачивания с поддержкой кириллицы
|
||||||
|
const encodedFileName = encodeURIComponent(file.name);
|
||||||
|
res.setHeader('Content-Type', file.type || 'application/octet-stream');
|
||||||
|
res.setHeader('Content-Disposition', `attachment; filename*=UTF-8''${encodedFileName}`);
|
||||||
|
res.setHeader('Content-Length', file.size);
|
||||||
|
|
||||||
|
// Отправляем файл
|
||||||
|
res.sendFile(filePath, (err) => {
|
||||||
|
if (err) {
|
||||||
|
console.error('[BuyProducts] Error sending file:', err.message);
|
||||||
|
if (!res.headersSent) {
|
||||||
|
res.status(500).json({ error: 'Error downloading file' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[BuyProducts] Error downloading file:', error.message);
|
||||||
|
res.status(500).json({ error: 'Internal server error' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
336
server/routers/procurement/routes/companies.js
Normal file
336
server/routers/procurement/routes/companies.js
Normal file
@@ -0,0 +1,336 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const router = express.Router();
|
||||||
|
const { verifyToken } = require('../middleware/auth');
|
||||||
|
const Company = require('../models/Company');
|
||||||
|
const Experience = require('../models/Experience');
|
||||||
|
const Request = require('../models/Request');
|
||||||
|
const Message = require('../models/Message');
|
||||||
|
const mongoose = require('../../../utils/mongoose');
|
||||||
|
const { Types } = mongoose;
|
||||||
|
|
||||||
|
// GET /my/info - получить мою компанию (требует авторизации) - ДОЛЖНО быть ПЕРЕД /:id
|
||||||
|
router.get('/my/info', verifyToken, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const userId = req.userId;
|
||||||
|
const user = await require('../models/User').findById(userId);
|
||||||
|
|
||||||
|
if (!user || !user.companyId) {
|
||||||
|
return res.status(404).json({ error: 'Company not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const company = await Company.findById(user.companyId);
|
||||||
|
|
||||||
|
if (!company) {
|
||||||
|
return res.status(404).json({ error: 'Company not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
...company.toObject(),
|
||||||
|
id: company._id
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Get my company error:', error);
|
||||||
|
res.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /my/stats - получить статистику компании - ДОЛЖНО быть ПЕРЕД /:id
|
||||||
|
router.get('/my/stats', verifyToken, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const userId = req.userId;
|
||||||
|
const User = require('../models/User');
|
||||||
|
const user = await User.findById(userId);
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return res.status(404).json({ error: 'User not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
let companyId = user.companyId;
|
||||||
|
|
||||||
|
if (!companyId) {
|
||||||
|
const fallbackCompany = await Company.create({
|
||||||
|
fullName: 'Компания пользователя',
|
||||||
|
shortName: 'Компания пользователя',
|
||||||
|
verified: false,
|
||||||
|
partnerGeography: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
user.companyId = fallbackCompany._id;
|
||||||
|
user.updatedAt = new Date();
|
||||||
|
await user.save();
|
||||||
|
companyId = fallbackCompany._id;
|
||||||
|
}
|
||||||
|
|
||||||
|
let company = await Company.findById(companyId);
|
||||||
|
|
||||||
|
if (!company) {
|
||||||
|
company = await Company.create({
|
||||||
|
_id: companyId,
|
||||||
|
fullName: 'Компания пользователя',
|
||||||
|
verified: false,
|
||||||
|
partnerGeography: [],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const companyIdString = company._id.toString();
|
||||||
|
const companyObjectId = Types.ObjectId.isValid(companyIdString)
|
||||||
|
? new Types.ObjectId(companyIdString)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const [sentRequests, receivedRequests, unreadMessages] = await Promise.all([
|
||||||
|
Request.countDocuments({ senderCompanyId: companyIdString }),
|
||||||
|
Request.countDocuments({ recipientCompanyId: companyIdString }),
|
||||||
|
companyObjectId
|
||||||
|
? Message.countDocuments({ recipientCompanyId: companyObjectId, read: false })
|
||||||
|
: Promise.resolve(0),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Подсчитываем просмотры профиля из запросов к профилю компании
|
||||||
|
const profileViews = company?.metrics?.profileViews || 0;
|
||||||
|
|
||||||
|
// Получаем статистику за последнюю неделю для изменений
|
||||||
|
const weekAgo = new Date();
|
||||||
|
weekAgo.setDate(weekAgo.getDate() - 7);
|
||||||
|
|
||||||
|
const sentRequestsLastWeek = await Request.countDocuments({
|
||||||
|
senderCompanyId: companyIdString,
|
||||||
|
createdAt: { $gte: weekAgo }
|
||||||
|
});
|
||||||
|
|
||||||
|
const receivedRequestsLastWeek = await Request.countDocuments({
|
||||||
|
recipientCompanyId: companyIdString,
|
||||||
|
createdAt: { $gte: weekAgo }
|
||||||
|
});
|
||||||
|
|
||||||
|
const stats = {
|
||||||
|
profileViews: profileViews,
|
||||||
|
profileViewsChange: 0, // Можно добавить отслеживание просмотров, если нужно
|
||||||
|
sentRequests,
|
||||||
|
sentRequestsChange: sentRequestsLastWeek,
|
||||||
|
receivedRequests,
|
||||||
|
receivedRequestsChange: receivedRequestsLastWeek,
|
||||||
|
newMessages: unreadMessages,
|
||||||
|
rating: Number.isFinite(company?.rating) ? Number(company.rating) : 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
res.json(stats);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Get company stats error:', error);
|
||||||
|
res.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /:id/experience - получить опыт компании
|
||||||
|
router.get('/:id/experience', verifyToken, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
|
||||||
|
if (!Types.ObjectId.isValid(id)) {
|
||||||
|
return res.status(400).json({ error: 'Invalid company ID' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const experience = await Experience.find({ companyId: new Types.ObjectId(id) })
|
||||||
|
.sort({ createdAt: -1 });
|
||||||
|
|
||||||
|
res.json(experience.map(exp => ({
|
||||||
|
...exp.toObject(),
|
||||||
|
id: exp._id
|
||||||
|
})));
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /:id/experience - добавить опыт компании
|
||||||
|
router.post('/:id/experience', verifyToken, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
const { confirmed, customer, subject, volume, contact, comment } = req.body;
|
||||||
|
|
||||||
|
if (!Types.ObjectId.isValid(id)) {
|
||||||
|
return res.status(400).json({ error: 'Invalid company ID' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const newExp = await Experience.create({
|
||||||
|
companyId: new Types.ObjectId(id),
|
||||||
|
confirmed: confirmed || false,
|
||||||
|
customer: customer || '',
|
||||||
|
subject: subject || '',
|
||||||
|
volume: volume || '',
|
||||||
|
contact: contact || '',
|
||||||
|
comment: comment || ''
|
||||||
|
});
|
||||||
|
|
||||||
|
res.status(201).json({
|
||||||
|
...newExp.toObject(),
|
||||||
|
id: newExp._id
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// PUT /:id/experience/:expId - обновить опыт
|
||||||
|
router.put('/:id/experience/:expId', verifyToken, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { id, expId } = req.params;
|
||||||
|
|
||||||
|
if (!Types.ObjectId.isValid(id) || !Types.ObjectId.isValid(expId)) {
|
||||||
|
return res.status(400).json({ error: 'Invalid IDs' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const experience = await Experience.findByIdAndUpdate(
|
||||||
|
new Types.ObjectId(expId),
|
||||||
|
{
|
||||||
|
...req.body,
|
||||||
|
updatedAt: new Date()
|
||||||
|
},
|
||||||
|
{ new: true }
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!experience || experience.companyId.toString() !== id) {
|
||||||
|
return res.status(404).json({ error: 'Experience not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
...experience.toObject(),
|
||||||
|
id: experience._id
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// DELETE /:id/experience/:expId - удалить опыт
|
||||||
|
router.delete('/:id/experience/:expId', verifyToken, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { id, expId } = req.params;
|
||||||
|
|
||||||
|
if (!Types.ObjectId.isValid(id) || !Types.ObjectId.isValid(expId)) {
|
||||||
|
return res.status(400).json({ error: 'Invalid IDs' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const experience = await Experience.findById(new Types.ObjectId(expId));
|
||||||
|
|
||||||
|
if (!experience || experience.companyId.toString() !== id) {
|
||||||
|
return res.status(404).json({ error: 'Experience not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
await Experience.findByIdAndDelete(new Types.ObjectId(expId));
|
||||||
|
res.json({ message: 'Experience deleted' });
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Получить компанию по ID (ДОЛЖНО быть ПОСЛЕ специфичных маршрутов)
|
||||||
|
router.get('/:id', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const company = await Company.findById(req.params.id);
|
||||||
|
|
||||||
|
if (!company) {
|
||||||
|
if (!Types.ObjectId.isValid(req.params.id)) {
|
||||||
|
return res.status(404).json({ error: 'Company not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const placeholder = await Company.create({
|
||||||
|
_id: new Types.ObjectId(req.params.id),
|
||||||
|
fullName: 'Новая компания',
|
||||||
|
shortName: 'Новая компания',
|
||||||
|
verified: false,
|
||||||
|
partnerGeography: [],
|
||||||
|
industry: '',
|
||||||
|
companySize: '',
|
||||||
|
});
|
||||||
|
|
||||||
|
return res.json({
|
||||||
|
...placeholder.toObject(),
|
||||||
|
id: placeholder._id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Отслеживаем просмотр профиля (если это не владелец компании)
|
||||||
|
const userId = req.userId;
|
||||||
|
if (userId) {
|
||||||
|
const User = require('../models/User');
|
||||||
|
const user = await User.findById(userId);
|
||||||
|
if (user && user.companyId && user.companyId.toString() !== company._id.toString()) {
|
||||||
|
// Инкрементируем просмотры профиля
|
||||||
|
if (!company.metrics) {
|
||||||
|
company.metrics = {};
|
||||||
|
}
|
||||||
|
if (!company.metrics.profileViews) {
|
||||||
|
company.metrics.profileViews = 0;
|
||||||
|
}
|
||||||
|
company.metrics.profileViews = (company.metrics.profileViews || 0) + 1;
|
||||||
|
await company.save();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
...company.toObject(),
|
||||||
|
id: company._id
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Get company error:', error);
|
||||||
|
res.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Обновить компанию (требует авторизации)
|
||||||
|
const updateCompanyHandler = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const company = await Company.findByIdAndUpdate(
|
||||||
|
req.params.id,
|
||||||
|
{ ...req.body, updatedAt: new Date() },
|
||||||
|
{ new: true }
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!company) {
|
||||||
|
return res.status(404).json({ error: 'Company not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
...company.toObject(),
|
||||||
|
id: company._id
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
router.put('/:id', verifyToken, updateCompanyHandler);
|
||||||
|
router.patch('/:id', verifyToken, updateCompanyHandler);
|
||||||
|
|
||||||
|
// Поиск с AI анализом
|
||||||
|
router.post('/ai-search', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { query } = req.body;
|
||||||
|
|
||||||
|
if (!query) {
|
||||||
|
return res.status(400).json({ error: 'Query required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const q = query.toLowerCase();
|
||||||
|
const result = await Company.find({
|
||||||
|
$or: [
|
||||||
|
{ fullName: { $regex: q, $options: 'i' } },
|
||||||
|
{ shortName: { $regex: q, $options: 'i' } },
|
||||||
|
{ industry: { $regex: q, $options: 'i' } }
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
companies: result.map(c => ({
|
||||||
|
...c.toObject(),
|
||||||
|
id: c._id
|
||||||
|
})),
|
||||||
|
total: result.length,
|
||||||
|
aiSuggestion: `Found ${result.length} companies matching "${query}"`
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
134
server/routers/procurement/routes/experience.js
Normal file
134
server/routers/procurement/routes/experience.js
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const router = express.Router();
|
||||||
|
const { verifyToken } = require('../middleware/auth');
|
||||||
|
const Experience = require('../models/Experience');
|
||||||
|
const mongoose = require('../../../utils/mongoose');
|
||||||
|
const { Types } = mongoose;
|
||||||
|
|
||||||
|
// GET /experience - Получить список опыта работы компании
|
||||||
|
router.get('/', verifyToken, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { companyId } = req.query;
|
||||||
|
|
||||||
|
if (!companyId) {
|
||||||
|
return res.status(400).json({ error: 'companyId is required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Types.ObjectId.isValid(companyId)) {
|
||||||
|
return res.status(400).json({ error: 'Invalid company ID' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const companyExperiences = await Experience.find({
|
||||||
|
companyId: new Types.ObjectId(companyId)
|
||||||
|
}).sort({ createdAt: -1 });
|
||||||
|
|
||||||
|
res.json(companyExperiences.map(exp => ({
|
||||||
|
...exp.toObject(),
|
||||||
|
id: exp._id
|
||||||
|
})));
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Get experience error:', error);
|
||||||
|
res.status(500).json({ error: 'Internal server error' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /experience - Создать запись опыта работы
|
||||||
|
router.post('/', verifyToken, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { companyId, data } = req.body;
|
||||||
|
|
||||||
|
if (!companyId || !data) {
|
||||||
|
return res.status(400).json({ error: 'companyId and data are required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Types.ObjectId.isValid(companyId)) {
|
||||||
|
return res.status(400).json({ error: 'Invalid company ID' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { confirmed, customer, subject, volume, contact, comment } = data;
|
||||||
|
|
||||||
|
if (!customer || !subject) {
|
||||||
|
return res.status(400).json({ error: 'customer and subject are required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const newExperience = await Experience.create({
|
||||||
|
companyId: new Types.ObjectId(companyId),
|
||||||
|
confirmed: confirmed || false,
|
||||||
|
customer,
|
||||||
|
subject,
|
||||||
|
volume: volume || '',
|
||||||
|
contact: contact || '',
|
||||||
|
comment: comment || ''
|
||||||
|
});
|
||||||
|
|
||||||
|
res.status(201).json({
|
||||||
|
...newExperience.toObject(),
|
||||||
|
id: newExperience._id
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Create experience error:', error);
|
||||||
|
res.status(500).json({ error: 'Internal server error' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// PUT /experience/:id - Обновить запись опыта работы
|
||||||
|
router.put('/:id', verifyToken, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
const { data } = req.body;
|
||||||
|
|
||||||
|
if (!data) {
|
||||||
|
return res.status(400).json({ error: 'data is required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Types.ObjectId.isValid(id)) {
|
||||||
|
return res.status(400).json({ error: 'Invalid experience ID' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatedExperience = await Experience.findByIdAndUpdate(
|
||||||
|
new Types.ObjectId(id),
|
||||||
|
{
|
||||||
|
...data,
|
||||||
|
updatedAt: new Date()
|
||||||
|
},
|
||||||
|
{ new: true }
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!updatedExperience) {
|
||||||
|
return res.status(404).json({ error: 'Experience not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
...updatedExperience.toObject(),
|
||||||
|
id: updatedExperience._id
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Update experience error:', error);
|
||||||
|
res.status(500).json({ error: 'Internal server error' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// DELETE /experience/:id - Удалить запись опыта работы
|
||||||
|
router.delete('/:id', verifyToken, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
|
||||||
|
if (!Types.ObjectId.isValid(id)) {
|
||||||
|
return res.status(400).json({ error: 'Invalid experience ID' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const deletedExperience = await Experience.findByIdAndDelete(new Types.ObjectId(id));
|
||||||
|
|
||||||
|
if (!deletedExperience) {
|
||||||
|
return res.status(404).json({ error: 'Experience not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ message: 'Experience deleted successfully' });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Delete experience error:', error);
|
||||||
|
res.status(500).json({ error: 'Internal server error' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
|
|
||||||
137
server/routers/procurement/routes/home.js
Normal file
137
server/routers/procurement/routes/home.js
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const router = express.Router();
|
||||||
|
const { verifyToken } = require('../middleware/auth');
|
||||||
|
const BuyProduct = require('../models/BuyProduct');
|
||||||
|
const Request = require('../models/Request');
|
||||||
|
|
||||||
|
// Получить агрегированные данные для главной страницы
|
||||||
|
router.get('/aggregates', verifyToken, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const userId = req.userId;
|
||||||
|
const User = require('../models/User');
|
||||||
|
const user = await User.findById(userId);
|
||||||
|
|
||||||
|
if (!user || !user.companyId) {
|
||||||
|
return res.json({
|
||||||
|
docsCount: 0,
|
||||||
|
acceptsCount: 0,
|
||||||
|
requestsCount: 0
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const companyId = user.companyId.toString();
|
||||||
|
|
||||||
|
// Получить все BuyProduct для подсчета файлов и акцептов
|
||||||
|
const buyProducts = await BuyProduct.find({ companyId });
|
||||||
|
|
||||||
|
// Подсчет документов - сумма всех файлов во всех BuyProduct
|
||||||
|
const docsCount = buyProducts.reduce((total, product) => {
|
||||||
|
return total + (product.files ? product.files.length : 0);
|
||||||
|
}, 0);
|
||||||
|
|
||||||
|
// Подсчет акцептов - сумма всех acceptedBy во всех BuyProduct
|
||||||
|
const acceptsCount = buyProducts.reduce((total, product) => {
|
||||||
|
return total + (product.acceptedBy ? product.acceptedBy.length : 0);
|
||||||
|
}, 0);
|
||||||
|
|
||||||
|
// Подсчет исходящих запросов (только отправленные этой компанией)
|
||||||
|
const requestsCount = await Request.countDocuments({
|
||||||
|
senderCompanyId: companyId
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
docsCount,
|
||||||
|
acceptsCount,
|
||||||
|
requestsCount
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error getting aggregates:', error);
|
||||||
|
res.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Получить статистику компании
|
||||||
|
router.get('/stats', verifyToken, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const userId = req.userId;
|
||||||
|
const User = require('../models/User');
|
||||||
|
const Company = require('../models/Company');
|
||||||
|
const user = await User.findById(userId);
|
||||||
|
|
||||||
|
if (!user || !user.companyId) {
|
||||||
|
return res.json({
|
||||||
|
profileViews: 0,
|
||||||
|
profileViewsChange: 0,
|
||||||
|
sentRequests: 0,
|
||||||
|
sentRequestsChange: 0,
|
||||||
|
receivedRequests: 0,
|
||||||
|
receivedRequestsChange: 0,
|
||||||
|
newMessages: 0,
|
||||||
|
rating: 0
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const companyId = user.companyId.toString();
|
||||||
|
const company = await Company.findById(user.companyId);
|
||||||
|
|
||||||
|
const sentRequests = await Request.countDocuments({ senderCompanyId: companyId });
|
||||||
|
const receivedRequests = await Request.countDocuments({ recipientCompanyId: companyId });
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
profileViews: company?.metrics?.profileViews || 0,
|
||||||
|
profileViewsChange: 0,
|
||||||
|
sentRequests,
|
||||||
|
sentRequestsChange: 0,
|
||||||
|
receivedRequests,
|
||||||
|
receivedRequestsChange: 0,
|
||||||
|
newMessages: 0,
|
||||||
|
rating: company?.rating || 0
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error getting stats:', error);
|
||||||
|
res.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Получить рекомендации партнеров (AI)
|
||||||
|
router.get('/recommendations', verifyToken, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const userId = req.userId;
|
||||||
|
const User = require('../models/User');
|
||||||
|
const Company = require('../models/Company');
|
||||||
|
const user = await User.findById(userId);
|
||||||
|
|
||||||
|
if (!user || !user.companyId) {
|
||||||
|
return res.json({
|
||||||
|
recommendations: [],
|
||||||
|
message: 'No recommendations available'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Получить компании кроме текущей
|
||||||
|
const companies = await Company.find({
|
||||||
|
_id: { $ne: user.companyId }
|
||||||
|
})
|
||||||
|
.sort({ rating: -1 })
|
||||||
|
.limit(5);
|
||||||
|
|
||||||
|
const recommendations = companies.map(company => ({
|
||||||
|
id: company._id.toString(),
|
||||||
|
name: company.fullName || company.shortName,
|
||||||
|
industry: company.industry,
|
||||||
|
logo: company.logo,
|
||||||
|
matchScore: company.rating ? Math.min(100, Math.round(company.rating * 20)) : 50,
|
||||||
|
reason: 'Matches your industry'
|
||||||
|
}));
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
recommendations,
|
||||||
|
message: recommendations.length > 0 ? 'Recommendations available' : 'No recommendations available'
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error getting recommendations:', error);
|
||||||
|
res.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
263
server/routers/procurement/routes/messages.js
Normal file
263
server/routers/procurement/routes/messages.js
Normal file
@@ -0,0 +1,263 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const router = express.Router();
|
||||||
|
const { verifyToken } = require('../middleware/auth');
|
||||||
|
const Message = require('../models/Message');
|
||||||
|
const mongoose = require('../../../utils/mongoose');
|
||||||
|
const { ObjectId } = mongoose.Types;
|
||||||
|
|
||||||
|
// Функция для логирования с проверкой DEV переменной
|
||||||
|
const log = (message, data = '') => {
|
||||||
|
if (process.env.DEV === 'true') {
|
||||||
|
if (data) {
|
||||||
|
console.log(message, data);
|
||||||
|
} else {
|
||||||
|
console.log(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// GET /messages/threads - получить все потоки для компании
|
||||||
|
router.get('/threads', verifyToken, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const companyId = req.companyId;
|
||||||
|
|
||||||
|
log('[Messages] Fetching threads for companyId:', companyId, 'type:', typeof companyId);
|
||||||
|
|
||||||
|
// Преобразовать в ObjectId если это строка
|
||||||
|
let companyObjectId = companyId;
|
||||||
|
let companyIdString = companyId.toString ? companyId.toString() : companyId;
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (typeof companyId === 'string' && ObjectId.isValid(companyId)) {
|
||||||
|
companyObjectId = new ObjectId(companyId);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
log('[Messages] Could not convert to ObjectId:', e.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
log('[Messages] Using companyObjectId:', companyObjectId, 'companyIdString:', companyIdString);
|
||||||
|
|
||||||
|
// Получить все сообщения где текущая компания отправитель или получатель
|
||||||
|
// Поддерживаем оба формата - ObjectId и строки
|
||||||
|
const allMessages = await Message.find({
|
||||||
|
$or: [
|
||||||
|
{ senderCompanyId: companyObjectId },
|
||||||
|
{ senderCompanyId: companyIdString },
|
||||||
|
{ recipientCompanyId: companyObjectId },
|
||||||
|
{ recipientCompanyId: companyIdString },
|
||||||
|
// Также ищем по threadId который может содержать ID компании
|
||||||
|
{ threadId: { $regex: companyIdString } }
|
||||||
|
]
|
||||||
|
})
|
||||||
|
.sort({ timestamp: -1 })
|
||||||
|
.limit(500);
|
||||||
|
|
||||||
|
log('[Messages] Found', allMessages.length, 'messages for company');
|
||||||
|
|
||||||
|
if (allMessages.length === 0) {
|
||||||
|
log('[Messages] No messages found');
|
||||||
|
res.json([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Группируем по потокам и берем последнее сообщение каждого потока
|
||||||
|
const threadsMap = new Map();
|
||||||
|
allMessages.forEach(msg => {
|
||||||
|
const threadId = msg.threadId;
|
||||||
|
if (!threadsMap.has(threadId)) {
|
||||||
|
threadsMap.set(threadId, {
|
||||||
|
threadId,
|
||||||
|
lastMessage: msg.text,
|
||||||
|
lastMessageAt: msg.timestamp,
|
||||||
|
senderCompanyId: msg.senderCompanyId,
|
||||||
|
recipientCompanyId: msg.recipientCompanyId
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const threads = Array.from(threadsMap.values()).sort((a, b) =>
|
||||||
|
new Date(b.lastMessageAt) - new Date(a.lastMessageAt)
|
||||||
|
);
|
||||||
|
|
||||||
|
log('[Messages] Returned', threads.length, 'unique threads');
|
||||||
|
|
||||||
|
res.json(threads);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[Messages] Error fetching threads:', error.message, error.stack);
|
||||||
|
res.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /messages/:threadId - получить сообщения потока
|
||||||
|
router.get('/:threadId', verifyToken, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { threadId } = req.params;
|
||||||
|
const companyId = req.companyId;
|
||||||
|
|
||||||
|
// Получить все сообщения потока
|
||||||
|
const threadMessages = await Message.find({ threadId })
|
||||||
|
.sort({ timestamp: 1 })
|
||||||
|
.exec();
|
||||||
|
|
||||||
|
// Отметить сообщения как прочитанные для текущей компании
|
||||||
|
await Message.updateMany(
|
||||||
|
{ threadId, recipientCompanyId: companyId, read: false },
|
||||||
|
{ read: true }
|
||||||
|
);
|
||||||
|
|
||||||
|
log('[Messages] Returned', threadMessages.length, 'messages for thread', threadId);
|
||||||
|
|
||||||
|
res.json(threadMessages);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[Messages] Error fetching messages:', error.message);
|
||||||
|
res.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /messages/:threadId - добавить сообщение в поток
|
||||||
|
router.post('/:threadId', verifyToken, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { threadId } = req.params;
|
||||||
|
const { text, senderCompanyId } = req.body;
|
||||||
|
|
||||||
|
if (!text || !threadId) {
|
||||||
|
return res.status(400).json({ error: 'Text and threadId required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Определить получателя на основе threadId
|
||||||
|
// threadId формат: "thread-id1-id2"
|
||||||
|
const threadParts = threadId.replace('thread-', '').split('-');
|
||||||
|
let recipientCompanyId = null;
|
||||||
|
|
||||||
|
const currentSender = senderCompanyId || req.companyId;
|
||||||
|
const currentSenderString = currentSender.toString ? currentSender.toString() : currentSender;
|
||||||
|
|
||||||
|
if (threadParts.length >= 2) {
|
||||||
|
const companyId1 = threadParts[0];
|
||||||
|
const companyId2 = threadParts[1];
|
||||||
|
// Получатель - это другая сторона
|
||||||
|
recipientCompanyId = currentSenderString === companyId1 ? companyId2 : companyId1;
|
||||||
|
}
|
||||||
|
|
||||||
|
log('[Messages] POST /messages/:threadId');
|
||||||
|
log('[Messages] threadId:', threadId);
|
||||||
|
log('[Messages] Sender:', currentSender);
|
||||||
|
log('[Messages] SenderString:', currentSenderString);
|
||||||
|
log('[Messages] Recipient:', recipientCompanyId);
|
||||||
|
|
||||||
|
// Найти recipientCompanyId по ObjectId если нужно
|
||||||
|
let recipientObjectId = recipientCompanyId;
|
||||||
|
try {
|
||||||
|
if (typeof recipientCompanyId === 'string' && ObjectId.isValid(recipientCompanyId)) {
|
||||||
|
recipientObjectId = new ObjectId(recipientCompanyId);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
log('[Messages] Could not convert recipientId to ObjectId');
|
||||||
|
}
|
||||||
|
|
||||||
|
const message = new Message({
|
||||||
|
threadId,
|
||||||
|
senderCompanyId: currentSender,
|
||||||
|
recipientCompanyId: recipientObjectId,
|
||||||
|
text: text.trim(),
|
||||||
|
read: false,
|
||||||
|
timestamp: new Date()
|
||||||
|
});
|
||||||
|
|
||||||
|
const savedMessage = await message.save();
|
||||||
|
|
||||||
|
log('[Messages] New message created:', savedMessage._id);
|
||||||
|
log('[Messages] Message data:', {
|
||||||
|
threadId: savedMessage.threadId,
|
||||||
|
senderCompanyId: savedMessage.senderCompanyId,
|
||||||
|
recipientCompanyId: savedMessage.recipientCompanyId
|
||||||
|
});
|
||||||
|
|
||||||
|
res.status(201).json(savedMessage);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[Messages] Error creating message:', error.message, error.stack);
|
||||||
|
res.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// MIGRATION ENDPOINT - Fix recipientCompanyId for all messages
|
||||||
|
router.post('/admin/migrate-fix-recipients', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const allMessages = await Message.find().exec();
|
||||||
|
log('[Messages] Migrating', allMessages.length, 'messages...');
|
||||||
|
|
||||||
|
let fixedCount = 0;
|
||||||
|
let errorCount = 0;
|
||||||
|
|
||||||
|
for (const message of allMessages) {
|
||||||
|
try {
|
||||||
|
const threadId = message.threadId;
|
||||||
|
if (!threadId) continue;
|
||||||
|
|
||||||
|
// Parse threadId формат "thread-id1-id2" или "id1-id2"
|
||||||
|
const ids = threadId.replace('thread-', '').split('-');
|
||||||
|
if (ids.length < 2) {
|
||||||
|
errorCount++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const companyId1 = ids[0];
|
||||||
|
const companyId2 = ids[1];
|
||||||
|
|
||||||
|
// Compare with senderCompanyId
|
||||||
|
const senderIdString = message.senderCompanyId.toString ? message.senderCompanyId.toString() : message.senderCompanyId;
|
||||||
|
const expectedRecipient = senderIdString === companyId1 ? companyId2 : companyId1;
|
||||||
|
|
||||||
|
// If recipientCompanyId is not set or wrong - fix it
|
||||||
|
if (!message.recipientCompanyId || message.recipientCompanyId.toString() !== expectedRecipient) {
|
||||||
|
let recipientObjectId = expectedRecipient;
|
||||||
|
try {
|
||||||
|
if (typeof expectedRecipient === 'string' && ObjectId.isValid(expectedRecipient)) {
|
||||||
|
recipientObjectId = new ObjectId(expectedRecipient);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// continue
|
||||||
|
}
|
||||||
|
|
||||||
|
await Message.updateOne(
|
||||||
|
{ _id: message._id },
|
||||||
|
{ recipientCompanyId: recipientObjectId }
|
||||||
|
);
|
||||||
|
|
||||||
|
fixedCount++;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[Messages] Migration error:', err.message);
|
||||||
|
errorCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
log('[Messages] Migration completed! Fixed:', fixedCount, 'Errors:', errorCount);
|
||||||
|
res.json({ success: true, fixed: fixedCount, errors: errorCount, total: allMessages.length });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[Messages] Migration error:', error.message);
|
||||||
|
res.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// DEBUG ENDPOINT
|
||||||
|
router.get('/debug/all-messages', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const allMessages = await Message.find().limit(10).exec();
|
||||||
|
log('[Debug] Total messages in DB:', allMessages.length);
|
||||||
|
|
||||||
|
const info = allMessages.map(m => ({
|
||||||
|
_id: m._id,
|
||||||
|
threadId: m.threadId,
|
||||||
|
senderCompanyId: m.senderCompanyId?.toString ? m.senderCompanyId.toString() : m.senderCompanyId,
|
||||||
|
recipientCompanyId: m.recipientCompanyId?.toString ? m.recipientCompanyId.toString() : m.recipientCompanyId,
|
||||||
|
text: m.text.substring(0, 30)
|
||||||
|
}));
|
||||||
|
|
||||||
|
res.json({ totalCount: allMessages.length, messages: info });
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
175
server/routers/procurement/routes/products.js
Normal file
175
server/routers/procurement/routes/products.js
Normal file
@@ -0,0 +1,175 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const router = express.Router();
|
||||||
|
const { verifyToken } = require('../middleware/auth');
|
||||||
|
const Product = require('../models/Product');
|
||||||
|
|
||||||
|
// Функция для логирования с проверкой DEV переменной
|
||||||
|
const log = (message, data = '') => {
|
||||||
|
if (process.env.DEV === 'true') {
|
||||||
|
if (data) {
|
||||||
|
console.log(message, data);
|
||||||
|
} else {
|
||||||
|
console.log(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Helper to transform _id to id
|
||||||
|
const transformProduct = (doc) => {
|
||||||
|
if (!doc) return null;
|
||||||
|
const obj = doc.toObject ? doc.toObject() : doc;
|
||||||
|
return {
|
||||||
|
...obj,
|
||||||
|
id: obj._id,
|
||||||
|
_id: undefined
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// GET /products - Получить список продуктов/услуг компании (текущего пользователя)
|
||||||
|
router.get('/', verifyToken, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const companyId = req.companyId;
|
||||||
|
|
||||||
|
log('[Products] GET Fetching products for companyId:', companyId);
|
||||||
|
|
||||||
|
const products = await Product.find({ companyId })
|
||||||
|
.sort({ createdAt: -1 })
|
||||||
|
.exec();
|
||||||
|
|
||||||
|
log('[Products] Found', products.length, 'products');
|
||||||
|
res.json(products.map(transformProduct));
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[Products] Get error:', error.message);
|
||||||
|
res.status(500).json({ error: 'Internal server error', message: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /products - Создать продукт/услугу
|
||||||
|
router.post('/', verifyToken, async (req, res) => {
|
||||||
|
// try {
|
||||||
|
const { name, category, description, type, productUrl, price, unit, minOrder } = req.body;
|
||||||
|
const companyId = req.companyId;
|
||||||
|
|
||||||
|
log('[Products] POST Creating product:', { name, category, type });
|
||||||
|
|
||||||
|
// // Валидация
|
||||||
|
// if (!name || !category || !description || !type) {
|
||||||
|
// return res.status(400).json({ error: 'name, category, description, and type are required' });
|
||||||
|
// }
|
||||||
|
|
||||||
|
// if (description.length < 20) {
|
||||||
|
// return res.status(400).json({ error: 'Description must be at least 20 characters' });
|
||||||
|
// }
|
||||||
|
|
||||||
|
const newProduct = new Product({
|
||||||
|
name: name.trim(),
|
||||||
|
category: category.trim(),
|
||||||
|
description: description.trim(),
|
||||||
|
type,
|
||||||
|
productUrl: productUrl || '',
|
||||||
|
companyId,
|
||||||
|
price: price || '',
|
||||||
|
unit: unit || '',
|
||||||
|
minOrder: minOrder || ''
|
||||||
|
});
|
||||||
|
|
||||||
|
const savedProduct = await newProduct.save();
|
||||||
|
log('[Products] Product created with ID:', savedProduct._id);
|
||||||
|
|
||||||
|
res.status(201).json(transformProduct(savedProduct));
|
||||||
|
// } catch (error) {
|
||||||
|
// console.error('[Products] Create error:', error.message);
|
||||||
|
// res.status(500).json({ error: 'Internal server error', message: error.message });
|
||||||
|
// }
|
||||||
|
});
|
||||||
|
|
||||||
|
// PUT /products/:id - Обновить продукт/услугу
|
||||||
|
router.put('/:id', verifyToken, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
const updates = req.body;
|
||||||
|
const companyId = req.companyId;
|
||||||
|
|
||||||
|
const product = await Product.findById(id);
|
||||||
|
|
||||||
|
if (!product) {
|
||||||
|
return res.status(404).json({ error: 'Product not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Проверить, что продукт принадлежит текущей компании
|
||||||
|
if (product.companyId !== companyId) {
|
||||||
|
return res.status(403).json({ error: 'Not authorized' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatedProduct = await Product.findByIdAndUpdate(
|
||||||
|
id,
|
||||||
|
{ ...updates, updatedAt: new Date() },
|
||||||
|
{ new: true, runValidators: true }
|
||||||
|
);
|
||||||
|
|
||||||
|
log('[Products] Product updated:', id);
|
||||||
|
res.json(transformProduct(updatedProduct));
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[Products] Update error:', error.message);
|
||||||
|
res.status(500).json({ error: 'Internal server error', message: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// PATCH /products/:id - Частичное обновление продукта/услуги
|
||||||
|
router.patch('/:id', verifyToken, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
const updates = req.body;
|
||||||
|
const companyId = req.companyId;
|
||||||
|
|
||||||
|
const product = await Product.findById(id);
|
||||||
|
|
||||||
|
if (!product) {
|
||||||
|
return res.status(404).json({ error: 'Product not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (product.companyId !== companyId) {
|
||||||
|
return res.status(403).json({ error: 'Not authorized' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatedProduct = await Product.findByIdAndUpdate(
|
||||||
|
id,
|
||||||
|
{ ...updates, updatedAt: new Date() },
|
||||||
|
{ new: true, runValidators: true }
|
||||||
|
);
|
||||||
|
|
||||||
|
log('[Products] Product patched:', id);
|
||||||
|
res.json(transformProduct(updatedProduct));
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[Products] Patch error:', error.message);
|
||||||
|
res.status(500).json({ error: 'Internal server error', message: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// DELETE /products/:id - Удалить продукт/услугу
|
||||||
|
router.delete('/:id', verifyToken, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
const companyId = req.companyId;
|
||||||
|
|
||||||
|
const product = await Product.findById(id);
|
||||||
|
|
||||||
|
if (!product) {
|
||||||
|
return res.status(404).json({ error: 'Product not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (product.companyId !== companyId) {
|
||||||
|
return res.status(403).json({ error: 'Not authorized' });
|
||||||
|
}
|
||||||
|
|
||||||
|
await Product.findByIdAndDelete(id);
|
||||||
|
|
||||||
|
log('[Products] Product deleted:', id);
|
||||||
|
res.json({ message: 'Product deleted successfully' });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[Products] Delete error:', error.message);
|
||||||
|
res.status(500).json({ error: 'Internal server error', message: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
563
server/routers/procurement/routes/requests.js
Normal file
563
server/routers/procurement/routes/requests.js
Normal file
@@ -0,0 +1,563 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const router = express.Router();
|
||||||
|
const { verifyToken } = require('../middleware/auth');
|
||||||
|
const Request = require('../models/Request');
|
||||||
|
const BuyProduct = require('../models/BuyProduct');
|
||||||
|
const path = require('path');
|
||||||
|
const fs = require('fs');
|
||||||
|
const multer = require('multer');
|
||||||
|
const mongoose = require('../../../utils/mongoose');
|
||||||
|
|
||||||
|
// Функция для логирования с проверкой DEV переменной
|
||||||
|
const log = (message, data = '') => {
|
||||||
|
if (process.env.DEV === 'true') {
|
||||||
|
if (data) {
|
||||||
|
console.log(message, data);
|
||||||
|
} else {
|
||||||
|
console.log(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const REQUESTS_UPLOAD_ROOT = 'server/routers/remote-assets/uploads/requests';
|
||||||
|
|
||||||
|
const ensureDirectory = (dirPath) => {
|
||||||
|
if (!fs.existsSync(dirPath)) {
|
||||||
|
fs.mkdirSync(dirPath, { recursive: true });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
ensureDirectory(REQUESTS_UPLOAD_ROOT);
|
||||||
|
|
||||||
|
const MAX_REQUEST_FILE_SIZE = 20 * 1024 * 1024; // 20MB
|
||||||
|
const ALLOWED_REQUEST_MIME_TYPES = new Set([
|
||||||
|
'application/pdf',
|
||||||
|
'application/msword',
|
||||||
|
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||||
|
'application/vnd.ms-excel',
|
||||||
|
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||||
|
'text/csv',
|
||||||
|
]);
|
||||||
|
|
||||||
|
const storage = multer.diskStorage({
|
||||||
|
destination: (req, file, cb) => {
|
||||||
|
const subfolder = req.requestUploadSubfolder || '';
|
||||||
|
const destinationDir = `${REQUESTS_UPLOAD_ROOT}/${subfolder}`;
|
||||||
|
ensureDirectory(destinationDir);
|
||||||
|
cb(null, destinationDir);
|
||||||
|
},
|
||||||
|
filename: (req, file, cb) => {
|
||||||
|
const extension = path.extname(file.originalname) || '';
|
||||||
|
const baseName = path
|
||||||
|
.basename(file.originalname, extension)
|
||||||
|
.replace(/[^a-zA-Z0-9-_]+/g, '_')
|
||||||
|
.toLowerCase();
|
||||||
|
cb(null, `${Date.now()}_${baseName}${extension}`);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const upload = multer({
|
||||||
|
storage,
|
||||||
|
limits: {
|
||||||
|
fileSize: MAX_REQUEST_FILE_SIZE,
|
||||||
|
},
|
||||||
|
fileFilter: (req, file, cb) => {
|
||||||
|
if (ALLOWED_REQUEST_MIME_TYPES.has(file.mimetype)) {
|
||||||
|
cb(null, true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!req.invalidFiles) {
|
||||||
|
req.invalidFiles = [];
|
||||||
|
}
|
||||||
|
req.invalidFiles.push(file.originalname);
|
||||||
|
cb(null, false);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleFilesUpload = (fieldName, subfolderResolver, maxCount = 10) => (req, res, next) => {
|
||||||
|
req.invalidFiles = [];
|
||||||
|
req.requestUploadSubfolder = subfolderResolver(req);
|
||||||
|
|
||||||
|
upload.array(fieldName, maxCount)(req, res, (err) => {
|
||||||
|
if (err) {
|
||||||
|
console.error('[Requests] Multer error:', err.message);
|
||||||
|
if (err.code === 'LIMIT_FILE_SIZE') {
|
||||||
|
return res.status(400).json({ error: 'File is too large. Maximum size is 20MB.' });
|
||||||
|
}
|
||||||
|
return res.status(400).json({ error: err.message });
|
||||||
|
}
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const cleanupUploadedFiles = async (req) => {
|
||||||
|
if (!Array.isArray(req.files) || req.files.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const subfolder = req.requestUploadSubfolder || '';
|
||||||
|
const removalTasks = req.files.map((file) => {
|
||||||
|
const filePath = `${REQUESTS_UPLOAD_ROOT}/${subfolder}/${file.filename}`;
|
||||||
|
return fs.promises.unlink(filePath).catch((error) => {
|
||||||
|
if (error.code !== 'ENOENT') {
|
||||||
|
console.error('[Requests] Failed to cleanup uploaded file:', error.message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await Promise.all(removalTasks);
|
||||||
|
};
|
||||||
|
|
||||||
|
const mapFilesToMetadata = (req) => {
|
||||||
|
if (!Array.isArray(req.files) || req.files.length === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const subfolder = req.requestUploadSubfolder || '';
|
||||||
|
return req.files.map((file) => {
|
||||||
|
const relativePath = `requests/${subfolder}/${file.filename}`;
|
||||||
|
return {
|
||||||
|
id: `file-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
||||||
|
name: file.originalname,
|
||||||
|
url: `/uploads/${relativePath}`,
|
||||||
|
type: file.mimetype,
|
||||||
|
size: file.size,
|
||||||
|
uploadedAt: new Date(),
|
||||||
|
storagePath: relativePath,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const normalizeToArray = (value) => {
|
||||||
|
if (!value) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(value);
|
||||||
|
if (Array.isArray(parsed)) {
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// ignore JSON parse errors
|
||||||
|
}
|
||||||
|
|
||||||
|
return String(value)
|
||||||
|
.split(',')
|
||||||
|
.map((item) => item.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeStoredFiles = async (files = []) => {
|
||||||
|
if (!files || files.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tasks = files
|
||||||
|
.filter((file) => file && file.storagePath)
|
||||||
|
.map((file) => {
|
||||||
|
const absolutePath = `server/routers/remote-assets/uploads/${file.storagePath}`;
|
||||||
|
return fs.promises.unlink(absolutePath).catch((error) => {
|
||||||
|
if (error.code !== 'ENOENT') {
|
||||||
|
console.error('[Requests] Failed to remove stored file:', error.message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await Promise.all(tasks);
|
||||||
|
};
|
||||||
|
|
||||||
|
// GET /requests/sent - получить отправленные запросы
|
||||||
|
router.get('/sent', verifyToken, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const companyId = req.companyId;
|
||||||
|
|
||||||
|
if (!companyId) {
|
||||||
|
return res.status(400).json({ error: 'Company ID is required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const requests = await Request.find({ senderCompanyId: companyId })
|
||||||
|
.sort({ createdAt: -1 })
|
||||||
|
.exec();
|
||||||
|
|
||||||
|
log('[Requests] Returned', requests.length, 'sent requests for company', companyId);
|
||||||
|
|
||||||
|
res.json(requests);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[Requests] Error fetching sent requests:', error.message);
|
||||||
|
res.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /requests/received - получить полученные запросы
|
||||||
|
router.get('/received', verifyToken, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const companyId = req.companyId;
|
||||||
|
|
||||||
|
if (!companyId) {
|
||||||
|
return res.status(400).json({ error: 'Company ID is required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const requests = await Request.find({ recipientCompanyId: companyId })
|
||||||
|
.sort({ createdAt: -1 })
|
||||||
|
.exec();
|
||||||
|
|
||||||
|
log('[Requests] Returned', requests.length, 'received requests for company', companyId);
|
||||||
|
|
||||||
|
res.json(requests);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[Requests] Error fetching received requests:', error.message);
|
||||||
|
res.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /requests - создать запрос
|
||||||
|
router.post(
|
||||||
|
'/',
|
||||||
|
verifyToken,
|
||||||
|
handleFilesUpload('files', (req) => `sent/${(req.companyId || 'unknown').toString()}`, 10),
|
||||||
|
async (req, res) => {
|
||||||
|
try {
|
||||||
|
const senderCompanyId = req.companyId;
|
||||||
|
const recipients = normalizeToArray(req.body.recipientCompanyIds);
|
||||||
|
const text = (req.body.text || '').trim();
|
||||||
|
const productId = req.body.productId ? String(req.body.productId) : null;
|
||||||
|
let subject = (req.body.subject || '').trim();
|
||||||
|
|
||||||
|
if (req.invalidFiles && req.invalidFiles.length > 0) {
|
||||||
|
await cleanupUploadedFiles(req);
|
||||||
|
return res.status(400).json({
|
||||||
|
error: 'Unsupported file type. Allowed formats: PDF, DOC, DOCX, XLS, XLSX, CSV.',
|
||||||
|
details: req.invalidFiles,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!text) {
|
||||||
|
await cleanupUploadedFiles(req);
|
||||||
|
return res.status(400).json({ error: 'Request text is required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!recipients.length) {
|
||||||
|
await cleanupUploadedFiles(req);
|
||||||
|
return res.status(400).json({ error: 'At least one recipient is required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
let uploadedFiles = mapFilesToMetadata(req);
|
||||||
|
|
||||||
|
console.log('========================');
|
||||||
|
console.log('[Requests] Initial uploadedFiles:', uploadedFiles.length);
|
||||||
|
console.log('[Requests] ProductId:', productId);
|
||||||
|
|
||||||
|
// Если есть productId, получаем данные товара
|
||||||
|
if (productId) {
|
||||||
|
try {
|
||||||
|
const product = await BuyProduct.findById(productId);
|
||||||
|
console.log('[Requests] Product found:', product ? product.name : 'null');
|
||||||
|
console.log('[Requests] Product files count:', product?.files?.length || 0);
|
||||||
|
if (product && product.files) {
|
||||||
|
console.log('[Requests] Product files:', JSON.stringify(product.files, null, 2));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (product) {
|
||||||
|
// Берем subject из товара, если не указан
|
||||||
|
if (!subject) {
|
||||||
|
subject = product.name;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Если файлы не загружены вручную, используем файлы из товара
|
||||||
|
if (uploadedFiles.length === 0 && product.files && product.files.length > 0) {
|
||||||
|
console.log('[Requests] ✅ Copying files from product...');
|
||||||
|
// Копируем файлы из товара, изменяя путь для запроса
|
||||||
|
uploadedFiles = product.files.map(file => ({
|
||||||
|
id: file.id || `file-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
||||||
|
name: file.name,
|
||||||
|
url: file.url,
|
||||||
|
type: file.type,
|
||||||
|
size: file.size,
|
||||||
|
uploadedAt: file.uploadedAt || new Date(),
|
||||||
|
storagePath: file.storagePath || file.url.replace('/uploads/', ''),
|
||||||
|
}));
|
||||||
|
console.log('[Requests] ✅ Using', uploadedFiles.length, 'files from product:', productId);
|
||||||
|
console.log('[Requests] ✅ Copied files:', JSON.stringify(uploadedFiles, null, 2));
|
||||||
|
} else {
|
||||||
|
console.log('[Requests] ❌ NOT copying files. uploadedFiles.length:', uploadedFiles.length, 'product.files.length:', product.files?.length || 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (lookupError) {
|
||||||
|
console.error('[Requests] ❌ Failed to lookup product:', lookupError.message);
|
||||||
|
console.error(lookupError.stack);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('[Requests] Final uploadedFiles for saving:', JSON.stringify(uploadedFiles, null, 2));
|
||||||
|
console.log('========================');
|
||||||
|
|
||||||
|
if (!subject) {
|
||||||
|
await cleanupUploadedFiles(req);
|
||||||
|
return res.status(400).json({ error: 'Subject is required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const results = [];
|
||||||
|
for (const recipientCompanyId of recipients) {
|
||||||
|
try {
|
||||||
|
const request = new Request({
|
||||||
|
senderCompanyId,
|
||||||
|
recipientCompanyId,
|
||||||
|
text,
|
||||||
|
productId,
|
||||||
|
subject,
|
||||||
|
files: uploadedFiles,
|
||||||
|
responseFiles: [],
|
||||||
|
status: 'pending',
|
||||||
|
});
|
||||||
|
|
||||||
|
await request.save();
|
||||||
|
results.push({
|
||||||
|
companyId: recipientCompanyId,
|
||||||
|
success: true,
|
||||||
|
message: 'Request sent successfully',
|
||||||
|
});
|
||||||
|
|
||||||
|
log('[Requests] Request sent to company:', recipientCompanyId);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[Requests] Error storing request for company:', recipientCompanyId, err.message);
|
||||||
|
results.push({
|
||||||
|
companyId: recipientCompanyId,
|
||||||
|
success: false,
|
||||||
|
message: err.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const createdAt = new Date();
|
||||||
|
|
||||||
|
res.status(201).json({
|
||||||
|
id: 'bulk-' + Date.now(),
|
||||||
|
text,
|
||||||
|
subject,
|
||||||
|
productId,
|
||||||
|
files: uploadedFiles,
|
||||||
|
result: results,
|
||||||
|
createdAt,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[Requests] Error creating request:', error.message);
|
||||||
|
res.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// PUT /requests/:id - ответить на запрос
|
||||||
|
router.put(
|
||||||
|
'/:id',
|
||||||
|
verifyToken,
|
||||||
|
handleFilesUpload('responseFiles', (req) => `responses/${req.params.id || 'unknown'}`, 5),
|
||||||
|
async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
console.log('[Requests] PUT /requests/:id called with id:', id);
|
||||||
|
console.log('[Requests] Request body:', req.body);
|
||||||
|
console.log('[Requests] Files:', req.files);
|
||||||
|
console.log('[Requests] CompanyId:', req.companyId);
|
||||||
|
|
||||||
|
const responseText = (req.body.response || '').trim();
|
||||||
|
const statusRaw = (req.body.status || 'accepted').toLowerCase();
|
||||||
|
const status = statusRaw === 'rejected' ? 'rejected' : 'accepted';
|
||||||
|
|
||||||
|
console.log('[Requests] Response text:', responseText);
|
||||||
|
console.log('[Requests] Status:', status);
|
||||||
|
|
||||||
|
if (req.invalidFiles && req.invalidFiles.length > 0) {
|
||||||
|
await cleanupUploadedFiles(req);
|
||||||
|
return res.status(400).json({
|
||||||
|
error: 'Unsupported file type. Allowed formats: PDF, DOC, DOCX, XLS, XLSX, CSV.',
|
||||||
|
details: req.invalidFiles,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!responseText) {
|
||||||
|
await cleanupUploadedFiles(req);
|
||||||
|
return res.status(400).json({ error: 'Response text is required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const request = await Request.findById(id);
|
||||||
|
|
||||||
|
if (!request) {
|
||||||
|
await cleanupUploadedFiles(req);
|
||||||
|
return res.status(404).json({ error: 'Request not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.recipientCompanyId !== req.companyId) {
|
||||||
|
await cleanupUploadedFiles(req);
|
||||||
|
return res.status(403).json({ error: 'Not authorized' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const uploadedResponseFiles = mapFilesToMetadata(req);
|
||||||
|
console.log('[Requests] Uploaded response files count:', uploadedResponseFiles.length);
|
||||||
|
console.log('[Requests] Uploaded response files:', JSON.stringify(uploadedResponseFiles, null, 2));
|
||||||
|
|
||||||
|
if (uploadedResponseFiles.length > 0) {
|
||||||
|
await removeStoredFiles(request.responseFiles || []);
|
||||||
|
request.responseFiles = uploadedResponseFiles;
|
||||||
|
}
|
||||||
|
|
||||||
|
request.response = responseText;
|
||||||
|
request.status = status;
|
||||||
|
request.respondedAt = new Date();
|
||||||
|
request.updatedAt = new Date();
|
||||||
|
|
||||||
|
let savedRequest;
|
||||||
|
try {
|
||||||
|
savedRequest = await request.save();
|
||||||
|
log('[Requests] Request responded:', id);
|
||||||
|
} catch (saveError) {
|
||||||
|
console.error('[Requests] Mongoose save failed, trying direct MongoDB update:', saveError.message);
|
||||||
|
// Fallback: использовать MongoDB драйвер напрямую
|
||||||
|
const updateData = {
|
||||||
|
response: responseText,
|
||||||
|
status: status,
|
||||||
|
respondedAt: new Date(),
|
||||||
|
updatedAt: new Date()
|
||||||
|
};
|
||||||
|
if (uploadedResponseFiles.length > 0) {
|
||||||
|
updateData.responseFiles = uploadedResponseFiles;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await mongoose.connection.collection('requests').findOneAndUpdate(
|
||||||
|
{ _id: new mongoose.Types.ObjectId(id) },
|
||||||
|
{ $set: updateData },
|
||||||
|
{ returnDocument: 'after' }
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!result) {
|
||||||
|
throw new Error('Failed to update request');
|
||||||
|
}
|
||||||
|
savedRequest = result;
|
||||||
|
log('[Requests] Request responded via direct MongoDB update:', id);
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json(savedRequest);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[Requests] Error responding to request:', error.message);
|
||||||
|
console.error('[Requests] Error stack:', error.stack);
|
||||||
|
if (error.name === 'ValidationError') {
|
||||||
|
console.error('[Requests] Validation errors:', JSON.stringify(error.errors, null, 2));
|
||||||
|
}
|
||||||
|
res.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// GET /requests/download/:id/:fileId - скачать файл ответа
|
||||||
|
router.get('/download/:id/:fileId', verifyToken, async (req, res) => {
|
||||||
|
try {
|
||||||
|
console.log('[Requests] Download request received:', {
|
||||||
|
requestId: req.params.id,
|
||||||
|
fileId: req.params.fileId,
|
||||||
|
userId: req.userId,
|
||||||
|
companyId: req.companyId,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { id, fileId } = req.params;
|
||||||
|
const request = await Request.findById(id);
|
||||||
|
|
||||||
|
if (!request) {
|
||||||
|
return res.status(404).json({ error: 'Request not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Проверяем, что пользователь имеет доступ к запросу (отправитель или получатель)
|
||||||
|
if (request.senderCompanyId !== req.companyId && request.recipientCompanyId !== req.companyId) {
|
||||||
|
return res.status(403).json({ error: 'Not authorized' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ищем файл в responseFiles или в обычных files
|
||||||
|
let file = request.responseFiles?.find((f) => f.id === fileId);
|
||||||
|
if (!file) {
|
||||||
|
file = request.files?.find((f) => f.id === fileId);
|
||||||
|
}
|
||||||
|
if (!file) {
|
||||||
|
return res.status(404).json({ error: 'File not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Создаем абсолютный путь к файлу
|
||||||
|
// Если storagePath не начинается с 'requests/', значит это файл из buy-products
|
||||||
|
let fullPath = file.storagePath;
|
||||||
|
if (!fullPath.startsWith('requests/')) {
|
||||||
|
fullPath = `buy-products/${fullPath}`;
|
||||||
|
}
|
||||||
|
const filePath = path.resolve(`server/routers/remote-assets/uploads/${fullPath}`);
|
||||||
|
|
||||||
|
console.log('[Requests] Trying to download file:', {
|
||||||
|
fileId: file.id,
|
||||||
|
fileName: file.name,
|
||||||
|
storagePath: file.storagePath,
|
||||||
|
absolutePath: filePath,
|
||||||
|
exists: fs.existsSync(filePath),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Проверяем существование файла
|
||||||
|
if (!fs.existsSync(filePath)) {
|
||||||
|
console.error('[Requests] File not found on disk:', filePath);
|
||||||
|
return res.status(404).json({ error: 'File not found on disk' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Устанавливаем правильные заголовки для скачивания с поддержкой кириллицы
|
||||||
|
const encodedFileName = encodeURIComponent(file.name);
|
||||||
|
res.setHeader('Content-Type', file.type || 'application/octet-stream');
|
||||||
|
res.setHeader('Content-Disposition', `attachment; filename*=UTF-8''${encodedFileName}`);
|
||||||
|
res.setHeader('Content-Length', file.size);
|
||||||
|
|
||||||
|
// Отправляем файл
|
||||||
|
res.sendFile(filePath, (err) => {
|
||||||
|
if (err) {
|
||||||
|
console.error('[Requests] Error sending file:', err.message);
|
||||||
|
if (!res.headersSent) {
|
||||||
|
res.status(500).json({ error: 'Error sending file' });
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
log('[Requests] File downloaded:', file.name);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[Requests] Error downloading file:', error.message);
|
||||||
|
if (!res.headersSent) {
|
||||||
|
res.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// DELETE /requests/:id - удалить запрос
|
||||||
|
router.delete('/:id', verifyToken, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
|
||||||
|
const request = await Request.findById(id);
|
||||||
|
|
||||||
|
if (!request) {
|
||||||
|
return res.status(404).json({ error: 'Request not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Может удалить отправитель или получатель
|
||||||
|
if (request.senderCompanyId !== req.companyId && request.recipientCompanyId !== req.companyId) {
|
||||||
|
return res.status(403).json({ error: 'Not authorized' });
|
||||||
|
}
|
||||||
|
|
||||||
|
await removeStoredFiles(request.files || []);
|
||||||
|
await removeStoredFiles(request.responseFiles || []);
|
||||||
|
|
||||||
|
await Request.findByIdAndDelete(id);
|
||||||
|
|
||||||
|
log('[Requests] Request deleted:', id);
|
||||||
|
|
||||||
|
res.json({ message: 'Request deleted successfully' });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[Requests] Error deleting request:', error.message);
|
||||||
|
res.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
145
server/routers/procurement/routes/reviews.js
Normal file
145
server/routers/procurement/routes/reviews.js
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const router = express.Router();
|
||||||
|
const { verifyToken } = require('../middleware/auth');
|
||||||
|
const Review = require('../models/Review');
|
||||||
|
const Company = require('../models/Company');
|
||||||
|
|
||||||
|
// Функция для логирования с проверкой DEV переменной
|
||||||
|
const log = (message, data = '') => {
|
||||||
|
if (process.env.DEV === 'true') {
|
||||||
|
if (data) {
|
||||||
|
console.log(message, data);
|
||||||
|
} else {
|
||||||
|
console.log(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Функция для пересчета рейтинга компании
|
||||||
|
const updateCompanyRating = async (companyId) => {
|
||||||
|
try {
|
||||||
|
const reviews = await Review.find({ companyId });
|
||||||
|
|
||||||
|
if (reviews.length === 0) {
|
||||||
|
await Company.findByIdAndUpdate(companyId, {
|
||||||
|
rating: 0,
|
||||||
|
reviews: 0,
|
||||||
|
updatedAt: new Date()
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalRating = reviews.reduce((sum, review) => sum + review.rating, 0);
|
||||||
|
const averageRating = totalRating / reviews.length;
|
||||||
|
|
||||||
|
await Company.findByIdAndUpdate(companyId, {
|
||||||
|
rating: averageRating,
|
||||||
|
reviews: reviews.length,
|
||||||
|
updatedAt: new Date()
|
||||||
|
});
|
||||||
|
|
||||||
|
log('[Reviews] Updated company rating:', companyId, 'New rating:', averageRating);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[Reviews] Error updating company rating:', error.message);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// GET /reviews/company/:companyId - получить отзывы компании
|
||||||
|
router.get('/company/:companyId', verifyToken, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { companyId } = req.params;
|
||||||
|
|
||||||
|
const companyReviews = await Review.find({ companyId })
|
||||||
|
.sort({ createdAt: -1 })
|
||||||
|
.exec();
|
||||||
|
|
||||||
|
log('[Reviews] Returned', companyReviews.length, 'reviews for company', companyId);
|
||||||
|
|
||||||
|
res.json(companyReviews);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[Reviews] Error fetching reviews:', error.message);
|
||||||
|
res.status(500).json({
|
||||||
|
error: 'Internal server error',
|
||||||
|
message: error.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /reviews - создать новый отзыв
|
||||||
|
router.post('/', verifyToken, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { companyId, rating, comment } = req.body;
|
||||||
|
|
||||||
|
if (!companyId || !rating || !comment) {
|
||||||
|
return res.status(400).json({
|
||||||
|
error: 'Заполните все обязательные поля: компания, рейтинг и комментарий',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rating < 1 || rating > 5) {
|
||||||
|
return res.status(400).json({
|
||||||
|
error: 'Рейтинг должен быть от 1 до 5',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const trimmedComment = comment.trim();
|
||||||
|
if (trimmedComment.length < 10) {
|
||||||
|
return res.status(400).json({
|
||||||
|
error: 'Отзыв должен содержать минимум 10 символов',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (trimmedComment.length > 1000) {
|
||||||
|
return res.status(400).json({
|
||||||
|
error: 'Отзыв не должен превышать 1000 символов',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Получить данные пользователя из БД для актуальной информации
|
||||||
|
const User = require('../models/User');
|
||||||
|
const Company = require('../models/Company');
|
||||||
|
|
||||||
|
const user = await User.findById(req.userId);
|
||||||
|
const userCompany = user && user.companyId ? await Company.findById(user.companyId) : null;
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return res.status(404).json({
|
||||||
|
error: 'Пользователь не найден',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Создать новый отзыв
|
||||||
|
const newReview = new Review({
|
||||||
|
companyId,
|
||||||
|
authorCompanyId: user.companyId || req.companyId,
|
||||||
|
authorName: user.firstName && user.lastName
|
||||||
|
? `${user.firstName} ${user.lastName}`
|
||||||
|
: req.user?.firstName && req.user?.lastName
|
||||||
|
? `${req.user.firstName} ${req.user.lastName}`
|
||||||
|
: 'Аноним',
|
||||||
|
authorCompany: userCompany?.fullName || userCompany?.shortName || req.user?.companyName || 'Компания',
|
||||||
|
rating: parseInt(rating),
|
||||||
|
comment: trimmedComment,
|
||||||
|
verified: true,
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date()
|
||||||
|
});
|
||||||
|
|
||||||
|
const savedReview = await newReview.save();
|
||||||
|
|
||||||
|
log('[Reviews] New review created:', savedReview._id);
|
||||||
|
|
||||||
|
// Пересчитываем рейтинг компании
|
||||||
|
await updateCompanyRating(companyId);
|
||||||
|
|
||||||
|
res.status(201).json(savedReview);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[Reviews] Error creating review:', error.message);
|
||||||
|
res.status(500).json({
|
||||||
|
error: 'Ошибка при сохранении отзыва',
|
||||||
|
message: error.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
337
server/routers/procurement/routes/search.js
Normal file
337
server/routers/procurement/routes/search.js
Normal file
@@ -0,0 +1,337 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const router = express.Router();
|
||||||
|
const { verifyToken } = require('../middleware/auth');
|
||||||
|
const Company = require('../models/Company');
|
||||||
|
|
||||||
|
// Функция для логирования с проверкой DEV переменной
|
||||||
|
const log = (message, data = '') => {
|
||||||
|
if (process.env.DEV === 'true') {
|
||||||
|
if (data) {
|
||||||
|
console.log(message, data);
|
||||||
|
} else {
|
||||||
|
console.log(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// GET /search/recommendations - получить рекомендации компаний (ДОЛЖЕН быть ПЕРЕД /*)
|
||||||
|
router.get('/recommendations', verifyToken, async (req, res) => {
|
||||||
|
try {
|
||||||
|
// Получить компанию пользователя, чтобы исключить её из результатов
|
||||||
|
const User = require('../models/User');
|
||||||
|
const user = await User.findById(req.userId);
|
||||||
|
|
||||||
|
let filter = {};
|
||||||
|
if (user && user.companyId) {
|
||||||
|
filter._id = { $ne: user.companyId };
|
||||||
|
}
|
||||||
|
|
||||||
|
const companies = await Company.find(filter)
|
||||||
|
.sort({ rating: -1 })
|
||||||
|
.limit(5);
|
||||||
|
|
||||||
|
const recommendations = companies.map(company => ({
|
||||||
|
id: company._id.toString(),
|
||||||
|
name: company.fullName || company.shortName,
|
||||||
|
industry: company.industry,
|
||||||
|
logo: company.logo,
|
||||||
|
matchScore: Math.floor(Math.random() * 30 + 70), // 70-100
|
||||||
|
reason: 'Matches your search criteria'
|
||||||
|
}));
|
||||||
|
|
||||||
|
log('[Search] Returned recommendations:', recommendations.length);
|
||||||
|
|
||||||
|
res.json(recommendations);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[Search] Recommendations error:', error.message);
|
||||||
|
res.status(500).json({
|
||||||
|
error: 'Internal server error',
|
||||||
|
message: error.message
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /search - Поиск компаний
|
||||||
|
router.get('/', verifyToken, async (req, res) => {
|
||||||
|
try {
|
||||||
|
console.log('[Search] === NEW VERSION WITH FIXED SIZE FILTER ===');
|
||||||
|
|
||||||
|
const {
|
||||||
|
query = '',
|
||||||
|
page = 1,
|
||||||
|
limit = 10,
|
||||||
|
offset, // Добавляем поддержку offset для точной пагинации
|
||||||
|
industries,
|
||||||
|
companySize,
|
||||||
|
geography,
|
||||||
|
minRating = 0,
|
||||||
|
hasReviews,
|
||||||
|
hasAcceptedDocs,
|
||||||
|
sortBy = 'relevance',
|
||||||
|
sortOrder = 'desc',
|
||||||
|
minEmployees, // Кастомный фильтр: минимум сотрудников
|
||||||
|
maxEmployees // Кастомный фильтр: максимум сотрудников
|
||||||
|
} = req.query;
|
||||||
|
|
||||||
|
console.log('[Search] Filters:', { minEmployees, maxEmployees, companySize });
|
||||||
|
|
||||||
|
// Получить компанию пользователя, чтобы исключить её из результатов
|
||||||
|
const User = require('../models/User');
|
||||||
|
const user = await User.findById(req.userId);
|
||||||
|
|
||||||
|
log('[Search] Request params:', { query, industries, companySize, geography, minRating, hasReviews, hasAcceptedDocs, sortBy, sortOrder });
|
||||||
|
|
||||||
|
// Маппинг кодов фильтров на значения в БД
|
||||||
|
const industryMap = {
|
||||||
|
'it': 'IT',
|
||||||
|
'finance': 'Финансы',
|
||||||
|
'manufacturing': 'Производство',
|
||||||
|
'construction': 'Строительство',
|
||||||
|
'retail': 'Розничная торговля',
|
||||||
|
'wholesale': 'Оптовая торговля',
|
||||||
|
'logistics': 'Логистика',
|
||||||
|
'healthcare': 'Здравоохранение',
|
||||||
|
'education': 'Образование',
|
||||||
|
'consulting': 'Консалтинг',
|
||||||
|
'marketing': 'Маркетинг',
|
||||||
|
'realestate': 'Недвижимость',
|
||||||
|
'food': 'Пищевая промышленность',
|
||||||
|
'agriculture': 'Сельское хозяйство',
|
||||||
|
'energy': 'Энергетика',
|
||||||
|
'telecom': 'Телекоммуникации',
|
||||||
|
'media': 'Медиа'
|
||||||
|
};
|
||||||
|
|
||||||
|
// Начальный фильтр: исключить собственную компанию
|
||||||
|
let filters = [];
|
||||||
|
|
||||||
|
if (user && user.companyId) {
|
||||||
|
filters.push({ _id: { $ne: user.companyId } });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Текстовый поиск
|
||||||
|
if (query && query.trim()) {
|
||||||
|
const q = query.toLowerCase();
|
||||||
|
filters.push({
|
||||||
|
$or: [
|
||||||
|
{ fullName: { $regex: q, $options: 'i' } },
|
||||||
|
{ shortName: { $regex: q, $options: 'i' } },
|
||||||
|
{ slogan: { $regex: q, $options: 'i' } },
|
||||||
|
{ industry: { $regex: q, $options: 'i' } }
|
||||||
|
]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Фильтр по отраслям - преобразуем коды в значения БД
|
||||||
|
if (industries) {
|
||||||
|
const industryList = Array.isArray(industries) ? industries : [industries];
|
||||||
|
if (industryList.length > 0) {
|
||||||
|
const dbIndustries = industryList
|
||||||
|
.map(code => industryMap[code])
|
||||||
|
.filter(val => val !== undefined);
|
||||||
|
|
||||||
|
log('[Search] Raw industries param:', industries);
|
||||||
|
log('[Search] Industry codes:', industryList, 'Mapped to:', dbIndustries);
|
||||||
|
|
||||||
|
if (dbIndustries.length > 0) {
|
||||||
|
filters.push({ industry: { $in: dbIndustries } });
|
||||||
|
log('[Search] Added industry filter:', { industry: { $in: dbIndustries } });
|
||||||
|
} else {
|
||||||
|
log('[Search] No industries mapped! Codes were:', industryList);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Функция для парсинга диапазона из строки вида "51-250" или "500+"
|
||||||
|
const parseEmployeeRange = (sizeStr) => {
|
||||||
|
if (sizeStr.includes('+')) {
|
||||||
|
const min = parseInt(sizeStr.replace('+', ''));
|
||||||
|
return { min, max: Infinity };
|
||||||
|
}
|
||||||
|
const parts = sizeStr.split('-');
|
||||||
|
return {
|
||||||
|
min: parseInt(parts[0]),
|
||||||
|
max: parts[1] ? parseInt(parts[1]) : parseInt(parts[0])
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// Функция для проверки пересечения двух диапазонов
|
||||||
|
const rangesOverlap = (range1, range2) => {
|
||||||
|
return range1.min <= range2.max && range1.max >= range2.min;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Фильтр по размеру компании (чекбоксы) или кастомный диапазон
|
||||||
|
// Важно: этот фильтр должен получить все компании для корректной работы пересечения диапазонов
|
||||||
|
let sizeFilteredIds = null;
|
||||||
|
if ((companySize && companySize.length > 0) || minEmployees || maxEmployees) {
|
||||||
|
// Получаем все компании (без других фильтров, так как размер компании - это property-based фильтр)
|
||||||
|
const allCompanies = await Company.find({});
|
||||||
|
|
||||||
|
log('[Search] Employee size filter - checking companies:', allCompanies.length);
|
||||||
|
|
||||||
|
let matchingIds = [];
|
||||||
|
|
||||||
|
// Если есть кастомный диапазон - используем его
|
||||||
|
if (minEmployees || maxEmployees) {
|
||||||
|
const customRange = {
|
||||||
|
min: minEmployees ? parseInt(minEmployees, 10) : 0,
|
||||||
|
max: maxEmployees ? parseInt(maxEmployees, 10) : Infinity
|
||||||
|
};
|
||||||
|
|
||||||
|
log('[Search] Custom employee range filter:', customRange);
|
||||||
|
|
||||||
|
matchingIds = allCompanies
|
||||||
|
.filter(company => {
|
||||||
|
if (!company.companySize) {
|
||||||
|
log('[Search] Company has no size:', company.fullName);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const companyRange = parseEmployeeRange(company.companySize);
|
||||||
|
const overlaps = rangesOverlap(companyRange, customRange);
|
||||||
|
|
||||||
|
log('[Search] Checking overlap:', {
|
||||||
|
company: company.fullName,
|
||||||
|
companyRange,
|
||||||
|
customRange,
|
||||||
|
overlaps
|
||||||
|
});
|
||||||
|
|
||||||
|
return overlaps;
|
||||||
|
})
|
||||||
|
.map(c => c._id);
|
||||||
|
|
||||||
|
log('[Search] Matching companies by custom range:', matchingIds.length);
|
||||||
|
}
|
||||||
|
// Иначе используем чекбоксы
|
||||||
|
else if (companySize && companySize.length > 0) {
|
||||||
|
const sizeList = Array.isArray(companySize) ? companySize : [companySize];
|
||||||
|
|
||||||
|
log('[Search] Company size checkboxes filter:', sizeList);
|
||||||
|
|
||||||
|
matchingIds = allCompanies
|
||||||
|
.filter(company => {
|
||||||
|
if (!company.companySize) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const companyRange = parseEmployeeRange(company.companySize);
|
||||||
|
|
||||||
|
// Проверяем пересечение с любым из выбранных диапазонов
|
||||||
|
const matches = sizeList.some(selectedSize => {
|
||||||
|
const filterRange = parseEmployeeRange(selectedSize);
|
||||||
|
const overlaps = rangesOverlap(companyRange, filterRange);
|
||||||
|
log('[Search] Check:', company.fullName, companyRange, 'vs', filterRange, '=', overlaps);
|
||||||
|
return overlaps;
|
||||||
|
});
|
||||||
|
|
||||||
|
return matches;
|
||||||
|
})
|
||||||
|
.map(c => c._id);
|
||||||
|
|
||||||
|
log('[Search] Matching companies by size checkboxes:', matchingIds.length);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Сохраняем ID для дальнейшей фильтрации
|
||||||
|
sizeFilteredIds = matchingIds;
|
||||||
|
log('[Search] Size filtered IDs count:', sizeFilteredIds.length);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Фильтр по географии
|
||||||
|
if (geography) {
|
||||||
|
const geoList = Array.isArray(geography) ? geography : [geography];
|
||||||
|
if (geoList.length > 0) {
|
||||||
|
filters.push({ partnerGeography: { $in: geoList } });
|
||||||
|
log('[Search] Geography filter:', { partnerGeography: { $in: geoList } });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Фильтр по рейтингу
|
||||||
|
if (minRating) {
|
||||||
|
const rating = parseFloat(minRating);
|
||||||
|
if (rating > 0) {
|
||||||
|
filters.push({ rating: { $gte: rating } });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Фильтр по отзывам
|
||||||
|
if (hasReviews === 'true') {
|
||||||
|
filters.push({ verified: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Фильтр по акцептам
|
||||||
|
if (hasAcceptedDocs === 'true') {
|
||||||
|
filters.push({ verified: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Применяем фильтр по размеру компании (если был задан)
|
||||||
|
if (sizeFilteredIds !== null) {
|
||||||
|
if (sizeFilteredIds.length > 0) {
|
||||||
|
filters.push({ _id: { $in: sizeFilteredIds } });
|
||||||
|
log('[Search] Applied size filter, IDs:', sizeFilteredIds.length);
|
||||||
|
} else {
|
||||||
|
// Если нет подходящих компаний по размеру, возвращаем пустой результат
|
||||||
|
filters.push({ _id: null });
|
||||||
|
log('[Search] No companies match size criteria');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Комбинировать все фильтры
|
||||||
|
let filter = filters.length > 0 ? { $and: filters } : {};
|
||||||
|
|
||||||
|
// Пагинация - используем offset если передан, иначе вычисляем из page
|
||||||
|
const limitNum = parseInt(limit) || 10;
|
||||||
|
const skip = offset !== undefined ? parseInt(offset) : ((parseInt(page) || 1) - 1) * limitNum;
|
||||||
|
const pageNum = offset !== undefined ? Math.floor(skip / limitNum) + 1 : parseInt(page) || 1;
|
||||||
|
|
||||||
|
// Сортировка
|
||||||
|
let sortOptions = {};
|
||||||
|
if (sortBy === 'name') {
|
||||||
|
sortOptions.fullName = sortOrder === 'asc' ? 1 : -1;
|
||||||
|
} else {
|
||||||
|
sortOptions.rating = sortOrder === 'asc' ? 1 : -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
log('[Search] Final MongoDB filter:', JSON.stringify(filter, null, 2));
|
||||||
|
|
||||||
|
let filterDebug = filters.length > 0 ? { $and: filters } : {};
|
||||||
|
const allCompanies = await Company.find({});
|
||||||
|
log('[Search] All companies in DB:', allCompanies.map(c => ({ name: c.fullName, geography: c.partnerGeography, industry: c.industry })));
|
||||||
|
|
||||||
|
const total = await Company.countDocuments(filter);
|
||||||
|
const companies = await Company.find(filter)
|
||||||
|
.sort(sortOptions)
|
||||||
|
.skip(skip)
|
||||||
|
.limit(limitNum);
|
||||||
|
|
||||||
|
const paginatedResults = companies.map(c => ({
|
||||||
|
...c.toObject(),
|
||||||
|
id: c._id
|
||||||
|
}));
|
||||||
|
|
||||||
|
log('[Search] Query:', query, 'Industries:', industries, 'Size:', companySize, 'Geo:', geography);
|
||||||
|
log('[Search] Total found:', total, 'Returning:', paginatedResults.length, 'companies');
|
||||||
|
log('[Search] Company details:', paginatedResults.map(c => ({ name: c.fullName, industry: c.industry })));
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
companies: paginatedResults,
|
||||||
|
total,
|
||||||
|
page: pageNum,
|
||||||
|
totalPages: Math.ceil(total / limitNum),
|
||||||
|
_debug: {
|
||||||
|
filter: JSON.stringify(filter),
|
||||||
|
industriesReceived: industries
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[Search] Error:', error.message);
|
||||||
|
res.status(500).json({
|
||||||
|
error: 'Internal server error',
|
||||||
|
message: error.message
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
|
|
||||||
|
|
||||||
92
server/routers/procurement/scripts/migrate-messages.js
Normal file
92
server/routers/procurement/scripts/migrate-messages.js
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
const mongoose = require('../../../utils/mongoose');
|
||||||
|
const { ObjectId } = mongoose.Types;
|
||||||
|
const Message = require('../models/Message');
|
||||||
|
require('dotenv').config();
|
||||||
|
|
||||||
|
async function migrateMessages() {
|
||||||
|
try {
|
||||||
|
// Подключение к MongoDB происходит через server/utils/mongoose.ts
|
||||||
|
console.log('[Migration] Checking MongoDB connection...');
|
||||||
|
if (mongoose.connection.readyState !== 1) {
|
||||||
|
console.log('[Migration] Waiting for MongoDB connection...');
|
||||||
|
await new Promise((resolve) => {
|
||||||
|
mongoose.connection.once('connected', resolve);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
console.log('[Migration] Connected to MongoDB');
|
||||||
|
|
||||||
|
// Найти все сообщения
|
||||||
|
const allMessages = await Message.find().exec();
|
||||||
|
console.log('[Migration] Found', allMessages.length, 'total messages');
|
||||||
|
|
||||||
|
let fixedCount = 0;
|
||||||
|
let errorCount = 0;
|
||||||
|
|
||||||
|
// Проходим по каждому сообщению
|
||||||
|
for (const message of allMessages) {
|
||||||
|
try {
|
||||||
|
const threadId = message.threadId;
|
||||||
|
if (!threadId) {
|
||||||
|
console.log('[Migration] Skipping message', message._id, '- no threadId');
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Парсим threadId формата "thread-id1-id2" или "id1-id2"
|
||||||
|
let ids = threadId.replace('thread-', '').split('-');
|
||||||
|
|
||||||
|
if (ids.length < 2) {
|
||||||
|
console.log('[Migration] Invalid threadId format:', threadId);
|
||||||
|
errorCount++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const companyId1 = ids[0];
|
||||||
|
const companyId2 = ids[1];
|
||||||
|
|
||||||
|
// Сравниваем с senderCompanyId
|
||||||
|
const senderIdString = message.senderCompanyId.toString ? message.senderCompanyId.toString() : message.senderCompanyId;
|
||||||
|
const expectedRecipient = senderIdString === companyId1 ? companyId2 : companyId1;
|
||||||
|
|
||||||
|
// Если recipientCompanyId не установлена или неправильная - исправляем
|
||||||
|
if (!message.recipientCompanyId || message.recipientCompanyId.toString() !== expectedRecipient) {
|
||||||
|
console.log('[Migration] Fixing message', message._id);
|
||||||
|
console.log(' Old recipientCompanyId:', message.recipientCompanyId);
|
||||||
|
console.log(' Expected:', expectedRecipient);
|
||||||
|
|
||||||
|
// Конвертируем в ObjectId если нужно
|
||||||
|
let recipientObjectId = expectedRecipient;
|
||||||
|
try {
|
||||||
|
if (typeof expectedRecipient === 'string' && ObjectId.isValid(expectedRecipient)) {
|
||||||
|
recipientObjectId = new ObjectId(expectedRecipient);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.log(' Could not convert to ObjectId');
|
||||||
|
}
|
||||||
|
|
||||||
|
await Message.updateOne(
|
||||||
|
{ _id: message._id },
|
||||||
|
{ recipientCompanyId: recipientObjectId }
|
||||||
|
);
|
||||||
|
|
||||||
|
fixedCount++;
|
||||||
|
console.log(' ✅ Fixed');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[Migration] Error processing message', message._id, ':', err.message);
|
||||||
|
errorCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('[Migration] ✅ Migration completed!');
|
||||||
|
console.log('[Migration] Fixed:', fixedCount, 'messages');
|
||||||
|
console.log('[Migration] Errors:', errorCount);
|
||||||
|
|
||||||
|
await mongoose.connection.close();
|
||||||
|
console.log('[Migration] Disconnected from MongoDB');
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[Migration] ❌ Error:', err.message);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
migrateMessages();
|
||||||
382
server/routers/procurement/scripts/recreate-test-user.js
Normal file
382
server/routers/procurement/scripts/recreate-test-user.js
Normal file
@@ -0,0 +1,382 @@
|
|||||||
|
const mongoose = require('../../../utils/mongoose');
|
||||||
|
require('dotenv').config();
|
||||||
|
|
||||||
|
// Импорт моделей
|
||||||
|
const User = require('../models/User');
|
||||||
|
const Company = require('../models/Company');
|
||||||
|
const Request = require('../models/Request');
|
||||||
|
|
||||||
|
// Подключение к MongoDB происходит через server/utils/mongoose.ts
|
||||||
|
// Проверяем, подключено ли уже
|
||||||
|
const ensureConnection = async () => {
|
||||||
|
if (mongoose.connection.readyState === 1) {
|
||||||
|
console.log('✅ MongoDB уже подключено');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('⏳ Ожидание подключения к MongoDB...');
|
||||||
|
await new Promise((resolve) => {
|
||||||
|
if (mongoose.connection.readyState === 1) {
|
||||||
|
resolve();
|
||||||
|
} else {
|
||||||
|
mongoose.connection.once('connected', resolve);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
console.log('✅ Подключено к MongoDB');
|
||||||
|
};
|
||||||
|
|
||||||
|
const recreateTestUser = async () => {
|
||||||
|
try {
|
||||||
|
await ensureConnection();
|
||||||
|
|
||||||
|
const presetCompanyId = new mongoose.Types.ObjectId('68fe2ccda3526c303ca06796');
|
||||||
|
const presetUserEmail = 'admin@test-company.ru';
|
||||||
|
|
||||||
|
const presetCompanyId2 = new mongoose.Types.ObjectId('68fe2ccda3526c303ca06797');
|
||||||
|
const presetUserEmail2 = 'manager@partner-company.ru';
|
||||||
|
|
||||||
|
// Удалить старых тестовых пользователей
|
||||||
|
console.log('🗑️ Удаление старых тестовых пользователей...');
|
||||||
|
const testEmails = [presetUserEmail, presetUserEmail2];
|
||||||
|
|
||||||
|
for (const email of testEmails) {
|
||||||
|
const oldUser = await User.findOne({ email });
|
||||||
|
if (oldUser) {
|
||||||
|
// Удалить связанную компанию
|
||||||
|
if (oldUser.companyId) {
|
||||||
|
await Company.findByIdAndDelete(oldUser.companyId);
|
||||||
|
console.log(` ✓ Старая компания для ${email} удалена`);
|
||||||
|
}
|
||||||
|
await User.findByIdAndDelete(oldUser._id);
|
||||||
|
console.log(` ✓ Старый пользователь ${email} удален`);
|
||||||
|
} else {
|
||||||
|
console.log(` ℹ️ Пользователь ${email} не найден`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Создать новую компанию с правильной кодировкой UTF-8
|
||||||
|
console.log('\n🏢 Создание тестовой компании...');
|
||||||
|
const company = await Company.create({
|
||||||
|
_id: presetCompanyId,
|
||||||
|
fullName: 'ООО "Тестовая Компания"',
|
||||||
|
shortName: 'Тестовая Компания',
|
||||||
|
inn: '1234567890',
|
||||||
|
ogrn: '1234567890123',
|
||||||
|
legalForm: 'ООО',
|
||||||
|
industry: 'IT',
|
||||||
|
companySize: '51-250',
|
||||||
|
website: 'https://test-company.ru',
|
||||||
|
phone: '+7 (999) 123-45-67',
|
||||||
|
email: 'info@test-company.ru',
|
||||||
|
description: 'Тестовая компания для разработки',
|
||||||
|
legalAddress: 'г. Москва, ул. Тестовая, д. 1',
|
||||||
|
actualAddress: 'г. Москва, ул. Тестовая, д. 1',
|
||||||
|
foundedYear: 2015,
|
||||||
|
employeeCount: '51-250',
|
||||||
|
revenue: 'До 120 млн ₽',
|
||||||
|
rating: 4.5,
|
||||||
|
reviews: 10,
|
||||||
|
verified: true,
|
||||||
|
partnerGeography: ['moscow', 'russia_all'],
|
||||||
|
slogan: 'Ваш надежный партнер в IT',
|
||||||
|
});
|
||||||
|
console.log(' ✓ Компания создана:', company.fullName);
|
||||||
|
|
||||||
|
// Создать первого пользователя с правильной кодировкой UTF-8
|
||||||
|
console.log('\n👤 Создание первого тестового пользователя...');
|
||||||
|
const user = await User.create({
|
||||||
|
email: presetUserEmail,
|
||||||
|
password: 'SecurePass123!',
|
||||||
|
firstName: 'Иван',
|
||||||
|
lastName: 'Иванов',
|
||||||
|
position: 'Директор',
|
||||||
|
phone: '+7 (999) 123-45-67',
|
||||||
|
companyId: company._id,
|
||||||
|
});
|
||||||
|
console.log(' ✓ Пользователь создан:', user.firstName, user.lastName);
|
||||||
|
|
||||||
|
// Создать вторую компанию
|
||||||
|
console.log('\n🏢 Создание второй тестовой компании...');
|
||||||
|
const company2 = await Company.create({
|
||||||
|
_id: presetCompanyId2,
|
||||||
|
fullName: 'ООО "Партнер"',
|
||||||
|
shortName: 'Партнер',
|
||||||
|
inn: '9876543210',
|
||||||
|
ogrn: '1089876543210',
|
||||||
|
legalForm: 'ООО',
|
||||||
|
industry: 'Торговля',
|
||||||
|
companySize: '11-50',
|
||||||
|
website: 'https://partner-company.ru',
|
||||||
|
phone: '+7 (495) 987-65-43',
|
||||||
|
email: 'info@partner-company.ru',
|
||||||
|
description: 'Надежный партнер для бизнеса',
|
||||||
|
legalAddress: 'г. Санкт-Петербург, пр. Невский, д. 100',
|
||||||
|
actualAddress: 'г. Санкт-Петербург, пр. Невский, д. 100',
|
||||||
|
foundedYear: 2018,
|
||||||
|
employeeCount: '11-50',
|
||||||
|
revenue: 'До 60 млн ₽',
|
||||||
|
rating: 4.3,
|
||||||
|
reviews: 5,
|
||||||
|
verified: true,
|
||||||
|
partnerGeography: ['spb', 'russia_all'],
|
||||||
|
slogan: 'Качество и надежность',
|
||||||
|
});
|
||||||
|
console.log(' ✓ Компания создана:', company2.fullName);
|
||||||
|
|
||||||
|
// Создать второго пользователя
|
||||||
|
console.log('\n👤 Создание второго тестового пользователя...');
|
||||||
|
const user2 = await User.create({
|
||||||
|
email: presetUserEmail2,
|
||||||
|
password: 'SecurePass123!',
|
||||||
|
firstName: 'Петр',
|
||||||
|
lastName: 'Петров',
|
||||||
|
position: 'Менеджер',
|
||||||
|
phone: '+7 (495) 987-65-43',
|
||||||
|
companyId: company2._id,
|
||||||
|
});
|
||||||
|
console.log(' ✓ Пользователь создан:', user2.firstName, user2.lastName);
|
||||||
|
|
||||||
|
// Проверка что данные сохранены правильно
|
||||||
|
console.log('\n✅ Проверка данных:');
|
||||||
|
console.log('\n Пользователь 1:');
|
||||||
|
console.log(' Email:', user.email);
|
||||||
|
console.log(' Имя:', user.firstName);
|
||||||
|
console.log(' Фамилия:', user.lastName);
|
||||||
|
console.log(' Компания:', company.fullName);
|
||||||
|
console.log(' Должность:', user.position);
|
||||||
|
|
||||||
|
console.log('\n Пользователь 2:');
|
||||||
|
console.log(' Email:', user2.email);
|
||||||
|
console.log(' Имя:', user2.firstName);
|
||||||
|
console.log(' Фамилия:', user2.lastName);
|
||||||
|
console.log(' Компания:', company2.fullName);
|
||||||
|
console.log(' Должность:', user2.position);
|
||||||
|
|
||||||
|
console.log('\n✅ ГОТОВО! Тестовые пользователи созданы с правильной кодировкой UTF-8');
|
||||||
|
console.log('\n📋 Данные для входа:');
|
||||||
|
console.log('\n Пользователь 1:');
|
||||||
|
console.log(' Email: admin@test-company.ru');
|
||||||
|
console.log(' Пароль: SecurePass123!');
|
||||||
|
console.log('\n Пользователь 2:');
|
||||||
|
console.log(' Email: manager@partner-company.ru');
|
||||||
|
console.log(' Пароль: SecurePass123!');
|
||||||
|
console.log('');
|
||||||
|
|
||||||
|
// Создать дополнительные тестовые компании для поиска
|
||||||
|
console.log('\n🏢 Создание дополнительных тестовых компаний...');
|
||||||
|
const testCompanies = [
|
||||||
|
{
|
||||||
|
fullName: 'ООО "ТехноСтрой"',
|
||||||
|
shortName: 'ТехноСтрой',
|
||||||
|
inn: '7707083894',
|
||||||
|
ogrn: '1077707083894',
|
||||||
|
legalForm: 'ООО',
|
||||||
|
industry: 'Строительство',
|
||||||
|
companySize: '51-250',
|
||||||
|
website: 'https://technostroy.ru',
|
||||||
|
phone: '+7 (495) 111-22-33',
|
||||||
|
email: 'info@technostroy.ru',
|
||||||
|
description: 'Строительство промышленных объектов',
|
||||||
|
foundedYear: 2010,
|
||||||
|
employeeCount: '51-250',
|
||||||
|
revenue: 'До 2 млрд ₽',
|
||||||
|
rating: 4.2,
|
||||||
|
reviews: 15,
|
||||||
|
verified: true,
|
||||||
|
partnerGeography: ['moscow', 'russia_all'],
|
||||||
|
slogan: 'Строим будущее вместе',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fullName: 'АО "ФинансГрупп"',
|
||||||
|
shortName: 'ФинансГрупп',
|
||||||
|
inn: '7707083895',
|
||||||
|
ogrn: '1077707083895',
|
||||||
|
legalForm: 'АО',
|
||||||
|
industry: 'Финансы',
|
||||||
|
companySize: '500+',
|
||||||
|
website: 'https://finansgrupp.ru',
|
||||||
|
phone: '+7 (495) 222-33-44',
|
||||||
|
email: 'contact@finansgrupp.ru',
|
||||||
|
description: 'Финансовые услуги для бизнеса',
|
||||||
|
foundedYear: 2005,
|
||||||
|
employeeCount: '500+',
|
||||||
|
revenue: 'Более 2 млрд ₽',
|
||||||
|
rating: 4.8,
|
||||||
|
reviews: 50,
|
||||||
|
verified: true,
|
||||||
|
partnerGeography: ['moscow', 'russia_all', 'international'],
|
||||||
|
slogan: 'Финансовая стабильность',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fullName: 'ООО "ИТ Решения"',
|
||||||
|
shortName: 'ИТ Решения',
|
||||||
|
inn: '7707083896',
|
||||||
|
ogrn: '1077707083896',
|
||||||
|
legalForm: 'ООО',
|
||||||
|
industry: 'IT',
|
||||||
|
companySize: '11-50',
|
||||||
|
website: 'https://it-solutions.ru',
|
||||||
|
phone: '+7 (495) 333-44-55',
|
||||||
|
email: 'hello@it-solutions.ru',
|
||||||
|
description: 'Разработка программного обеспечения',
|
||||||
|
foundedYear: 2018,
|
||||||
|
employeeCount: '11-50',
|
||||||
|
revenue: 'До 60 млн ₽',
|
||||||
|
rating: 4.5,
|
||||||
|
reviews: 8,
|
||||||
|
verified: true,
|
||||||
|
partnerGeography: ['moscow', 'spb', 'russia_all'],
|
||||||
|
slogan: 'Инновации для вашего бизнеса',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fullName: 'ООО "ЛогистикПро"',
|
||||||
|
shortName: 'ЛогистикПро',
|
||||||
|
inn: '7707083897',
|
||||||
|
ogrn: '1077707083897',
|
||||||
|
legalForm: 'ООО',
|
||||||
|
industry: 'Логистика',
|
||||||
|
companySize: '51-250',
|
||||||
|
website: 'https://logistikpro.ru',
|
||||||
|
phone: '+7 (495) 444-55-66',
|
||||||
|
email: 'info@logistikpro.ru',
|
||||||
|
description: 'Транспортные и логистические услуги',
|
||||||
|
foundedYear: 2012,
|
||||||
|
employeeCount: '51-250',
|
||||||
|
revenue: 'До 120 млн ₽',
|
||||||
|
rating: 4.3,
|
||||||
|
reviews: 20,
|
||||||
|
verified: true,
|
||||||
|
partnerGeography: ['russia_all', 'cis'],
|
||||||
|
slogan: 'Доставим в срок',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fullName: 'ООО "ПродуктТрейд"',
|
||||||
|
shortName: 'ПродуктТрейд',
|
||||||
|
inn: '7707083898',
|
||||||
|
ogrn: '1077707083898',
|
||||||
|
legalForm: 'ООО',
|
||||||
|
industry: 'Оптовая торговля',
|
||||||
|
companySize: '251-500',
|
||||||
|
website: 'https://produkttrade.ru',
|
||||||
|
phone: '+7 (495) 555-66-77',
|
||||||
|
email: 'sales@produkttrade.ru',
|
||||||
|
description: 'Оптовая торговля продуктами питания',
|
||||||
|
foundedYear: 2008,
|
||||||
|
employeeCount: '251-500',
|
||||||
|
revenue: 'До 2 млрд ₽',
|
||||||
|
rating: 4.1,
|
||||||
|
reviews: 30,
|
||||||
|
verified: true,
|
||||||
|
partnerGeography: ['moscow', 'russia_all'],
|
||||||
|
slogan: 'Качество и надежность',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fullName: 'ООО "МедСервис"',
|
||||||
|
shortName: 'МедСервис',
|
||||||
|
inn: '7707083899',
|
||||||
|
ogrn: '1077707083899',
|
||||||
|
legalForm: 'ООО',
|
||||||
|
industry: 'Здравоохранение',
|
||||||
|
companySize: '11-50',
|
||||||
|
website: 'https://medservice.ru',
|
||||||
|
phone: '+7 (495) 666-77-88',
|
||||||
|
email: 'info@medservice.ru',
|
||||||
|
description: 'Медицинские услуги и оборудование',
|
||||||
|
foundedYear: 2016,
|
||||||
|
employeeCount: '11-50',
|
||||||
|
revenue: 'До 60 млн ₽',
|
||||||
|
rating: 4.6,
|
||||||
|
reviews: 12,
|
||||||
|
verified: true,
|
||||||
|
partnerGeography: ['moscow', 'central'],
|
||||||
|
slogan: 'Забота о вашем здоровье',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const companyData of testCompanies) {
|
||||||
|
await Company.updateOne(
|
||||||
|
{ inn: companyData.inn },
|
||||||
|
{ $set: companyData },
|
||||||
|
{ upsert: true }
|
||||||
|
);
|
||||||
|
console.log(` ✓ Компания создана/обновлена: ${companyData.shortName}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Создать тестовые запросы
|
||||||
|
console.log('\n📨 Создание тестовых запросов...');
|
||||||
|
await Request.deleteMany({});
|
||||||
|
|
||||||
|
const companies = await Company.find().limit(10).exec();
|
||||||
|
const testCompanyId = company._id.toString();
|
||||||
|
const requests = [];
|
||||||
|
const now = new Date();
|
||||||
|
|
||||||
|
// Создаем отправленные запросы (от тестовой компании)
|
||||||
|
for (let i = 0; i < 5; i++) {
|
||||||
|
const recipientCompany = companies[i % companies.length];
|
||||||
|
if (recipientCompany._id.toString() === testCompanyId) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const createdAt = new Date(now.getTime() - i * 24 * 60 * 60 * 1000);
|
||||||
|
|
||||||
|
requests.push({
|
||||||
|
senderCompanyId: testCompanyId,
|
||||||
|
recipientCompanyId: recipientCompany._id.toString(),
|
||||||
|
subject: `Запрос на поставку ${i + 1}`,
|
||||||
|
text: `Здравствуйте! Интересует поставка товаров/услуг. Запрос ${i + 1}. Прошу предоставить коммерческое предложение.`,
|
||||||
|
files: [],
|
||||||
|
responseFiles: [],
|
||||||
|
status: i % 3 === 0 ? 'accepted' : i % 3 === 1 ? 'rejected' : 'pending',
|
||||||
|
response: i % 3 === 0
|
||||||
|
? 'Благодарим за запрос! Готовы предоставить услуги. Отправили КП на почту.'
|
||||||
|
: i % 3 === 1
|
||||||
|
? 'К сожалению, в данный момент не можем предоставить эти услуги.'
|
||||||
|
: null,
|
||||||
|
respondedAt: i % 3 !== 2 ? new Date(createdAt.getTime() + 2 * 60 * 60 * 1000) : null,
|
||||||
|
createdAt,
|
||||||
|
updatedAt: i % 3 !== 2 ? new Date(createdAt.getTime() + 2 * 60 * 60 * 1000) : createdAt,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Создаем полученные запросы (к тестовой компании)
|
||||||
|
for (let i = 0; i < 3; i++) {
|
||||||
|
const senderCompany = companies[(i + 2) % companies.length];
|
||||||
|
if (senderCompany._id.toString() === testCompanyId) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const createdAt = new Date(now.getTime() - (i + 1) * 12 * 60 * 60 * 1000);
|
||||||
|
|
||||||
|
requests.push({
|
||||||
|
senderCompanyId: senderCompany._id.toString(),
|
||||||
|
recipientCompanyId: testCompanyId,
|
||||||
|
subject: `Предложение о сотрудничестве ${i + 1}`,
|
||||||
|
text: `Добрый день! Предлагаем сотрудничество. Запрос ${i + 1}. Заинтересованы в вашей продукции.`,
|
||||||
|
files: [],
|
||||||
|
responseFiles: [],
|
||||||
|
status: 'pending',
|
||||||
|
response: null,
|
||||||
|
respondedAt: null,
|
||||||
|
createdAt,
|
||||||
|
updatedAt: createdAt,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (requests.length > 0) {
|
||||||
|
await Request.insertMany(requests);
|
||||||
|
console.log(` ✓ Создано ${requests.length} тестовых запросов`);
|
||||||
|
}
|
||||||
|
|
||||||
|
await mongoose.connection.close();
|
||||||
|
process.exit(0);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('\n❌ Ошибка:', error.message);
|
||||||
|
console.error(error);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Запуск
|
||||||
|
recreateTestUser();
|
||||||
|
|
||||||
126
server/routers/procurement/scripts/seed-activities.js
Normal file
126
server/routers/procurement/scripts/seed-activities.js
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
const mongoose = require('../../../utils/mongoose');
|
||||||
|
require('dotenv').config();
|
||||||
|
|
||||||
|
// Подключение моделей
|
||||||
|
const Activity = require('../models/Activity');
|
||||||
|
const User = require('../models/User');
|
||||||
|
const Company = require('../models/Company');
|
||||||
|
|
||||||
|
const activityTemplates = [
|
||||||
|
{
|
||||||
|
type: 'request_received',
|
||||||
|
title: 'Получен новый запрос',
|
||||||
|
description: 'Компания отправила вам запрос на поставку товаров',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'request_sent',
|
||||||
|
title: 'Запрос отправлен',
|
||||||
|
description: 'Ваш запрос был отправлен компании',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'request_response',
|
||||||
|
title: 'Получен ответ на запрос',
|
||||||
|
description: 'Компания ответила на ваш запрос',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'product_accepted',
|
||||||
|
title: 'Товар акцептован',
|
||||||
|
description: 'Ваш товар был акцептован компанией',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'message_received',
|
||||||
|
title: 'Новое сообщение',
|
||||||
|
description: 'Вы получили новое сообщение от компании',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'review_received',
|
||||||
|
title: 'Новый отзыв',
|
||||||
|
description: 'Компания оставила отзыв о сотрудничестве',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'profile_updated',
|
||||||
|
title: 'Профиль обновлен',
|
||||||
|
description: 'Информация о вашей компании была обновлена',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'buy_product_added',
|
||||||
|
title: 'Добавлен товар для закупки',
|
||||||
|
description: 'В раздел "Я покупаю" добавлен новый товар',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
async function seedActivities() {
|
||||||
|
try {
|
||||||
|
// Подключение к MongoDB происходит через server/utils/mongoose.ts
|
||||||
|
console.log('🌱 Checking MongoDB connection...');
|
||||||
|
if (mongoose.connection.readyState !== 1) {
|
||||||
|
console.log('⏳ Waiting for MongoDB connection...');
|
||||||
|
await new Promise((resolve) => {
|
||||||
|
mongoose.connection.once('connected', resolve);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
console.log('✅ Connected to MongoDB');
|
||||||
|
|
||||||
|
// Найти тестового пользователя
|
||||||
|
const testUser = await User.findOne({ email: 'admin@test-company.ru' });
|
||||||
|
if (!testUser) {
|
||||||
|
console.log('❌ Test user not found. Please run recreate-test-user.js first.');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const company = await Company.findById(testUser.companyId);
|
||||||
|
if (!company) {
|
||||||
|
console.log('❌ Company not found');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Найти другие компании для связанных активностей
|
||||||
|
const otherCompanies = await Company.find({
|
||||||
|
_id: { $ne: company._id }
|
||||||
|
}).limit(3);
|
||||||
|
|
||||||
|
console.log('🗑️ Clearing existing activities...');
|
||||||
|
await Activity.deleteMany({ companyId: company._id.toString() });
|
||||||
|
|
||||||
|
console.log('➕ Creating activities...');
|
||||||
|
const activities = [];
|
||||||
|
|
||||||
|
for (let i = 0; i < 8; i++) {
|
||||||
|
const template = activityTemplates[i % activityTemplates.length];
|
||||||
|
const relatedCompany = otherCompanies[i % otherCompanies.length];
|
||||||
|
|
||||||
|
const activity = {
|
||||||
|
companyId: company._id.toString(),
|
||||||
|
userId: testUser._id.toString(),
|
||||||
|
type: template.type,
|
||||||
|
title: template.title,
|
||||||
|
description: template.description,
|
||||||
|
relatedCompanyId: relatedCompany?._id.toString(),
|
||||||
|
relatedCompanyName: relatedCompany?.shortName || relatedCompany?.fullName,
|
||||||
|
read: i >= 5, // Первые 5 непрочитанные
|
||||||
|
createdAt: new Date(Date.now() - i * 3600000), // Каждый час назад
|
||||||
|
};
|
||||||
|
|
||||||
|
activities.push(activity);
|
||||||
|
}
|
||||||
|
|
||||||
|
await Activity.insertMany(activities);
|
||||||
|
|
||||||
|
console.log(`✅ Created ${activities.length} activities`);
|
||||||
|
console.log('✨ Activities seeded successfully!');
|
||||||
|
|
||||||
|
await mongoose.connection.close();
|
||||||
|
console.log('👋 Database connection closed');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ Error seeding activities:', error);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Запуск
|
||||||
|
if (require.main === module) {
|
||||||
|
seedActivities();
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { seedActivities };
|
||||||
|
|
||||||
118
server/routers/procurement/scripts/seed-requests.js
Normal file
118
server/routers/procurement/scripts/seed-requests.js
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
const mongoose = require('../../../utils/mongoose');
|
||||||
|
const Request = require('../models/Request');
|
||||||
|
const Company = require('../models/Company');
|
||||||
|
const User = require('../models/User');
|
||||||
|
|
||||||
|
async function seedRequests() {
|
||||||
|
try {
|
||||||
|
// Подключение к MongoDB происходит через server/utils/mongoose.ts
|
||||||
|
if (mongoose.connection.readyState !== 1) {
|
||||||
|
console.log('⏳ Waiting for MongoDB connection...');
|
||||||
|
await new Promise((resolve) => {
|
||||||
|
mongoose.connection.once('connected', resolve);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
console.log('✅ Connected to MongoDB');
|
||||||
|
|
||||||
|
// Получаем все компании
|
||||||
|
const companies = await Company.find().limit(10).exec();
|
||||||
|
if (companies.length < 2) {
|
||||||
|
console.error('❌ Need at least 2 companies in database');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Получаем тестового пользователя
|
||||||
|
const testUser = await User.findOne({ email: 'admin@test-company.ru' }).exec();
|
||||||
|
if (!testUser) {
|
||||||
|
console.error('❌ Test user not found');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const testCompanyId = testUser.companyId.toString();
|
||||||
|
console.log('📋 Test company ID:', testCompanyId);
|
||||||
|
console.log('📋 Found', companies.length, 'companies');
|
||||||
|
|
||||||
|
// Удаляем старые запросы
|
||||||
|
await Request.deleteMany({});
|
||||||
|
console.log('🗑️ Cleared old requests');
|
||||||
|
|
||||||
|
const requests = [];
|
||||||
|
const now = new Date();
|
||||||
|
|
||||||
|
// Создаем отправленные запросы (от тестовой компании)
|
||||||
|
for (let i = 0; i < 5; i++) {
|
||||||
|
const recipientCompany = companies[i % companies.length];
|
||||||
|
if (recipientCompany._id.toString() === testCompanyId) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const createdAt = new Date(now.getTime() - i * 24 * 60 * 60 * 1000); // За последние 5 дней
|
||||||
|
|
||||||
|
requests.push({
|
||||||
|
senderCompanyId: testCompanyId,
|
||||||
|
recipientCompanyId: recipientCompany._id.toString(),
|
||||||
|
subject: `Запрос на поставку ${i + 1}`,
|
||||||
|
text: `Здравствуйте! Интересует поставка товаров/услуг. Запрос ${i + 1}. Прошу предоставить коммерческое предложение.`,
|
||||||
|
files: [],
|
||||||
|
responseFiles: [],
|
||||||
|
status: i % 3 === 0 ? 'accepted' : i % 3 === 1 ? 'rejected' : 'pending',
|
||||||
|
response: i % 3 === 0
|
||||||
|
? 'Благодарим за запрос! Готовы предоставить услуги. Отправили КП на почту.'
|
||||||
|
: i % 3 === 1
|
||||||
|
? 'К сожалению, в данный момент не можем предоставить эти услуги.'
|
||||||
|
: null,
|
||||||
|
respondedAt: i % 3 !== 2 ? new Date(createdAt.getTime() + 2 * 60 * 60 * 1000) : null,
|
||||||
|
createdAt,
|
||||||
|
updatedAt: i % 3 !== 2 ? new Date(createdAt.getTime() + 2 * 60 * 60 * 1000) : createdAt,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Создаем полученные запросы (к тестовой компании)
|
||||||
|
for (let i = 0; i < 3; i++) {
|
||||||
|
const senderCompany = companies[(i + 2) % companies.length];
|
||||||
|
if (senderCompany._id.toString() === testCompanyId) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const createdAt = new Date(now.getTime() - (i + 1) * 12 * 60 * 60 * 1000); // За последние 1.5 дня
|
||||||
|
|
||||||
|
requests.push({
|
||||||
|
senderCompanyId: senderCompany._id.toString(),
|
||||||
|
recipientCompanyId: testCompanyId,
|
||||||
|
subject: `Предложение о сотрудничестве ${i + 1}`,
|
||||||
|
text: `Добрый день! Предлагаем сотрудничество. Запрос ${i + 1}. Заинтересованы в вашей продукции.`,
|
||||||
|
files: [],
|
||||||
|
responseFiles: [],
|
||||||
|
status: 'pending',
|
||||||
|
response: null,
|
||||||
|
respondedAt: null,
|
||||||
|
createdAt,
|
||||||
|
updatedAt: createdAt,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Сохраняем все запросы
|
||||||
|
const savedRequests = await Request.insertMany(requests);
|
||||||
|
console.log('✅ Created', savedRequests.length, 'test requests');
|
||||||
|
|
||||||
|
// Статистика
|
||||||
|
const sentCount = await Request.countDocuments({ senderCompanyId: testCompanyId });
|
||||||
|
const receivedCount = await Request.countDocuments({ recipientCompanyId: testCompanyId });
|
||||||
|
const withResponses = await Request.countDocuments({ senderCompanyId: testCompanyId, response: { $ne: null } });
|
||||||
|
|
||||||
|
console.log('📊 Statistics:');
|
||||||
|
console.log(' - Sent requests:', sentCount);
|
||||||
|
console.log(' - Received requests:', receivedCount);
|
||||||
|
console.log(' - With responses:', withResponses);
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ Error:', error);
|
||||||
|
process.exit(1);
|
||||||
|
} finally {
|
||||||
|
await mongoose.connection.close();
|
||||||
|
console.log('👋 Disconnected from MongoDB');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
seedRequests();
|
||||||
|
|
||||||
61
server/routers/procurement/scripts/test-logging.js
Normal file
61
server/routers/procurement/scripts/test-logging.js
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Скрипт для тестирования логирования
|
||||||
|
*
|
||||||
|
* Использование:
|
||||||
|
* node stubs/scripts/test-logging.js # Логи скрыты (DEV не установлена)
|
||||||
|
* DEV=true node stubs/scripts/test-logging.js # Логи видны
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Функция логирования из маршрутов
|
||||||
|
const log = (message, data = '') => {
|
||||||
|
if (process.env.DEV === 'true') {
|
||||||
|
if (data) {
|
||||||
|
console.log(message, data);
|
||||||
|
} else {
|
||||||
|
console.log(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log('');
|
||||||
|
console.log('='.repeat(60));
|
||||||
|
console.log('TEST: Логирование с переменной окружения DEV');
|
||||||
|
console.log('='.repeat(60));
|
||||||
|
console.log('');
|
||||||
|
|
||||||
|
console.log('Значение DEV:', process.env.DEV || '(не установлена)');
|
||||||
|
console.log('');
|
||||||
|
|
||||||
|
// Тестируем различные логи
|
||||||
|
log('[Auth] Token verified - userId: 68fe2ccda3526c303ca06799 companyId: 68fe2ccda3526c303ca06796');
|
||||||
|
log('[Auth] Generating token for userId:', '68fe2ccda3526c303ca06799');
|
||||||
|
log('[BuyProducts] Found', 0, 'products for company 68fe2ccda3526c303ca06796');
|
||||||
|
log('[Products] GET Fetching products for companyId:', '68fe2ccda3526c303ca06799');
|
||||||
|
log('[Products] Found', 1, 'products');
|
||||||
|
log('[Reviews] Returned', 0, 'reviews for company 68fe2ccda3526c303ca06796');
|
||||||
|
log('[Messages] Fetching threads for companyId:', '68fe2ccda3526c303ca06796');
|
||||||
|
log('[Messages] Found', 4, 'messages for company');
|
||||||
|
log('[Messages] Returned', 3, 'unique threads');
|
||||||
|
log('[Search] Request params:', { query: '', page: 1 });
|
||||||
|
|
||||||
|
console.log('');
|
||||||
|
console.log('='.repeat(60));
|
||||||
|
console.log('РЕЗУЛЬТАТ:');
|
||||||
|
console.log('='.repeat(60));
|
||||||
|
|
||||||
|
if (process.env.DEV === 'true') {
|
||||||
|
console.log('✅ DEV=true - логи ВИДНЫ выше');
|
||||||
|
} else {
|
||||||
|
console.log('❌ DEV не установлена или != "true" - логи СКРЫТЫ');
|
||||||
|
console.log('');
|
||||||
|
console.log('Для включения логов запустите:');
|
||||||
|
console.log(' export DEV=true && npm start (Linux/Mac)');
|
||||||
|
console.log(' $env:DEV = "true"; npm start (PowerShell)');
|
||||||
|
console.log(' set DEV=true && npm start (CMD)');
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('');
|
||||||
|
console.log('='.repeat(60));
|
||||||
|
console.log('');
|
||||||
421
server/routers/questioneer/index.js
Normal file
421
server/routers/questioneer/index.js
Normal file
@@ -0,0 +1,421 @@
|
|||||||
|
const express = require('express')
|
||||||
|
const { Router } = require("express")
|
||||||
|
const router = Router()
|
||||||
|
const crypto = require('crypto')
|
||||||
|
const path = require('path')
|
||||||
|
const { getDB } = require('../../utils/mongo')
|
||||||
|
const mongoose = require('mongoose')
|
||||||
|
|
||||||
|
// Используем одно определение модели
|
||||||
|
const Questionnaire = (() => {
|
||||||
|
// Если модель уже существует, используем её
|
||||||
|
if (mongoose.models.Questionnaire) {
|
||||||
|
return mongoose.models.Questionnaire;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Иначе создаем новую модель
|
||||||
|
const questionnaireSchema = new mongoose.Schema({
|
||||||
|
title: { type: String, required: true },
|
||||||
|
description: { type: String },
|
||||||
|
questions: [{
|
||||||
|
text: { type: String, required: true },
|
||||||
|
type: {
|
||||||
|
type: String,
|
||||||
|
enum: ['single_choice', 'multiple_choice', 'text', 'rating', 'tag_cloud', 'scale'],
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
required: { type: Boolean, default: false },
|
||||||
|
options: [{
|
||||||
|
text: { type: String, required: true },
|
||||||
|
count: { type: Number, default: 0 }
|
||||||
|
}],
|
||||||
|
scaleMin: { type: Number },
|
||||||
|
scaleMax: { type: Number },
|
||||||
|
scaleMinLabel: { type: String },
|
||||||
|
scaleMaxLabel: { type: String },
|
||||||
|
answers: [{ type: String }],
|
||||||
|
scaleValues: [{ type: Number }],
|
||||||
|
tags: [{
|
||||||
|
text: { type: String },
|
||||||
|
count: { type: Number, default: 1 }
|
||||||
|
}]
|
||||||
|
}],
|
||||||
|
displayType: {
|
||||||
|
type: String,
|
||||||
|
enum: ['default', 'tag_cloud', 'voting', 'poll', 'step_by_step'],
|
||||||
|
default: 'step_by_step'
|
||||||
|
},
|
||||||
|
createdAt: { type: Date, default: Date.now },
|
||||||
|
updatedAt: { type: Date, default: Date.now },
|
||||||
|
adminLink: { type: String, required: true },
|
||||||
|
publicLink: { type: String, required: true }
|
||||||
|
});
|
||||||
|
|
||||||
|
return mongoose.model('Questionnaire', questionnaireSchema);
|
||||||
|
})();
|
||||||
|
|
||||||
|
// Middleware для парсинга JSON
|
||||||
|
router.use(express.json());
|
||||||
|
|
||||||
|
// Обслуживание статичных файлов - проверяем правильность пути
|
||||||
|
router.use('/static', express.static(path.join(__dirname, 'public', 'static')));
|
||||||
|
|
||||||
|
// Получить главную страницу
|
||||||
|
router.get("/", (req, res) => {
|
||||||
|
res.sendFile(path.join(__dirname, 'public/index.html'))
|
||||||
|
})
|
||||||
|
|
||||||
|
// Страница создания нового опроса
|
||||||
|
router.get("/create", (req, res) => {
|
||||||
|
res.sendFile(path.join(__dirname, 'public/create.html'))
|
||||||
|
})
|
||||||
|
|
||||||
|
// Страница редактирования опроса
|
||||||
|
router.get("/edit/:adminLink", (req, res) => {
|
||||||
|
res.sendFile(path.join(__dirname, 'public/edit.html'))
|
||||||
|
})
|
||||||
|
|
||||||
|
// Страница администрирования опроса
|
||||||
|
router.get("/admin/:adminLink", (req, res) => {
|
||||||
|
res.sendFile(path.join(__dirname, 'public/admin.html'))
|
||||||
|
})
|
||||||
|
|
||||||
|
// Страница голосования
|
||||||
|
router.get("/poll/:publicLink", (req, res) => {
|
||||||
|
res.sendFile(path.join(__dirname, 'public/poll.html'))
|
||||||
|
})
|
||||||
|
|
||||||
|
// API для работы с опросами
|
||||||
|
|
||||||
|
// Создать новый опрос
|
||||||
|
router.post("/api/questionnaires", async (req, res) => {
|
||||||
|
try {
|
||||||
|
// Проверка наличия нужных полей
|
||||||
|
const { title, questions } = req.body;
|
||||||
|
|
||||||
|
if (!title || !Array.isArray(questions) || questions.length === 0) {
|
||||||
|
return res.json({ success: false, error: 'Необходимо указать название и хотя бы один вопрос' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Создаем уникальные ссылки
|
||||||
|
const adminLink = crypto.randomBytes(6).toString('hex');
|
||||||
|
const publicLink = crypto.randomBytes(6).toString('hex');
|
||||||
|
|
||||||
|
// Устанавливаем тип отображения step_by_step, если не указан
|
||||||
|
if (!req.body.displayType) {
|
||||||
|
req.body.displayType = 'step_by_step';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Создаем новый опросник
|
||||||
|
const questionnaire = new Questionnaire({
|
||||||
|
...req.body,
|
||||||
|
adminLink,
|
||||||
|
publicLink
|
||||||
|
});
|
||||||
|
|
||||||
|
await questionnaire.save();
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
adminLink,
|
||||||
|
publicLink
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error creating questionnaire:', error);
|
||||||
|
res.json({ success: false, error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Получить все опросы
|
||||||
|
router.get("/api/questionnaires", async (req, res) => {
|
||||||
|
try {
|
||||||
|
const questionnaires = await Questionnaire.find({}, {
|
||||||
|
title: 1,
|
||||||
|
description: 1,
|
||||||
|
createdAt: 1,
|
||||||
|
updatedAt: 1,
|
||||||
|
_id: 1,
|
||||||
|
adminLink: 1,
|
||||||
|
publicLink: 1
|
||||||
|
}).sort({ createdAt: -1 })
|
||||||
|
|
||||||
|
res.status(200).json({
|
||||||
|
success: true,
|
||||||
|
data: questionnaires
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching questionnaires:', error)
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
error: 'Failed to fetch questionnaires'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Получить опрос по ID для админа
|
||||||
|
router.get("/api/questionnaires/admin/:adminLink", async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { adminLink } = req.params
|
||||||
|
const questionnaire = await Questionnaire.findOne({ adminLink })
|
||||||
|
|
||||||
|
if (!questionnaire) {
|
||||||
|
return res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
error: 'Questionnaire not found'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
res.status(200).json({
|
||||||
|
success: true,
|
||||||
|
data: questionnaire
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching questionnaire:', error)
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
error: 'Failed to fetch questionnaire'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Получить опрос по публичной ссылке (для голосования)
|
||||||
|
router.get("/api/questionnaires/public/:publicLink", async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { publicLink } = req.params
|
||||||
|
const questionnaire = await Questionnaire.findOne({ publicLink })
|
||||||
|
|
||||||
|
if (!questionnaire) {
|
||||||
|
return res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
error: 'Questionnaire not found'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
res.status(200).json({
|
||||||
|
success: true,
|
||||||
|
data: questionnaire
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching questionnaire:', error)
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
error: 'Failed to fetch questionnaire'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Обновить опрос
|
||||||
|
router.put("/api/questionnaires/:adminLink", async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { adminLink } = req.params
|
||||||
|
const { title, description, questions, displayType } = req.body
|
||||||
|
|
||||||
|
const updatedQuestionnaire = await Questionnaire.findOneAndUpdate(
|
||||||
|
{ adminLink },
|
||||||
|
{
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
questions,
|
||||||
|
displayType,
|
||||||
|
updatedAt: Date.now()
|
||||||
|
},
|
||||||
|
{ new: true }
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!updatedQuestionnaire) {
|
||||||
|
return res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
error: 'Questionnaire not found'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
res.status(200).json({
|
||||||
|
success: true,
|
||||||
|
data: updatedQuestionnaire
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error updating questionnaire:', error)
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
error: 'Failed to update questionnaire'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Удалить опрос
|
||||||
|
router.delete("/api/questionnaires/:adminLink", async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { adminLink } = req.params
|
||||||
|
|
||||||
|
const deletedQuestionnaire = await Questionnaire.findOneAndDelete({ adminLink })
|
||||||
|
|
||||||
|
if (!deletedQuestionnaire) {
|
||||||
|
return res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
error: 'Questionnaire not found'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
res.status(200).json({
|
||||||
|
success: true,
|
||||||
|
message: 'Questionnaire deleted successfully'
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error deleting questionnaire:', error)
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
error: 'Failed to delete questionnaire'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Голосование в опросе
|
||||||
|
router.post("/api/vote/:publicLink", async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { publicLink } = req.params
|
||||||
|
const { answers } = req.body
|
||||||
|
|
||||||
|
const questionnaire = await Questionnaire.findOne({ publicLink })
|
||||||
|
|
||||||
|
if (!questionnaire) {
|
||||||
|
return res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
error: 'Questionnaire not found'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Обновить счетчики голосов
|
||||||
|
answers.forEach(answer => {
|
||||||
|
const { questionIndex, optionIndices, textAnswer, scaleValue, tagTexts } = answer
|
||||||
|
|
||||||
|
// Обработка одиночного и множественного выбора
|
||||||
|
if (Array.isArray(optionIndices)) {
|
||||||
|
// Для множественного выбора
|
||||||
|
optionIndices.forEach(optionIndex => {
|
||||||
|
if (questionnaire.questions[questionIndex] &&
|
||||||
|
questionnaire.questions[questionIndex].options[optionIndex]) {
|
||||||
|
questionnaire.questions[questionIndex].options[optionIndex].count += 1
|
||||||
|
}
|
||||||
|
})
|
||||||
|
} else if (typeof optionIndices === 'number') {
|
||||||
|
// Для единичного выбора
|
||||||
|
if (questionnaire.questions[questionIndex] &&
|
||||||
|
questionnaire.questions[questionIndex].options[optionIndices]) {
|
||||||
|
questionnaire.questions[questionIndex].options[optionIndices].count += 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Сохраняем текстовые ответы
|
||||||
|
if (textAnswer && questionnaire.questions[questionIndex]) {
|
||||||
|
if (!questionnaire.questions[questionIndex].answers) {
|
||||||
|
questionnaire.questions[questionIndex].answers = [];
|
||||||
|
}
|
||||||
|
questionnaire.questions[questionIndex].answers.push(textAnswer);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Сохраняем ответы шкалы оценки
|
||||||
|
if (scaleValue !== undefined && questionnaire.questions[questionIndex]) {
|
||||||
|
if (!questionnaire.questions[questionIndex].scaleValues) {
|
||||||
|
questionnaire.questions[questionIndex].scaleValues = [];
|
||||||
|
}
|
||||||
|
questionnaire.questions[questionIndex].scaleValues.push(scaleValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Сохраняем теги
|
||||||
|
if (Array.isArray(tagTexts) && tagTexts.length > 0 && questionnaire.questions[questionIndex]) {
|
||||||
|
if (!questionnaire.questions[questionIndex].tags) {
|
||||||
|
questionnaire.questions[questionIndex].tags = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
tagTexts.forEach(tagText => {
|
||||||
|
const existingTag = questionnaire.questions[questionIndex].tags.find(t => t.text === tagText);
|
||||||
|
if (existingTag) {
|
||||||
|
existingTag.count += 1;
|
||||||
|
} else {
|
||||||
|
questionnaire.questions[questionIndex].tags.push({ text: tagText, count: 1 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
await questionnaire.save()
|
||||||
|
|
||||||
|
res.status(200).json({
|
||||||
|
success: true,
|
||||||
|
message: 'Vote registered successfully'
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error registering vote:', error)
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
error: 'Failed to register vote'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Получить результаты опроса по публичной ссылке
|
||||||
|
router.get("/api/results/:publicLink", async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { publicLink } = req.params;
|
||||||
|
const questionnaire = await Questionnaire.findOne({ publicLink });
|
||||||
|
|
||||||
|
if (!questionnaire) {
|
||||||
|
return res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
error: 'Questionnaire not found'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Формируем результаты для отправки
|
||||||
|
const results = {
|
||||||
|
title: questionnaire.title,
|
||||||
|
description: questionnaire.description,
|
||||||
|
questions: questionnaire.questions.map(question => {
|
||||||
|
const result = {
|
||||||
|
text: question.text,
|
||||||
|
type: question.type
|
||||||
|
};
|
||||||
|
|
||||||
|
// Добавляем варианты ответов, если они есть
|
||||||
|
if (question.options && question.options.length > 0) {
|
||||||
|
result.options = question.options;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Добавляем текстовые ответы, если они есть
|
||||||
|
if (question.answers && question.answers.length > 0) {
|
||||||
|
result.answers = question.answers;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Добавляем результаты шкалы, если они есть
|
||||||
|
if (question.scaleValues && question.scaleValues.length > 0) {
|
||||||
|
result.scaleValues = question.scaleValues;
|
||||||
|
|
||||||
|
// Считаем среднее значение
|
||||||
|
result.scaleAverage = question.scaleValues.reduce((a, b) => a + b, 0) / question.scaleValues.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Добавляем теги, если они есть
|
||||||
|
if (question.tags && question.tags.length > 0) {
|
||||||
|
result.tags = question.tags;
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
res.status(200).json({
|
||||||
|
success: true,
|
||||||
|
data: results
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching poll results:', error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
error: 'Failed to fetch poll results'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router
|
||||||
583
server/routers/questioneer/openapi.yaml
Normal file
583
server/routers/questioneer/openapi.yaml
Normal file
@@ -0,0 +1,583 @@
|
|||||||
|
openapi: 3.0.0
|
||||||
|
info:
|
||||||
|
title: Анонимные опросы API
|
||||||
|
description: API для работы с системой анонимных опросов
|
||||||
|
version: 1.0.0
|
||||||
|
servers:
|
||||||
|
- url: /questioneer/api
|
||||||
|
description: Базовый URL API
|
||||||
|
paths:
|
||||||
|
/questionnaires:
|
||||||
|
get:
|
||||||
|
summary: Получить список опросов пользователя
|
||||||
|
description: Возвращает список всех опросов, сохраненных в локальном хранилище браузера
|
||||||
|
operationId: getQuestionnaires
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Успешный запрос
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/QuestionnairesResponse'
|
||||||
|
post:
|
||||||
|
summary: Создать новый опрос
|
||||||
|
description: Создает новый опрос с указанными параметрами
|
||||||
|
operationId: createQuestionnaire
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/QuestionnaireCreate'
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Опрос успешно создан
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/QuestionnaireResponse'
|
||||||
|
/questionnaires/public/{publicLink}:
|
||||||
|
get:
|
||||||
|
summary: Получить опрос для участия
|
||||||
|
description: Возвращает данные опроса по публичной ссылке
|
||||||
|
operationId: getPublicQuestionnaire
|
||||||
|
parameters:
|
||||||
|
- name: publicLink
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Успешный запрос
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/QuestionnaireResponse'
|
||||||
|
'404':
|
||||||
|
description: Опрос не найден
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ErrorResponse'
|
||||||
|
/questionnaires/admin/{adminLink}:
|
||||||
|
get:
|
||||||
|
summary: Получить опрос для редактирования и просмотра результатов
|
||||||
|
description: Возвращает данные опроса по административной ссылке
|
||||||
|
operationId: getAdminQuestionnaire
|
||||||
|
parameters:
|
||||||
|
- name: adminLink
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Успешный запрос
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/QuestionnaireResponse'
|
||||||
|
'404':
|
||||||
|
description: Опрос не найден
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ErrorResponse'
|
||||||
|
put:
|
||||||
|
summary: Обновить опрос
|
||||||
|
description: Обновляет существующий опрос
|
||||||
|
operationId: updateQuestionnaire
|
||||||
|
parameters:
|
||||||
|
- name: adminLink
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/QuestionnaireUpdate'
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Опрос успешно обновлен
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/QuestionnaireResponse'
|
||||||
|
'404':
|
||||||
|
description: Опрос не найден
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ErrorResponse'
|
||||||
|
delete:
|
||||||
|
summary: Удалить опрос
|
||||||
|
description: Удаляет опрос вместе со всеми ответами
|
||||||
|
operationId: deleteQuestionnaire
|
||||||
|
parameters:
|
||||||
|
- name: adminLink
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Опрос успешно удален
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/SuccessResponse'
|
||||||
|
'404':
|
||||||
|
description: Опрос не найден
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ErrorResponse'
|
||||||
|
/vote/{publicLink}:
|
||||||
|
post:
|
||||||
|
summary: Отправить ответы на опрос
|
||||||
|
description: Отправляет ответы пользователя на опрос
|
||||||
|
operationId: submitVote
|
||||||
|
parameters:
|
||||||
|
- name: publicLink
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/VoteRequest'
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Ответы успешно отправлены
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/SuccessResponse'
|
||||||
|
'404':
|
||||||
|
description: Опрос не найден
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ErrorResponse'
|
||||||
|
/results/{publicLink}:
|
||||||
|
get:
|
||||||
|
summary: Получить результаты опроса
|
||||||
|
description: Возвращает текущие результаты опроса
|
||||||
|
operationId: getResults
|
||||||
|
parameters:
|
||||||
|
- name: publicLink
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Успешный запрос
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ResultsResponse'
|
||||||
|
'404':
|
||||||
|
description: Опрос не найден
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ErrorResponse'
|
||||||
|
components:
|
||||||
|
schemas:
|
||||||
|
QuestionnaireCreate:
|
||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- title
|
||||||
|
- questions
|
||||||
|
properties:
|
||||||
|
title:
|
||||||
|
type: string
|
||||||
|
description: Название опроса
|
||||||
|
description:
|
||||||
|
type: string
|
||||||
|
description: Описание опроса
|
||||||
|
questions:
|
||||||
|
type: array
|
||||||
|
description: Список вопросов
|
||||||
|
items:
|
||||||
|
$ref: '#/components/schemas/Question'
|
||||||
|
displayType:
|
||||||
|
type: string
|
||||||
|
description: Тип отображения опроса
|
||||||
|
enum: [standard, step_by_step]
|
||||||
|
default: standard
|
||||||
|
QuestionnaireUpdate:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
title:
|
||||||
|
type: string
|
||||||
|
description: Название опроса
|
||||||
|
description:
|
||||||
|
type: string
|
||||||
|
description: Описание опроса
|
||||||
|
questions:
|
||||||
|
type: array
|
||||||
|
description: Список вопросов
|
||||||
|
items:
|
||||||
|
$ref: '#/components/schemas/Question'
|
||||||
|
displayType:
|
||||||
|
type: string
|
||||||
|
description: Тип отображения опроса
|
||||||
|
enum: [standard, step_by_step]
|
||||||
|
Question:
|
||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- text
|
||||||
|
- type
|
||||||
|
properties:
|
||||||
|
text:
|
||||||
|
type: string
|
||||||
|
description: Текст вопроса
|
||||||
|
type:
|
||||||
|
type: string
|
||||||
|
description: Тип вопроса
|
||||||
|
enum: [single, multiple, text, scale, rating, tagcloud]
|
||||||
|
required:
|
||||||
|
type: boolean
|
||||||
|
description: Является ли вопрос обязательным
|
||||||
|
default: false
|
||||||
|
options:
|
||||||
|
type: array
|
||||||
|
description: Варианты ответа (для single, multiple)
|
||||||
|
items:
|
||||||
|
$ref: '#/components/schemas/Option'
|
||||||
|
tags:
|
||||||
|
type: array
|
||||||
|
description: Список тегов (для tagcloud)
|
||||||
|
items:
|
||||||
|
$ref: '#/components/schemas/Tag'
|
||||||
|
scaleMin:
|
||||||
|
type: integer
|
||||||
|
description: Минимальное значение шкалы (для scale)
|
||||||
|
default: 0
|
||||||
|
scaleMax:
|
||||||
|
type: integer
|
||||||
|
description: Максимальное значение шкалы (для scale)
|
||||||
|
default: 10
|
||||||
|
scaleMinLabel:
|
||||||
|
type: string
|
||||||
|
description: Метка для минимального значения шкалы
|
||||||
|
default: "Минимум"
|
||||||
|
scaleMaxLabel:
|
||||||
|
type: string
|
||||||
|
description: Метка для максимального значения шкалы
|
||||||
|
default: "Максимум"
|
||||||
|
Option:
|
||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- text
|
||||||
|
properties:
|
||||||
|
text:
|
||||||
|
type: string
|
||||||
|
description: Текст варианта ответа
|
||||||
|
votes:
|
||||||
|
type: integer
|
||||||
|
description: Количество голосов за этот вариант
|
||||||
|
default: 0
|
||||||
|
Tag:
|
||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- text
|
||||||
|
properties:
|
||||||
|
text:
|
||||||
|
type: string
|
||||||
|
description: Текст тега
|
||||||
|
count:
|
||||||
|
type: integer
|
||||||
|
description: Количество выборов данного тега
|
||||||
|
default: 0
|
||||||
|
VoteRequest:
|
||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- answers
|
||||||
|
properties:
|
||||||
|
answers:
|
||||||
|
type: array
|
||||||
|
description: Список ответов пользователя
|
||||||
|
items:
|
||||||
|
$ref: '#/components/schemas/Answer'
|
||||||
|
Answer:
|
||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- questionIndex
|
||||||
|
properties:
|
||||||
|
questionIndex:
|
||||||
|
type: integer
|
||||||
|
description: Индекс вопроса
|
||||||
|
optionIndices:
|
||||||
|
type: array
|
||||||
|
description: Индексы выбранных вариантов (для single, multiple)
|
||||||
|
items:
|
||||||
|
type: integer
|
||||||
|
textAnswer:
|
||||||
|
type: string
|
||||||
|
description: Текстовый ответ пользователя (для text)
|
||||||
|
scaleValue:
|
||||||
|
type: integer
|
||||||
|
description: Значение шкалы (для scale, rating)
|
||||||
|
tagTexts:
|
||||||
|
type: array
|
||||||
|
description: Тексты выбранных или введенных тегов (для tagcloud)
|
||||||
|
items:
|
||||||
|
type: string
|
||||||
|
QuestionnairesResponse:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
success:
|
||||||
|
type: boolean
|
||||||
|
description: Успешность запроса
|
||||||
|
data:
|
||||||
|
type: array
|
||||||
|
description: Список опросов
|
||||||
|
items:
|
||||||
|
$ref: '#/components/schemas/QuestionnaireInfo'
|
||||||
|
QuestionnaireResponse:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
success:
|
||||||
|
type: boolean
|
||||||
|
description: Успешность запроса
|
||||||
|
data:
|
||||||
|
$ref: '#/components/schemas/QuestionnaireData'
|
||||||
|
QuestionnaireInfo:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
title:
|
||||||
|
type: string
|
||||||
|
description: Название опроса
|
||||||
|
description:
|
||||||
|
type: string
|
||||||
|
description: Описание опроса
|
||||||
|
adminLink:
|
||||||
|
type: string
|
||||||
|
description: Административная ссылка
|
||||||
|
publicLink:
|
||||||
|
type: string
|
||||||
|
description: Публичная ссылка
|
||||||
|
createdAt:
|
||||||
|
type: string
|
||||||
|
format: date-time
|
||||||
|
description: Дата создания опроса
|
||||||
|
updatedAt:
|
||||||
|
type: string
|
||||||
|
format: date-time
|
||||||
|
description: Дата последнего обновления опроса
|
||||||
|
QuestionnaireData:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
_id:
|
||||||
|
type: string
|
||||||
|
description: Идентификатор опроса
|
||||||
|
title:
|
||||||
|
type: string
|
||||||
|
description: Название опроса
|
||||||
|
description:
|
||||||
|
type: string
|
||||||
|
description: Описание опроса
|
||||||
|
questions:
|
||||||
|
type: array
|
||||||
|
description: Список вопросов
|
||||||
|
items:
|
||||||
|
$ref: '#/components/schemas/QuestionData'
|
||||||
|
displayType:
|
||||||
|
type: string
|
||||||
|
description: Тип отображения опроса
|
||||||
|
enum: [standard, step_by_step]
|
||||||
|
adminLink:
|
||||||
|
type: string
|
||||||
|
description: Административная ссылка
|
||||||
|
publicLink:
|
||||||
|
type: string
|
||||||
|
description: Публичная ссылка
|
||||||
|
createdAt:
|
||||||
|
type: string
|
||||||
|
format: date-time
|
||||||
|
description: Дата создания опроса
|
||||||
|
updatedAt:
|
||||||
|
type: string
|
||||||
|
format: date-time
|
||||||
|
description: Дата последнего обновления опроса
|
||||||
|
QuestionData:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
_id:
|
||||||
|
type: string
|
||||||
|
description: Идентификатор вопроса
|
||||||
|
text:
|
||||||
|
type: string
|
||||||
|
description: Текст вопроса
|
||||||
|
type:
|
||||||
|
type: string
|
||||||
|
description: Тип вопроса
|
||||||
|
required:
|
||||||
|
type: boolean
|
||||||
|
description: Является ли вопрос обязательным
|
||||||
|
options:
|
||||||
|
type: array
|
||||||
|
description: Варианты ответа (для single, multiple)
|
||||||
|
items:
|
||||||
|
$ref: '#/components/schemas/OptionData'
|
||||||
|
tags:
|
||||||
|
type: array
|
||||||
|
description: Список тегов (для tagcloud)
|
||||||
|
items:
|
||||||
|
$ref: '#/components/schemas/TagData'
|
||||||
|
scaleMin:
|
||||||
|
type: integer
|
||||||
|
description: Минимальное значение шкалы (для scale)
|
||||||
|
scaleMax:
|
||||||
|
type: integer
|
||||||
|
description: Максимальное значение шкалы (для scale)
|
||||||
|
scaleMinLabel:
|
||||||
|
type: string
|
||||||
|
description: Метка для минимального значения шкалы
|
||||||
|
scaleMaxLabel:
|
||||||
|
type: string
|
||||||
|
description: Метка для максимального значения шкалы
|
||||||
|
answers:
|
||||||
|
type: array
|
||||||
|
description: Текстовые ответы (для text)
|
||||||
|
items:
|
||||||
|
type: string
|
||||||
|
scaleValues:
|
||||||
|
type: array
|
||||||
|
description: Значения шкалы от пользователей (для scale, rating)
|
||||||
|
items:
|
||||||
|
type: integer
|
||||||
|
textAnswers:
|
||||||
|
type: array
|
||||||
|
description: Текстовые ответы (для text)
|
||||||
|
items:
|
||||||
|
type: string
|
||||||
|
responses:
|
||||||
|
type: array
|
||||||
|
description: Значения шкалы от пользователей (для scale, rating)
|
||||||
|
items:
|
||||||
|
type: integer
|
||||||
|
OptionData:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
_id:
|
||||||
|
type: string
|
||||||
|
description: Идентификатор варианта ответа
|
||||||
|
text:
|
||||||
|
type: string
|
||||||
|
description: Текст варианта ответа
|
||||||
|
votes:
|
||||||
|
type: integer
|
||||||
|
description: Количество голосов за этот вариант
|
||||||
|
count:
|
||||||
|
type: integer
|
||||||
|
description: Альтернативное поле для количества голосов
|
||||||
|
TagData:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
_id:
|
||||||
|
type: string
|
||||||
|
description: Идентификатор тега
|
||||||
|
text:
|
||||||
|
type: string
|
||||||
|
description: Текст тега
|
||||||
|
count:
|
||||||
|
type: integer
|
||||||
|
description: Количество выборов данного тега
|
||||||
|
ResultsResponse:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
success:
|
||||||
|
type: boolean
|
||||||
|
description: Успешность запроса
|
||||||
|
data:
|
||||||
|
$ref: '#/components/schemas/ResultsData'
|
||||||
|
ResultsData:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
questions:
|
||||||
|
type: array
|
||||||
|
description: Список вопросов с результатами
|
||||||
|
items:
|
||||||
|
$ref: '#/components/schemas/QuestionResults'
|
||||||
|
QuestionResults:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
text:
|
||||||
|
type: string
|
||||||
|
description: Текст вопроса
|
||||||
|
type:
|
||||||
|
type: string
|
||||||
|
description: Тип вопроса
|
||||||
|
options:
|
||||||
|
type: array
|
||||||
|
description: Варианты ответа с количеством голосов (для single, multiple)
|
||||||
|
items:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
text:
|
||||||
|
type: string
|
||||||
|
description: Текст варианта ответа
|
||||||
|
count:
|
||||||
|
type: integer
|
||||||
|
description: Количество голосов
|
||||||
|
tags:
|
||||||
|
type: array
|
||||||
|
description: Список тегов с количеством выборов (для tagcloud)
|
||||||
|
items:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
text:
|
||||||
|
type: string
|
||||||
|
description: Текст тега
|
||||||
|
count:
|
||||||
|
type: integer
|
||||||
|
description: Количество выборов
|
||||||
|
scaleValues:
|
||||||
|
type: array
|
||||||
|
description: Значения шкалы от пользователей (для scale, rating)
|
||||||
|
items:
|
||||||
|
type: integer
|
||||||
|
scaleAverage:
|
||||||
|
type: number
|
||||||
|
description: Среднее значение шкалы (для scale, rating)
|
||||||
|
answers:
|
||||||
|
type: array
|
||||||
|
description: Текстовые ответы (для text)
|
||||||
|
items:
|
||||||
|
type: string
|
||||||
|
responses:
|
||||||
|
type: array
|
||||||
|
description: Значения шкалы от пользователей (для scale, rating)
|
||||||
|
items:
|
||||||
|
type: integer
|
||||||
|
SuccessResponse:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
success:
|
||||||
|
type: boolean
|
||||||
|
description: Успешность запроса
|
||||||
|
example: true
|
||||||
|
message:
|
||||||
|
type: string
|
||||||
|
description: Сообщение об успешном выполнении
|
||||||
|
ErrorResponse:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
success:
|
||||||
|
type: boolean
|
||||||
|
description: Успешность запроса
|
||||||
|
example: false
|
||||||
|
error:
|
||||||
|
type: string
|
||||||
|
description: Сообщение об ошибке
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user