diff --git a/package-lock.json b/package-lock.json index a43c05c..cbf818f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,7 +15,7 @@ "ai": "^4.1.13", "axios": "^1.7.7", "bcrypt": "^5.1.0", - "bcryptjs": "^3.0.2", + "bcryptjs": "^3.0.3", "body-parser": "^1.19.0", "cookie-parser": "^1.4.5", "cors": "^2.8.5", @@ -3723,9 +3723,9 @@ } }, "node_modules/bcryptjs": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-3.0.2.tgz", - "integrity": "sha512-k38b3XOZKv60C4E2hVsXTolJWfkGRMbILBIe2IBITXciy5bOsTKot5kDrf3ZfufQtQOUN5mXceUEpU1rTl9Uog==", + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-3.0.3.tgz", + "integrity": "sha512-GlF5wPWnSa/X5LKM1o0wz0suXIINz1iHRLvTS+sLyi7XPbe5ycmYI3DlZqVGZZtDgl4DmasFg7gOB3JYbphV5g==", "license": "BSD-3-Clause", "bin": { "bcrypt": "bin/bcrypt" diff --git a/package.json b/package.json index a691838..cf42802 100644 --- a/package.json +++ b/package.json @@ -27,7 +27,7 @@ "ai": "^4.1.13", "axios": "^1.7.7", "bcrypt": "^5.1.0", - "bcryptjs": "^3.0.2", + "bcryptjs": "^3.0.3", "body-parser": "^1.19.0", "cookie-parser": "^1.4.5", "cors": "^2.8.5", diff --git a/server/index.ts b/server/index.ts index 6ed51c4..46aef65 100644 --- a/server/index.ts +++ b/server/index.ts @@ -21,6 +21,7 @@ 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() @@ -107,6 +108,7 @@ const initServer = async () => { app.use('/connectme', connectmeRouter) app.use('/questioneer', questioneerRouter) app.use('/procurement', procurementRouter) + app.use('/smoke-tracker', smokeTrackerRouter) app.use(errorHandler) // Создаем обычный HTTP сервер diff --git a/server/routers/smoke-tracker/API.md b/server/routers/smoke-tracker/API.md new file mode 100644 index 0000000..3d2e16d --- /dev/null +++ b/server/routers/smoke-tracker/API.md @@ -0,0 +1,626 @@ +# Smoke Tracker API — Документация для Frontend + +## Базовый URL + +``` +http://localhost:8044/smoke-tracker +``` + +В production окружении замените на соответствующий домен. + +--- + +## Оглавление + +1. [Авторизация](#авторизация) + - [Регистрация](#post-authsignup) + - [Вход](#post-authsignin) +2. [Логирование сигарет](#логирование-сигарет) + - [Записать сигарету](#post-cigarettes) + - [Получить список сигарет](#get-cigarettes) +3. [Статистика](#статистика) + - [Дневная статистика](#get-statsdaily) + +--- + +## Авторизация + +Все эндпоинты, кроме `/auth/signup` и `/auth/signin`, требуют JWT-токен в заголовке: + +``` +Authorization: Bearer +``` + +Токен возвращается при успешном входе (`/auth/signin`) и действителен **12 часов**. + +--- + +### `POST /auth/signup` + +**Описание**: Регистрация нового пользователя + +**Требуется авторизация**: ❌ Нет + +**Тело запроса** (JSON): + +```json +{ + "login": "string", // обязательно, уникальный логин + "password": "string" // обязательно +} +``` + +**Пример запроса**: + +```bash +curl -X POST http://localhost:8044/smoke-tracker/auth/signup \ + -H "Content-Type: application/json" \ + -d '{ + "login": "user123", + "password": "mySecurePassword" + }' +``` + +**Ответ при успехе** (200 OK): + +```json +{ + "success": true, + "body": { + "ok": true + } +} +``` + +**Возможные ошибки**: + +- **400 Bad Request**: `"Не все поля заполнены: login, password"` — не указаны обязательные поля +- **500 Internal Server Error**: `"Пользователь с таким логином уже существует"` — логин занят + +--- + +### `POST /auth/signin` + +**Описание**: Вход в систему (получение JWT-токена) + +**Требуется авторизация**: ❌ Нет + +**Тело запроса** (JSON): + +```json +{ + "login": "string", // обязательно + "password": "string" // обязательно +} +``` + +**Пример запроса**: + +```bash +curl -X POST http://localhost:8044/smoke-tracker/auth/signin \ + -H "Content-Type: application/json" \ + -d '{ + "login": "user123", + "password": "mySecurePassword" + }' +``` + +**Ответ при успехе** (200 OK): + +```json +{ + "success": true, + "body": { + "user": { + "id": "507f1f77bcf86cd799439011", + "login": "user123", + "created": "2024-01-15T10:30:00.000Z" + }, + "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." + } +} +``` + +**Поля ответа**: + +- `user.id` — уникальный идентификатор пользователя +- `user.login` — логин пользователя +- `user.created` — дата создания аккаунта (ISO 8601) +- `token` — JWT-токен для авторизации (действителен 12 часов) + +**Возможные ошибки**: + +- **400 Bad Request**: `"Не все поля заполнены: login, password"` — не указаны обязательные поля +- **500 Internal Server Error**: `"Неверный логин или пароль"` — неправильные учётные данные + +**Использование токена**: + +Сохраните токен в localStorage/sessionStorage/cookie и передавайте в заголовке всех последующих запросов: + +```javascript +// Пример для fetch API +const token = localStorage.getItem('smokeToken'); + +fetch('http://localhost:8044/smoke-tracker/cigarettes', { + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json' + } +}); +``` + +--- + +## Логирование сигарет + +### `POST /cigarettes` + +**Описание**: Записать факт выкуренной сигареты + +**Требуется авторизация**: ✅ Да (Bearer token) + +**Тело запроса** (JSON): + +```json +{ + "smokedAt": "string (ISO 8601)", // необязательно, по умолчанию — текущее время + "note": "string" // необязательно, заметка/комментарий +} +``` + +**Пример запроса**: + +```bash +curl -X POST http://localhost:8044/smoke-tracker/cigarettes \ + -H "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." \ + -H "Content-Type: application/json" \ + -d '{ + "smokedAt": "2024-01-15T14:30:00.000Z", + "note": "После обеда" + }' +``` + +**Пример без указания времени** (будет текущее время): + +```bash +curl -X POST http://localhost:8044/smoke-tracker/cigarettes \ + -H "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." \ + -H "Content-Type: application/json" \ + -d '{}' +``` + +**Ответ при успехе** (200 OK): + +```json +{ + "success": true, + "body": { + "id": "507f1f77bcf86cd799439012", + "userId": "507f1f77bcf86cd799439011", + "smokedAt": "2024-01-15T14:30:00.000Z", + "note": "После обеда", + "created": "2024-01-15T14:30:05.123Z" + } +} +``` + +**Поля ответа**: + +- `id` — уникальный идентификатор записи +- `userId` — ID пользователя +- `smokedAt` — дата и время курения (ISO 8601) +- `note` — заметка (если была указана) +- `created` — дата создания записи в БД + +**Возможные ошибки**: + +- **401 Unauthorized**: `"Требуется авторизация"` — не передан токен +- **401 Unauthorized**: `"Неверный или истекший токен авторизации"` — токен невалидный/просрочен +- **400 Bad Request**: `"Некорректный формат даты smokedAt"` — неверный формат даты + +--- + +### `GET /cigarettes` + +**Описание**: Получить список всех выкуренных сигарет текущего пользователя + +**Требуется авторизация**: ✅ Да (Bearer token) + +**Query-параметры** (все необязательные): + +| Параметр | Тип | Описание | Пример | +|----------|-----|----------|--------| +| `from` | string (ISO 8601) | Начало периода (включительно) | `2024-01-01T00:00:00.000Z` | +| `to` | string (ISO 8601) | Конец периода (включительно) | `2024-01-31T23:59:59.999Z` | + +**Пример запроса** (все сигареты): + +```bash +curl -X GET http://localhost:8044/smoke-tracker/cigarettes \ + -H "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." +``` + +**Пример запроса** (с фильтрацией по датам): + +```bash +curl -X GET "http://localhost:8044/smoke-tracker/cigarettes?from=2024-01-01T00:00:00.000Z&to=2024-01-31T23:59:59.999Z" \ + -H "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." +``` + +**Ответ при успехе** (200 OK): + +```json +{ + "success": true, + "body": [ + { + "id": "507f1f77bcf86cd799439012", + "userId": "507f1f77bcf86cd799439011", + "smokedAt": "2024-01-15T10:30:00.000Z", + "note": "Утренняя", + "created": "2024-01-15T10:30:05.123Z" + }, + { + "id": "507f1f77bcf86cd799439013", + "userId": "507f1f77bcf86cd799439011", + "smokedAt": "2024-01-15T14:30:00.000Z", + "note": "После обеда", + "created": "2024-01-15T14:30:05.456Z" + } + ] +} +``` + +**Особенности**: + +- Записи отсортированы по `smokedAt` (от старых к новым) +- Если указаны `from` и/или `to`, будет применена фильтрация +- Пустой массив возвращается, если сигарет в периоде нет + +**Возможные ошибки**: + +- **401 Unauthorized**: `"Требуется авторизация"` — не передан токен +- **401 Unauthorized**: `"Неверный или истекший токен авторизации"` — токен невалидный/просрочен + +--- + +## Статистика + +### `GET /stats/daily` + +**Описание**: Получить дневную статистику по количеству сигарет для построения графика + +**Требуется авторизация**: ✅ Да (Bearer token) + +**Query-параметры** (все необязательные): + +| Параметр | Тип | Описание | Пример | По умолчанию | +|----------|-----|----------|--------|--------------| +| `from` | string (ISO 8601) | Начало периода | `2024-01-01T00:00:00.000Z` | 30 дней назад от текущей даты | +| `to` | string (ISO 8601) | Конец периода | `2024-01-31T23:59:59.999Z` | Текущая дата и время | + +**Пример запроса** (последние 30 дней): + +```bash +curl -X GET http://localhost:8044/smoke-tracker/stats/daily \ + -H "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." +``` + +**Пример запроса** (с указанием периода): + +```bash +curl -X GET "http://localhost:8044/smoke-tracker/stats/daily?from=2024-01-01T00:00:00.000Z&to=2024-01-31T23:59:59.999Z" \ + -H "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." +``` + +**Ответ при успехе** (200 OK): + +```json +{ + "success": true, + "body": [ + { + "date": "2024-01-15", + "count": 8 + }, + { + "date": "2024-01-16", + "count": 12 + }, + { + "date": "2024-01-17", + "count": 5 + } + ] +} +``` + +**Поля ответа**: + +- `date` — дата в формате `YYYY-MM-DD` +- `count` — количество сигарет, выкуренных в этот день + +**Особенности**: + +- Данные отсортированы по дате (от старых к новым) +- Дни без сигарет **не включаются** в ответ (фронтенду нужно самостоятельно заполнить пропуски нулями при построении графика) +- Агрегация происходит по дате из поля `smokedAt` (не `created`) + +**Пример использования для графика** (Chart.js): + +```javascript +const response = await fetch('http://localhost:8044/smoke-tracker/stats/daily', { + headers: { + 'Authorization': `Bearer ${token}` + } +}); + +const { body } = await response.json(); + +// Заполнение пропущенных дней нулями +const fillMissingDates = (data, from, to) => { + const result = []; + const current = new Date(from); + const end = new Date(to); + + while (current <= end) { + const dateStr = current.toISOString().split('T')[0]; + const existing = data.find(d => d.date === dateStr); + + result.push({ + date: dateStr, + count: existing ? existing.count : 0 + }); + + current.setDate(current.getDate() + 1); + } + + return result; +}; + +const filledData = fillMissingDates(body, '2024-01-01', '2024-01-31'); + +// Данные для графика +const chartData = { + labels: filledData.map(d => d.date), + datasets: [{ + label: 'Количество сигарет', + data: filledData.map(d => d.count), + borderColor: 'rgb(255, 99, 132)', + backgroundColor: 'rgba(255, 99, 132, 0.2)', + }] +}; +``` + +**Возможные ошибки**: + +- **401 Unauthorized**: `"Требуется авторизация"` — не передан токен +- **401 Unauthorized**: `"Неверный или истекший токен авторизации"` — токен невалидный/просрочен + +--- + +## Общая структура ответов + +Все эндпоинты возвращают JSON в следующем формате: + +**Успешный ответ**: + +```json +{ + "success": true, + "body": { /* данные */ } +} +``` + +**Ответ с ошибкой**: + +```json +{ + "success": false, + "errors": "Описание ошибки" +} +``` + +или (при использовании глобального обработчика ошибок): + +```json +{ + "message": "Описание ошибки" +} +``` + +--- + +## Коды состояния HTTP + +| Код | Описание | +|-----|----------| +| **200 OK** | Запрос выполнен успешно | +| **400 Bad Request** | Некорректные данные в запросе | +| **401 Unauthorized** | Требуется авторизация или токен невалидный | +| **500 Internal Server Error** | Внутренняя ошибка сервера | + +--- + +## Примеры интеграции + +### React + Axios + +```javascript +import axios from 'axios'; + +const API_BASE_URL = 'http://localhost:8044/smoke-tracker'; + +// Создание экземпляра axios с базовыми настройками +const api = axios.create({ + baseURL: API_BASE_URL, + headers: { + 'Content-Type': 'application/json' + } +}); + +// Интерцептор для добавления токена +api.interceptors.request.use(config => { + const token = localStorage.getItem('smokeToken'); + if (token) { + config.headers.Authorization = `Bearer ${token}`; + } + return config; +}); + +// Регистрация +export const signup = async (login, password) => { + const { data } = await api.post('/auth/signup', { login, password }); + return data; +}; + +// Вход +export const signin = async (login, password) => { + const { data } = await api.post('/auth/signin', { login, password }); + if (data.success) { + localStorage.setItem('smokeToken', data.body.token); + } + return data; +}; + +// Выход +export const signout = () => { + localStorage.removeItem('smokeToken'); +}; + +// Записать сигарету +export const logCigarette = async (smokedAt = null, note = '') => { + const { data } = await api.post('/cigarettes', { smokedAt, note }); + return data; +}; + +// Получить список сигарет +export const getCigarettes = async (from = null, to = null) => { + const params = {}; + if (from) params.from = from; + if (to) params.to = to; + + const { data } = await api.get('/cigarettes', { params }); + return data; +}; + +// Получить дневную статистику +export const getDailyStats = async (from = null, to = null) => { + const params = {}; + if (from) params.from = from; + if (to) params.to = to; + + const { data } = await api.get('/stats/daily', { params }); + return data; +}; +``` + +### Vanilla JavaScript + Fetch + +```javascript +const API_BASE_URL = 'http://localhost:8044/smoke-tracker'; + +// Получение токена +const getToken = () => localStorage.getItem('smokeToken'); + +// Базовый запрос +const apiRequest = async (endpoint, options = {}) => { + const token = getToken(); + + const headers = { + 'Content-Type': 'application/json', + ...options.headers + }; + + if (token) { + headers.Authorization = `Bearer ${token}`; + } + + const response = await fetch(`${API_BASE_URL}${endpoint}`, { + ...options, + headers + }); + + if (!response.ok) { + const error = await response.json(); + throw new Error(error.message || error.errors || 'Ошибка запроса'); + } + + return response.json(); +}; + +// Регистрация +async function signup(login, password) { + return apiRequest('/auth/signup', { + method: 'POST', + body: JSON.stringify({ login, password }) + }); +} + +// Вход +async function signin(login, password) { + const data = await apiRequest('/auth/signin', { + method: 'POST', + body: JSON.stringify({ login, password }) + }); + + if (data.success) { + localStorage.setItem('smokeToken', data.body.token); + } + + return data; +} + +// Записать сигарету +async function logCigarette(note = '') { + return apiRequest('/cigarettes', { + method: 'POST', + body: JSON.stringify({ note }) + }); +} + +// Получить дневную статистику +async function getDailyStats() { + return apiRequest('/stats/daily'); +} +``` + +--- + +## Рекомендации по безопасности + +1. **Хранение токена**: + - Для веб-приложений: используйте `httpOnly` cookies или `sessionStorage` + - Избегайте `localStorage` при работе с чувствительными данными + - Для мобильных приложений: используйте безопасное хранилище (Keychain/Keystore) + +2. **HTTPS**: В production всегда используйте HTTPS для защиты токена при передаче + +3. **Обработка истечения токена**: + - Токен действителен 12 часов + - При получении ошибки 401 перенаправляйте пользователя на страницу входа + - Реализуйте механизм refresh token для бесшовного обновления + +4. **Валидация на фронтенде**: + - Проверяйте корректность email/логина перед отправкой + - Требуйте минимальную длину пароля (8+ символов) + - Показывайте индикатор силы пароля + +--- + +## Postman-коллекция + +Готовая коллекция для тестирования доступна в файле: + +``` +server/routers/smoke-tracker/postman/smoke-tracker.postman_collection.json +``` + +Импортируйте её в Postman для быстрого тестирования всех эндпоинтов. + +--- + +## Поддержка + +При возникновении вопросов или обнаружении проблем обращайтесь к разработчикам backend-команды. + diff --git a/server/routers/smoke-tracker/auth.js b/server/routers/smoke-tracker/auth.js new file mode 100644 index 0000000..b522812 --- /dev/null +++ b/server/routers/smoke-tracker/auth.js @@ -0,0 +1,89 @@ +const { Router } = require('express') +const hash = require('pbkdf2-password')() +const { promisify } = require('node:util') +const jwt = require('jsonwebtoken') + +const { getAnswer } = require('../../utils/common') + +const { SmokeAuthModel } = require('./model/auth') +const { SmokeUserModel } = require('./model/user') +const { SMOKE_TRACKER_TOKEN_KEY } = require('./const') +const { requiredValidate } = require('./utils') + +const router = Router() + +router.post( + '/signup', + requiredValidate('login', 'password'), + async (req, res, next) => { + const { login, password } = req.body + + const existing = await SmokeAuthModel.findOne({ login }) + + if (existing) { + throw new Error('Пользователь с таким логином уже существует') + } + + hash({ password }, async function (err, pass, salt, hashValue) { + if (err) return next(err) + + const user = await SmokeUserModel.create({ login }) + await SmokeAuthModel.create({ login, hash: hashValue, salt, userId: user.id }) + + res.json(getAnswer(null, { ok: true })) + }) + } +) + +function authenticate(login, pass, cb) { + SmokeAuthModel.findOne({ login }) + .populate('userId') + .exec() + .then((user) => { + if (!user) return cb(null, null) + + hash({ password: pass, salt: user.salt }, function (err, pass, salt, hashValue) { + if (err) return cb(err) + if (hashValue === user.hash) return cb(null, user) + cb(null, null) + }) + }) + .catch((err) => cb(err)) +} + +const auth = promisify(authenticate) + +router.post( + '/signin', + requiredValidate('login', 'password'), + async (req, res) => { + const { login, password } = req.body + + const user = await auth(login, password) + + if (!user) { + throw new Error('Неверный логин или пароль') + } + + const accessToken = jwt.sign( + { + ...JSON.parse(JSON.stringify(user.userId)), + }, + SMOKE_TRACKER_TOKEN_KEY, + { + expiresIn: '12h', + } + ) + + res.json( + getAnswer(null, { + user: user.userId, + token: accessToken, + }) + ) + } +) + +module.exports = router + + diff --git a/server/routers/smoke-tracker/cigarettes.js b/server/routers/smoke-tracker/cigarettes.js new file mode 100644 index 0000000..1cec10c --- /dev/null +++ b/server/routers/smoke-tracker/cigarettes.js @@ -0,0 +1,75 @@ +const { Router } = require('express') + +const { getAnswer } = require('../../utils/common') +const { CigaretteModel } = require('./model/cigarette') +const { authMiddleware } = require('./middleware/auth') + +const router = Router() + +// Все эндпоинты ниже требуют авторизации +router.use(authMiddleware) + +// Логирование одной сигареты +router.post('/', async (req, res, next) => { + try { + const { smokedAt, note } = req.body || {} + const user = req.user + + let date + if (smokedAt) { + const parsed = new Date(smokedAt) + if (Number.isNaN(parsed.getTime())) { + throw new Error('Некорректный формат даты smokedAt') + } + date = parsed + } else { + date = new Date() + } + + const item = await CigaretteModel.create({ + userId: user.id, + smokedAt: date, + note, + }) + + res.json(getAnswer(null, item)) + } catch (err) { + next(err) + } +}) + +// Получение списка сигарет пользователя (для отладки и таблиц) +router.get('/', async (req, res, next) => { + try { + const user = req.user + const { from, to } = req.query + + const filter = { userId: user.id } + + if (from || to) { + filter.smokedAt = {} + if (from) { + const fromDate = new Date(from) + if (!Number.isNaN(fromDate.getTime())) { + filter.smokedAt.$gte = fromDate + } + } + if (to) { + const toDate = new Date(to) + if (!Number.isNaN(toDate.getTime())) { + filter.smokedAt.$lte = toDate + } + } + } + + const items = await CigaretteModel.find(filter).sort({ smokedAt: 1 }) + + res.json(getAnswer(null, items)) + } catch (err) { + next(err) + } +}) + +module.exports = router + + diff --git a/server/routers/smoke-tracker/const.js b/server/routers/smoke-tracker/const.js new file mode 100644 index 0000000..1b089d8 --- /dev/null +++ b/server/routers/smoke-tracker/const.js @@ -0,0 +1,9 @@ +exports.SMOKE_TRACKER_USER_MODEL_NAME = 'SMOKE_TRACKER_USER' +exports.SMOKE_TRACKER_AUTH_MODEL_NAME = 'SMOKE_TRACKER_AUTH' +exports.SMOKE_TRACKER_CIGARETTE_MODEL_NAME = 'SMOKE_TRACKER_CIGARETTE' + +exports.SMOKE_TRACKER_TOKEN_KEY = + process.env.SMOKE_TRACKER_TOKEN_KEY || + 'smoke-tracker-secret-key-change-me' + + diff --git a/server/routers/smoke-tracker/index.js b/server/routers/smoke-tracker/index.js new file mode 100644 index 0000000..f3558c6 --- /dev/null +++ b/server/routers/smoke-tracker/index.js @@ -0,0 +1,13 @@ +const router = require('express').Router() + +const authRouter = require('./auth') +const cigarettesRouter = require('./cigarettes') +const statsRouter = require('./stats') + +router.use('/auth', authRouter) +router.use('/cigarettes', cigarettesRouter) +router.use('/stats', statsRouter) + +module.exports = router + + diff --git a/server/routers/smoke-tracker/middleware/auth.js b/server/routers/smoke-tracker/middleware/auth.js new file mode 100644 index 0000000..d762641 --- /dev/null +++ b/server/routers/smoke-tracker/middleware/auth.js @@ -0,0 +1,26 @@ +const jwt = require('jsonwebtoken') + +const { SMOKE_TRACKER_TOKEN_KEY } = require('../const') + +const authMiddleware = (req, res, next) => { + const authHeader = req.headers.authorization || '' + const token = authHeader.startsWith('Bearer ') + ? authHeader.slice(7) + : null + + if (!token) { + throw new Error('Требуется авторизация') + } + + try { + const decoded = jwt.verify(token, SMOKE_TRACKER_TOKEN_KEY) + req.user = decoded + next() + } catch (e) { + throw new Error('Неверный или истекший токен авторизации') + } +} + +module.exports.authMiddleware = authMiddleware + + diff --git a/server/routers/smoke-tracker/model/auth.js b/server/routers/smoke-tracker/model/auth.js new file mode 100644 index 0000000..7e701d5 --- /dev/null +++ b/server/routers/smoke-tracker/model/auth.js @@ -0,0 +1,33 @@ +const { Schema, model } = require('mongoose') + +const { + SMOKE_TRACKER_AUTH_MODEL_NAME, + SMOKE_TRACKER_USER_MODEL_NAME, +} = require('../const') + +const schema = new Schema({ + login: { type: String, required: true, unique: true }, + hash: { type: String, required: true }, + salt: { type: String, required: true }, + userId: { type: Schema.Types.ObjectId, ref: SMOKE_TRACKER_USER_MODEL_NAME }, + created: { + type: Date, + default: () => new Date().toISOString(), + }, +}) + +schema.set('toJSON', { + virtuals: true, + versionKey: false, + transform: function (doc, ret) { + delete ret._id + }, +}) + +schema.virtual('id').get(function () { + return this._id.toHexString() +}) + +exports.SmokeAuthModel = model(SMOKE_TRACKER_AUTH_MODEL_NAME, schema) + + diff --git a/server/routers/smoke-tracker/model/cigarette.js b/server/routers/smoke-tracker/model/cigarette.js new file mode 100644 index 0000000..768ac2d --- /dev/null +++ b/server/routers/smoke-tracker/model/cigarette.js @@ -0,0 +1,38 @@ +const { Schema, model } = require('mongoose') + +const { + SMOKE_TRACKER_CIGARETTE_MODEL_NAME, + SMOKE_TRACKER_USER_MODEL_NAME, +} = require('../const') + +const schema = new Schema({ + userId: { type: Schema.Types.ObjectId, ref: SMOKE_TRACKER_USER_MODEL_NAME, required: true }, + smokedAt: { + type: Date, + required: true, + default: () => new Date().toISOString(), + }, + note: { + type: String, + }, + created: { + type: Date, + default: () => new Date().toISOString(), + }, +}) + +schema.set('toJSON', { + virtuals: true, + versionKey: false, + transform: function (doc, ret) { + delete ret._id + }, +}) + +schema.virtual('id').get(function () { + return this._id.toHexString() +}) + +exports.CigaretteModel = model(SMOKE_TRACKER_CIGARETTE_MODEL_NAME, schema) + + diff --git a/server/routers/smoke-tracker/model/user.js b/server/routers/smoke-tracker/model/user.js new file mode 100644 index 0000000..221bbf7 --- /dev/null +++ b/server/routers/smoke-tracker/model/user.js @@ -0,0 +1,27 @@ +const { Schema, model } = require('mongoose') + +const { SMOKE_TRACKER_USER_MODEL_NAME } = require('../const') + +const schema = new Schema({ + login: { type: String, required: true, unique: true }, + created: { + type: Date, + default: () => new Date().toISOString(), + }, +}) + +schema.set('toJSON', { + virtuals: true, + versionKey: false, + transform: function (doc, ret) { + delete ret._id + }, +}) + +schema.virtual('id').get(function () { + return this._id.toHexString() +}) + +exports.SmokeUserModel = model(SMOKE_TRACKER_USER_MODEL_NAME, schema) + + diff --git a/server/routers/smoke-tracker/postman/smoke-tracker.postman_collection.json b/server/routers/smoke-tracker/postman/smoke-tracker.postman_collection.json new file mode 100644 index 0000000..7b522f6 --- /dev/null +++ b/server/routers/smoke-tracker/postman/smoke-tracker.postman_collection.json @@ -0,0 +1,207 @@ +{ + "info": { + "_postman_id": "9d74101d-f788-4dbf-83b3-11c8f9789b73", + "name": "Smoke Tracker", + "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json", + "_exporter_id": "smoke-tracker" + }, + "item": [ + { + "name": "Auth • Signup", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "name": "Content-Type", + "value": "application/json", + "type": "text" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"login\": \"smoker-demo\",\n \"password\": \"secret123\"\n}" + }, + "url": { + "raw": "{{baseUrl}}/smoke-tracker/auth/signup", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "smoke-tracker", + "auth", + "signup" + ] + }, + "description": "Регистрация нового пользователя. Повторный вызов с тем же логином вернёт ошибку." + }, + "response": [] + }, + { + "name": "Auth • Signin", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "const json = pm.response.json();", + "if (json && json.body && json.body.token) {", + " pm.environment.set('smokeToken', json.body.token);", + "}" + ] + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "name": "Content-Type", + "value": "application/json", + "type": "text" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"login\": \"smoker-demo\",\n \"password\": \"secret123\"\n}" + }, + "url": { + "raw": "{{baseUrl}}/smoke-tracker/auth/signin", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "smoke-tracker", + "auth", + "signin" + ] + }, + "description": "Авторизация пользователя. Скрипт тестов сохранит JWT в переменную окружения smokeToken." + }, + "response": [] + }, + { + "name": "Cigarettes • Log entry", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "name": "Content-Type", + "value": "application/json", + "type": "text" + }, + { + "key": "Authorization", + "name": "Authorization", + "value": "Bearer {{smokeToken}}", + "type": "text" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"smokedAt\": \"2025-01-01T09:30:00.000Z\",\n \"note\": \"Первая сигарета за день\"\n}" + }, + "url": { + "raw": "{{baseUrl}}/smoke-tracker/cigarettes", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "smoke-tracker", + "cigarettes" + ] + }, + "description": "Создать запись о выкуренной сигарете. Если smokedAt не указан, сервер использует текущее время." + }, + "response": [] + }, + { + "name": "Cigarettes • List", + "request": { + "method": "GET", + "header": [ + { + "key": "Authorization", + "name": "Authorization", + "value": "Bearer {{smokeToken}}", + "type": "text" + } + ], + "url": { + "raw": "{{baseUrl}}/smoke-tracker/cigarettes?from=2025-01-01T00:00:00.000Z&to=2025-01-07T23:59:59.999Z", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "smoke-tracker", + "cigarettes" + ], + "query": [ + { + "key": "from", + "value": "2025-01-01T00:00:00.000Z" + }, + { + "key": "to", + "value": "2025-01-07T23:59:59.999Z" + } + ] + }, + "description": "Список сигарет текущего пользователя. Параметры from/to необязательны." + }, + "response": [] + }, + { + "name": "Stats • Daily", + "request": { + "method": "GET", + "header": [ + { + "key": "Authorization", + "name": "Authorization", + "value": "Bearer {{smokeToken}}", + "type": "text" + } + ], + "url": { + "raw": "{{baseUrl}}/smoke-tracker/stats/daily?from=2025-01-01&to=2025-01-31", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "smoke-tracker", + "stats", + "daily" + ], + "query": [ + { + "key": "from", + "value": "2025-01-01" + }, + { + "key": "to", + "value": "2025-01-31" + } + ] + }, + "description": "Агрегация по дням для графиков. Если from/to не заданы, используется последний месяц." + }, + "response": [] + } + ], + "event": [], + "variable": [ + { + "key": "baseUrl", + "value": "http://localhost:8044" + }, + { + "key": "smokeToken", + "value": "" + } + ] +} + diff --git a/server/routers/smoke-tracker/stats.js b/server/routers/smoke-tracker/stats.js new file mode 100644 index 0000000..56b4791 --- /dev/null +++ b/server/routers/smoke-tracker/stats.js @@ -0,0 +1,59 @@ +const { Router } = require('express') + +const { getAnswer } = require('../../utils/common') +const { CigaretteModel } = require('./model/cigarette') +const { authMiddleware } = require('./middleware/auth') + +const router = Router() + +// Все эндпоинты статистики требуют авторизации +router.use(authMiddleware) + +// Агрегация по дням: количество сигарет в день для построения графика +router.get('/daily', async (req, res, next) => { + try { + const user = req.user + const { from, to } = req.query + + const now = new Date() + const defaultFrom = new Date(now) + defaultFrom.setDate(defaultFrom.getDate() - 30) + + const fromDate = from ? new Date(from) : defaultFrom + const toDate = to ? new Date(to) : now + + const match = { + userId: user.id, + smokedAt: { + $gte: fromDate, + $lte: toDate, + }, + } + + const data = await CigaretteModel.aggregate([ + { $match: match }, + { + $group: { + _id: { + $dateToString: { format: '%Y-%m-%d', date: '$smokedAt' }, + }, + count: { $sum: 1 }, + }, + }, + { $sort: { _id: 1 } }, + ]) + + const result = data.map((item) => ({ + date: item._id, + count: item.count, + })) + + res.json(getAnswer(null, result)) + } catch (err) { + next(err) + } +}) + +module.exports = router + + diff --git a/server/routers/smoke-tracker/utils.js b/server/routers/smoke-tracker/utils.js new file mode 100644 index 0000000..4c24b41 --- /dev/null +++ b/server/routers/smoke-tracker/utils.js @@ -0,0 +1,21 @@ +const requiredValidate = + (...fields) => + (req, res, next) => { + const errors = [] + + fields.forEach((field) => { + if (!req.body[field]) { + errors.push(field) + } + }) + + if (errors.length) { + throw new Error(`Не все поля заполнены: ${errors.join(', ')}`) + } else { + next() + } + } + +module.exports.requiredValidate = requiredValidate + +