Оглавление
- Обзор системы
- Технологический стек
- Структура репозитория
- Архитектура
- База данных MongoDB
- Telegram-бот
- REST API дашборда
- Внешние интеграции (обзор)
- Cron-задачи и фоновые процессы
- Безопасность и аутентификация
- Развёртывание
1. Обзор системы
Система состоит из двух независимо развёрнутых сервисов, разделяющих один экземпляр MongoDB:
| Компонент | Технология | Роль |
|---|---|---|
| Telegram-бот | Node.js + Telegraf | Опрос агрегаторов, push-уведомления, ежедневные отчёты |
| Веб-дашборд | Next.js 16 + React 19 | Аналитика, фильтры, графики, отзывы клиентов |
| MongoDB | 7.x | Единственный источник правды |
Зоны ответственности
| Компонент | Пишет | Читает |
|---|---|---|
| Бот | users, restaurantstates, stopevents, skustopevents | + reviews, delivery_reviews, delivery_places |
| Дашборд | auth, sessions, reviews, places_reviews, sync_jobs, delivery_reviews, delivery_places, delivery_sync_jobs | + restaurantstates, stopevents, skustopevents |
Имя БД берётся из URI; оба сервиса подключаются через
client.db()без явного аргумента.
2. Технологический стек
Telegram-бот
| Пакет | Версия | Назначение |
|---|---|---|
telegraf | 4.x | Клиент Telegram Bot API |
mongoose | 8.x | ODM для MongoDB |
playwright | 1.x | Headless Chromium для отдельных интеграций |
axios | 1.x | HTTP-клиент для агрегаторов |
https-proxy-agent | 7.x | Прокси для одного из агрегаторов |
node-cron | 3.x | Планировщик ежедневных отчётов |
dotenv | 17.x | Загрузка .env |
Дашборд
| Пакет | Версия | Назначение |
|---|---|---|
next | 16.2.4 | Фреймворк (App Router, Turbopack) |
react / react-dom | 19.2.4 | UI-библиотека |
mongodb | ^7.2.0 | Native Node.js driver (без Mongoose) |
tailwindcss | ^4 | Utility-CSS |
lucide-react | ^1.14.0 | Иконки |
recharts | ^3.8.1 | Графики (AreaChart, BarChart, PieChart) |
maplibre-gl | ^5.24.0 | Карта на странице ресторанов |
playwright | ^1.59.1 | Скрапинг для отдельных площадок отзывов |
tsx | ^4.21.0 | Прямое исполнение TS-скриптов |
State management: глобального нет. Каждая страница — изолированный "use client" компонент с локальным useState / useCallback / useEffect.
Сборка: Turbopack с зафиксированным turbopack.root в next.config.ts, чтобы не подхватывать родительский workspace.
3. Структура репозитория
restaurant-pulse/
├── bot/ # Telegram-бот
│ ├── index.js # главный файл (~4800 строк, монолит)
│ ├── lib/
│ │ ├── working-hours.js # утилиты времени с учётом часового пояса
│ │ ├── external-client-A.js # HTTP-клиент Агрегатора A
│ │ ├── menu-client.js # клиент меню через браузер
│ │ └── sku-stop-helpers.js # логика SKU-мониторинга
│ ├── scripts/ # CLI-утилиты и одноразовые миграции
│ ├── data/ # справочные данные
│ ├── .env # конфигурация (gitignored)
│ └── package.json
│
├── dashboard/ # Next.js-дашборд
│ ├── app/
│ │ ├── page.tsx # / — логин
│ │ ├── dashboard/page.tsx # /dashboard — главная аналитика
│ │ ├── stop-events/page.tsx # /stop-events
│ │ ├── sku-stops/page.tsx # /sku-stops
│ │ ├── restaurants/page.tsx # /restaurants — карта
│ │ ├── reviews/page.tsx # /reviews
│ │ └── api/ # REST API роуты (см. §7)
│ ├── components/ # переиспользуемые UI-компоненты
│ ├── lib/
│ │ ├── mongodb.ts # singleton MongoClient
│ │ ├── loss-calc.ts # расчёт потерь от стопов
│ │ ├── scraper.ts # Playwright-скрапер
│ │ ├── delivery-sync.ts # синк отзывов с одной из площадок
│ │ └── types.ts # TypeScript-типы
│ ├── middleware.ts # авторизация маршрутов
│ ├── next.config.ts
│ └── package.json
│
├── docs/
│ ├── USER_GUIDE.md # руководство для пользователей и админов
│ ├── API_DOCUMENTATION.md # этот документ
│ └── CHANGELOG.md # история изменений
│
└── README.md
4. Архитектура
4.1 Высокоуровневая схема
┌────────────────────────────────────────────────────────────────┐
│ VPS (Linux) │
│ │
│ ┌──────────────┐ ┌─────────────────┐ ┌────────────────┐ │
│ │ pm2: │ │ pm2: │ │ MongoDB 7.x │ │
│ │ bot │ │ dashboard │ │ 127.0.0.1 │ │
│ │ │ │ (Next.js) │ │ :27017 │ │
│ └──────┬───────┘ └────────┬────────┘ └────────▲───────┘ │
│ │ │ │ │
│ └────────────────────┼─────────────────────┘ │
└──────────────────────────────┼─────────────────────────────────┘
│
┌───────▼────────┐
│ Internet │
└────────────────┘
│
┌──────────┬───────────┼──────────┬────────────┐
▼ ▼ ▼ ▼ ▼
Агрегатор Агрегатор Агрегатор Агрегатор Скрапер
A B C D (площадки
(прокси) (браузер) отзывов)
4.2 Жизненный цикл данных
[Внешние API]
│
│ опрос каждые 5 мин
▼
[Бот: checkAll()]
│
│ нормализует ответы
▼
[Бот: stabilization (2 цикла подтверждают смену)]
│
▼
[MongoDB: restaurantstates, stopevents]
│
├─────────────────────────────┐
│ │
▼ ▼
[Бот: notify*] [Дашборд: /api/*]
│ │
▼ ▼
[Telegram-пользователи] [Браузер админа]
4.3 Стабилизация смены статуса
Изменение available агрегатора фиксируется только если новое значение продержалось 2 цикла подряд (CHECK_STABILIZATION_MS = 15000 мс). Это защита от флапов, вызванных временными сбоями API.
// псевдокод
if (sourceState.available !== sourceResult.available) {
if (sourceState.pendingState === sourceResult.available) {
// тот же кандидат-статус во второй раз → ждём stabilization_ms
if (Date.now() - sourceState.pendingSince >= CHECK_STABILIZATION_MS) {
// подтверждено → пишем lastChange, открываем/закрываем StopEvent, шлём уведомление
}
} else {
// первое расхождение → запоминаем кандидата
sourceState.pendingState = sourceResult.available;
sourceState.pendingSince = new Date();
}
}
4.4 Почему бот — монолит
Бот реализован как один файл ~4800 строк, а не как мультимодульный проект. Обоснование:
- Всё состояние живёт в MongoDB. IPC между модулями нет.
- Бот — один процесс под
pm2, границы модулей не транслируются в границы развёртывания. - Монолит упрощает grep-навигацию, а у Telegram-ботов много сквозных concerns (одно действие пользователя может затрагивать состояние юзера, состояние ресторана, уведомления и внешние API).
В lib/ вынесены только переиспользуемые чистые утилиты без побочных эффектов.
5. База данных MongoDB
5.1 Список коллекций
| Коллекция | Пишет | Читает | Назначение |
|---|---|---|---|
users | бот | бот | Telegram-пользователи |
restaurantstates | бот | бот, дашборд | Текущий статус каждого ресторана по агрегаторам |
stopevents | бот | бот, дашборд | Стоп-события на уровне ресторана |
skustopevents | бот | бот, дашборд | Стоп-события на уровне SKU |
auth | вручную | дашборд | Аккаунты администраторов дашборда |
sessions | дашборд | дашборд | HTTP-сессии (TTL 24ч) |
reviews | дашборд | бот, дашборд | Отзывы с публичной maps-площадки |
places_reviews | вручную | дашборд | Места, для которых синкаются отзывы |
sync_jobs | дашборд | дашборд | Трекинг джобов синка отзывов |
delivery_reviews | дашборд | бот, дашборд | Отзывы с delivery-площадки |
delivery_places | дашборд | бот, дашборд | Справочник точек delivery-площадки |
delivery_sync_jobs | дашборд | дашборд | Трекинг джобов синка доставки |
5.2 Mongoose-схемы (бот)
users
{
name: String,
status: String, // 'pending' | 'approved' | 'rejected'
telegramId: Number, // unique, indexed
role: String, // 'admin' | 'manager'
region: String, // indexed; null для admin
createdAt: Date,
observingRestaurants: [Number], // indexed
mainMessageId: Number, // для in-place редактирования сообщений
notificationPreferences: {
operations: { type: Boolean, default: true },
restaurantStop: { type: Boolean, default: true },
skuStop: { type: Boolean, default: true },
reviews: { type: Boolean, default: true }
}
}
Все чтения
notificationPreferencesидут через(prefs?.<key> ?? true)— отсутствующее поле = «подписан».
restaurantstates
{
id: Number, // unique, indexed (id из справочника)
name: String,
city: String, // indexed
available: Boolean, // indexed (общий: true если хотя бы один агрегатор открыт)
lastChange: Date, // indexed
deliveryTime: { min: Number, max: Number },
rating: Number, // площадка A
ratingMaps: Number, // maps-площадка
reviewsMaps: Number, // кол-во отзывов на maps
mapsAddress: String,
mapsPlaceId: String,
mapsUpdatedAt: Date,
sourceStates: Mixed, // см. ниже
pendingState: Boolean,
pendingSince: Date
}
Структура sourceStates (Mixed; ключ — код агрегатора):
{
aggregatorA: {
available: Boolean,
lastChange: Date,
deliveryTime: { min, max },
pendingState: Boolean,
pendingSince: Date,
lastDeliveryTimeAlertAt: Date
},
aggregatorB: { /* то же */ },
aggregatorD: {
/* + дополнительные поля */
paymentTypes: [String],
startTime: String, // "09:00"
endTime: String, // "21:00"
deliveryAvailable: Boolean,
availableByTime: Boolean
},
aggregatorC: {
/* + дополнительные поля */
deliveryForecastText: String,
closingAt: Date,
openingAt: Date
}
}
stopevents
{
restaurantId: Number,
restaurantName: String,
city: String, // indexed
aggregator: String,
startedAt: Date, // indexed
endedAt: Date, // indexed; null = активный
durationMs: Number, // пересечение с рабочими часами
lastHourlyAlertHour: Number // дедуп для почасовых напоминаний
}
Составные индексы:
{ restaurantId: 1, aggregator: 1, endedAt: 1 }{ startedAt: 1 }{ city: 1 }
skustopevents
{
restaurantId: Number, // required
restaurantName: String,
city: String, // indexed
zoneId: String, // ID зоны доставки
productId: String,
productTitle: String,
productCategory: String,
productPrice: Number,
startedAt: Date, // indexed
endedAt: Date, // indexed; null = активный
durationMs: Number,
firstAlertSentAt: Date,
lastAlertSentAt: Date,
alertCount: Number // 0 = алертов ещё не отправлено
}
5.3 Коллекции дашборда (TypeScript-типизированные)
auth
{
_id: ObjectId;
login: string;
password: string;
name?: string;
}
sessions
{
_id: ObjectId;
token: string; // составной из crypto.randomUUID()
userId: string;
login: string;
createdAt: Date;
expiresAt: Date; // createdAt + 24h
}
reviews (Maps-площадка)
{
reviewId: string;
placeId: string;
placeName: string;
placeCity: string;
placeAddress: string;
authorName: string;
rating: number; // 1–5
text: string;
ownerReply: string;
publishedAt: Date | null;
scrapedAt: Date; // indexed
notifiedAt: Date | null; // indexed
}
delivery_reviews (Delivery-площадка)
{
feedbackId: number;
orderNr: string;
placeId: number;
placeName: string;
service: "type1" | "type2";
eaterName: string;
rating: number | null;
comment: string;
predefinedComments: PredefinedComment[];
feedbackFilledAt: Date;
canAnswerStatus: string;
courierFeedback: object;
dishFeedbacks: DishFeedback[];
orderDetails: OrderDetails;
syncedAt: Date; // indexed
notifiedAt: Date | null; // indexed
}
delivery_places
{
id: number;
name: string;
customName: string;
displayName: string;
city: string;
shortAddress: string;
address: string;
region: string;
type: "native" | "marketplace";
brand: string;
rating: number;
lastSyncedAt: Date;
}
sync_jobs / delivery_sync_jobs
{
jobId: string;
label: string;
mode: "full" | "single";
status: "running" | "done" | "error";
placeCount: number;
processedCount: number;
newReviews: number;
syncedPlaces: number;
startedAt: Date;
finishedAt: Date | null;
error: string | null;
triggeredBy: string | null; // "cron" | "manual"
}
6. Telegram-бот
6.1 Точка входа
- Файл:
bot/index.js(~4800 строк). - Запуск:
pm2 start index.js --name bot. - Порядок инициализации: Mongoose connect → запуск headless-браузера (с persistent profile) → preload кеша агрегаторов →
bot.launch().
6.2 Telegraf-handlers
Slash-команды (bot.command)
| Команда | Middleware | Назначение |
|---|---|---|
/start | — | Регистрация / приветствие |
/menu | — | Главное меню |
/check | requireUser | Ручной запуск checkAll() |
/stop | requireUser | Список текущих стопов |
/update_maps_rating | только admin | Запуск стороннего скрапера для рейтингов |
/test_* | dev | Диагностические хелперы (тест отчёта, тест отзывов) |
Reply-кнопки (bot.hears)
| Триггер | Handler |
|---|---|
📊 Статус ресторанов | buildRestaurantListButtons |
❌ Стоп Лист | showStopListMenu |
⭐ Рейтинг моих ресторанов | buildRatingsSummaryText |
⚙️ Настройки | showSettingsMenu |
Inline-callback'и (bot.action)
| Pattern | Назначение |
|---|---|
role_admin / role_manager | Регистрация роли |
region_<Code> | Выбор региона (для manager) |
access_approve_<id> / access_reject_<id> | Модерация заявок |
observe_all / observe_select / observe_save | Управление списком ресторанов |
toggle_<id> | Переключение конкретного ресторана |
stats_<id> | Детальный экран ресторана |
back_main / back_to_list | Навигация |
stoplist_restaurants / stoplist_sku / stoplist_back | Подменю Стоп Листа |
settings_observe / settings_notifications / settings_back | Подменю Настроек |
notif_toggle_<key> | Переключение чекбокса уведомлений |
6.3 Главное меню
function mainMenu() {
return Markup.keyboard([
['📊 Статус ресторанов'],
['❌ Стоп Лист'],
['⭐ Рейтинг моих ресторанов'],
['⚙️ Настройки']
]).resize();
}
6.4 Сценарий регистрации
/start (User не найден)
↓
Inline: [Старший] [Локальный]
│ │
│ ├─ role_manager → Inline: [Регион 1] [Регион 2] ...
│ │ ↓
│ │ region_<code>
│ │ ↓
│ │ User.create({role:'manager', region, status:'pending'})
│ │
├─ role_admin → User.create({role:'admin', region:null, status:'pending'})
↓
sendAccessRequest(user)
→ бот пишет в ACCESS_REQUEST_CHAT_ID:
«Заявка от @username (Имя). Регион: …»
Inline: [✅ Принять] [❌ Отклонить]
↓
access_approve_<telegramId>
user.status = 'approved'
→ пользователю: «Регистрация подтверждена. /menu»
6.5 Основной цикл checkAll()
- Период:
CHECK_INTERVALмс (по умолчанию 5 мин). - Mutex:
isCheckAllRunning— пропускает запуск, если предыдущий ещё идёт. - Guard:
isWorkingHours()(07:00–24:00 локального времени) — иначе skip.
Алгоритм:
- Загрузить все
RestaurantStateвstateMap. - Разбить рестораны на батчи по
CHECK_BATCH_SIZE = 3. - Для каждого ресторана:
fetchRestaurant(restaurant)— параллельный опрос всех агрегаторов.- Для каждого
sourceResult:- Сравнить с предыдущим состоянием.
- Применить 2-цикловую стабилизацию для смены
available. - При подтверждённой смене:
handleStopEventChange(открыть/закрытьStopEvent) +notifyUsersAboutSourceStatusChange. - При смене
deliveryTime≥ 15 мин (с cooldown):notifyUsersAboutSourceDeliveryChange. - При смене
paymentTypes(только Агрегатор D):notifyUsersAboutPaymentTypesChange.
- Сохранить через
updateOneвrestaurantstates.
- Sleep
1200 + random*500мс между батчами.
6.6 Цикл SKU-мониторинга skuMonitorCycle()
- Период:
SKU_MONITOR_INTERVAL_MS(по умолчанию 5 мин). - Аварийный выключатель:
SKU_MONITOR_ENABLED=falseотключает цикл целиком. - Mutex:
isSkuMonitorRunning.
Алгоритм:
- Для каждого города из справочника:
- Прочитать кешированные зоны доставки.
- Для каждой зоны:
- Определить рестораны в зоне (point-in-polygon).
- Получить меню для
(city, zone)через клиент на headless-браузере. - Расплющить дерево категорий, отфильтровать
isOnStop=true. compareSkuStops(restaurant, zone, currentlyOnStop):- Открыть новые
SkuStopEventдля появившихся. - Закрыть события снятых со стопа.
- Открыть новые
checkSkuStopAlerts:- Найти события с
elapsed ≥ 2чиalertCount=0→ первый алерт. - Найти события с
(now - lastAlertSentAt) ≥ 24ч→ повторный алерт.
- Найти события с
6.7 Уведомления
Семь типов push-сообщений, все через bot.telegram.sendMessage с задержкой 30 мс между получателями.
| Функция | Триггер | Ключ prefs | Форма сообщения |
|---|---|---|---|
notifyUsersAboutSourceStatusChange | подтверждённая смена available | restaurantStop | «Филиал / Агрегатор / Дата / Статус» (через batch-механизм) |
notifyUsersAboutLongStop | стоп ≥ N часов | restaurantStop | «Ресторан в стопе N часов» (раз в час) |
notifyUsersAboutSourceDeliveryChange | diff deliveryTime ≥ 15 мин | operations | «Время доставки: было X → стало Y» |
notifyUsersAboutPaymentTypesChange | смена paymentTypes | operations | «Способы оплаты: было … стало …» |
notifyUsersAboutSkuStop (первый) | SKU на стопе ≥ 2ч | skuStop | «🍱 SKU надолго на стопе» |
notifyUsersAboutSkuStop (повтор) | SKU всё ещё на стопе, +24ч | skuStop | «⚠️ Напоминание: SKU всё ещё на стопе» |
checkAndSendNewReviewAlerts | новая запись в reviews или delivery_reviews | reviews | Форматированный отзыв |
Паттерн фильтрации:
const users = await User.find(
{ observingRestaurants: restaurantId, status: 'approved' },
{ telegramId: 1, notificationPreferences: 1, _id: 0 }
).lean();
const filtered = users.filter(u =>
(u.notificationPreferences?.<key> ?? true)
);
for (const user of filtered) {
await bot.telegram.sendMessage(user.telegramId, message);
await sleep(30);
}
6.8 Batch-механизм уведомлений
Для подавления спама при массовых событиях (плановое отключение нескольких точек на ночь):
const BATCH_FLUSH_DELAY_MS = 60 * 1000;
const BATCH_GROUP_THRESHOLD = 3;
addToNotificationBatch(telegramId, item):
// накапливает openedItems / closedItems на пользователя
// первое добавление → setTimeout(flush, 60s)
flushNotificationBatch(telegramId):
// closedItems.length >= 3 → buildGroupedStatusMessage(false) — одно сводное
// closedItems.length < 3 → buildIndividualStatusMessage × N
6.9 Тихие часы
function isQuietHours() {
const { hour } = getMoscowTimeParts();
return hour < 8 || hour >= 23;
}
В тихие часы notifyUsersAboutSourceStatusChange, notifyUsersAboutSourceDeliveryChange и notifyUsersAboutPaymentTypesChange short-circuit-ятся и не отправляют ничего.
6.10 Контроль доступа к ресторанам
function getAccessibleRestaurants(user) {
if (user.role === 'admin') return restaurants; // вся сеть
return restaurants.filter(r => // только свой регион
REGIONS[user.region]?.cities.includes(r.city)
);
}
REGIONS — статическая мапа { regionCode: { cities: [...] } }, лежит в справочных данных.
7. REST API дашборда
Все эндпоинты возвращают JSON с заголовком Content-Type: application/json.
7.1 Сводный список
| Метод | URL | Назначение |
|---|---|---|
POST | /api/auth | Логин |
DELETE | /api/auth | Logout |
GET | /api/auth/verify | Проверка сессии |
GET | /api/dashboard | Данные главной страницы |
GET | /api/restaurants | Список ресторанов |
GET | /api/stop-events | Стоп-события (пагинация) |
GET | /api/sku-stops | SKU-стопы (пагинация) |
GET | /api/reviews | Отзывы Maps-площадки |
GET POST DELETE | /api/reviews/sync | Синхронизация отзывов Maps |
GET | /api/reviews/places | Места для скрапинга |
GET | /api/reviews/debug | Диагностический эндпоинт |
GET | /api/delivery-reviews | Отзывы Delivery-площадки |
GET POST DELETE | /api/delivery-reviews/sync | Синхронизация Delivery |
GET | /api/delivery-reviews/places | Точки Delivery-площадки |
GET | /api/delivery-reviews/order/[orderNr] | Детали заказа |
GET | /api/cron/reviews | Cron-точка для синка отзывов |
GET | /api/cron/delivery-reviews | Cron-точка для синка delivery |
7.2 Аутентификация
POST /api/auth — Логин
Request body:
{ "login": "string", "password": "string" }
Response 200:
{
"success": true,
"user": { "id": "string", "login": "string", "name": "string" }
}
Response 400: { "success": false, "error": "Логин и пароль обязательны" }
Response 401: { "success": false, "error": "Неверный логин или пароль" }
Set-Cookie:
auth_token=<uuid-uuid>; HttpOnly; SameSite=Lax; Path=/; Expires=<+24h>
DELETE /api/auth — Logout
Response 200: { "success": true }
Удаляет запись сессии и cookie.
GET /api/auth/verify
Response 200 (валидна):
{ "authenticated": true, "user": { "login": "string", "userId": "string" } }
Response 401: { "authenticated": false }
7.3 Аналитика
GET /api/dashboard
Query параметры:
| Параметр | Тип | Default | Описание |
|---|---|---|---|
dateFrom | YYYY-MM-DD | — | startedAt >= dateFromT00:00:00Z |
dateTo | YYYY-MM-DD | — | startedAt <= dateToT23:59:59Z |
city | string | — | Точное совпадение по city |
aggregator | string | — | Точное совпадение по aggregator |
q | string | — | Regex по restaurantName |
minDurationMin | int | 0 | Минимальная длительность в минутах |
Response 200:
{
updatedAt: string; // ISO timestamp
filters: { cities: string[]; aggregators: string[] };
metrics: {
stopEvents: number;
avgDurationMin: number;
maxDowntime: string; // "Nч Mм"
maxDowntimeRestaurant: string;
avgStopMinPerDay: number; // % от рабочего дня
totalFinancialLoss: number;
};
hours: Array<{ time: string; events: number; duration: number }>;
cities: Array<{ city: string; value: number; loss: number }>;
aggregators: Array<{ name: string; value: number }>;
problemRestaurants: Array<{
name: string; city: string; aggregator: string;
events: number; avg: string; max: string; risk: number;
}>;
byCity: Array<{
city: string; totalEvents: number;
restaurants: Array<{ name: string; aggregator: string; events: number; avg: string }>;
}>;
}
7.4 Рестораны
GET /api/restaurants
Query: ?city=Город1 (опционально)
Response 200:
{
restaurants: Array<{
id: string;
name: string;
city: string;
rating: number | null;
ratingMaps: number | null;
reviewsMaps: number | null;
address: string | null;
lat: number | null;
lng: number | null;
}>;
cities: string[];
}
7.5 Стоп-события
GET /api/stop-events
Query:
| Параметр | Тип | Default | Описание |
|---|---|---|---|
mode | "active" | "past" | "active" | Активные / прошедшие |
page | int | 1 | 1-based |
sortField | string | "startedAt" | |
sortDir | "asc" | "desc" | "desc" | |
city | string | — | |
aggregator | string | — | |
q | string | — | Regex по restaurantName |
dateFrom / dateTo | YYYY-MM-DD | — | Только в режиме past |
minDurationMin | int | 0 | Только в режиме past |
Размер страницы фиксирован: 50.
Response 200:
{
events: Array<{
id: string;
restaurantName: string;
city: string;
aggregator: string;
startedAt: string;
dateEnd: string | null;
durationMs: number; // active: now - startedAt; past: из БД
}>;
total: number;
page: number;
pageSize: 50;
filters: { cities: string[]; aggregators: string[] };
}
7.6 SKU-стопы
GET /api/sku-stops
Query:
| Параметр | Тип | Default | Описание |
|---|---|---|---|
mode | "active" | "past" | "active" | |
page | int | 1 | |
sortField | string | "startedAt" | |
sortDir | string | "desc" | |
city | string | — | |
restaurantId | int | — | |
q | string | — | Regex по productTitle или restaurantName |
dateFrom / dateTo | YYYY-MM-DD | — | Только past |
longOnly | "1" | "true" | — | Только active: startedAt <= now - 2h |
minDurationMin | int | 0 | Только past |
Response 200:
{
events: Array<{
id: string;
restaurantId: number | null;
restaurantName: string;
city: string;
zoneId: string | null;
productId: string;
productTitle: string;
productCategory: string;
productPrice: number;
startedAt: string;
endedAt: string | null;
durationMs: number;
alertCount: number;
firstAlertSentAt: string | null;
}>;
total: number;
page: number;
pageSize: 50;
filters: {
cities: string[];
restaurants: Array<{ id: string; name: string }>;
};
}
7.7 Отзывы — Maps-площадка
GET /api/reviews
Query:
| Параметр | Тип | Default | Описание |
|---|---|---|---|
placeId | string | — | |
city | string | — | |
ratings | "1,2,3" | — | CSV из 1–5 |
hasText | "true" | — | Непустой text |
hasReply | "true" | — | Непустой ownerReply |
dateFrom / dateTo | YYYY-MM-DD | — | По publishedAt |
page | int | 1 | |
limit | int | 20 (max 50) |
Response 200:
{
reviews: Array<{
id: string; reviewId: string;
placeId: string; placeName: string; placeCity: string; placeAddress: string;
rating: number; text: string; authorName: string;
publishedAt: string | null; ownerReply: string;
scrapedAt: string | null;
}>;
total: number;
page: number;
totalPages: number;
stats: {
totalReviews: number;
avgRating: number;
recentWeek: number;
ratingDistribution: Array<{ rating: number; count: number }>;
};
}
Stats считаются одним $facet-агрегатом.
POST /api/reviews/sync — Запуск синхронизации
Request body (опционально): { "placeId": "string" } — синк одного места; пустое тело — синк всех.
Логика:
- Сбросить зависшие джобы (
status: 'running'старше 30 мин) → пометитьerror. - Если
runningуже есть → 409. - Создать
sync_jobsсоstatus: 'running'. - Запустить
runSyncв фоне (fire-and-forget). - Сразу ответить.
Response 200:
{ "jobId": "uuid", "label": "Все рестораны (43)", "placeCount": 43, "status": "running" }
Response 409:
{ "error": "Синхронизация уже запущена", "jobId": "uuid", "label": "..." }
Внутри runSync для каждого места: scrape с { maxReviews: 100, knownReviewIds }, early-stop после 10 знакомых ID подряд. Upsert в reviews по ключу { reviewId, placeId }. Прогресс пишется в sync_jobs каждые ~600 мс.
GET /api/reviews/sync — Статус джоба
Query: ?runId=<jobId> (опционально). Без параметра — последние 10 джобов.
Response (running):
{
status: "running";
label: string;
progress: string; // "N/M ресторанов"
processedCount: number;
placeCount: number;
newReviews: number;
}
Response (done/error):
{
status: "done" | "error";
newReviews: number;
syncedPlaces: number;
label: string;
error?: string;
}
DELETE /api/reviews/sync — Сброс зависших джобов
Response 200: { "reset": N }
GET /api/reviews/places
Response 200:
{
places: Array<{
id: string;
name: string;
city: string;
lastSyncedAt: string | null;
}>;
}
7.8 Отзывы — Delivery-площадка
GET /api/delivery-reviews
Query:
| Параметр | Тип | Default | Описание |
|---|---|---|---|
placeId | int | — | |
ratings | "1,2,3" | — | |
hasText | "true" | — | |
service | "type1" | "type2" | — | |
dateFrom / dateTo | YYYY-MM-DD | — | По feedbackFilledAt |
page | int | 1 | |
limit | int | 20 (max 50) |
Структура response аналогична /api/reviews плюс service, eaterName, comment, predefinedComments, feedbackFilledAt, courierFeedback, dishFeedbacks. orderDetails в списке не возвращается — для деталей отдельный эндпоинт.
POST /api/delivery-reviews/sync
Зеркальный к POST /api/reviews/sync, использует runDeliverySync из lib/delivery-sync.ts.
GET /api/delivery-reviews/sync
// running:
{
status: "running";
label: string;
newReviews: number;
totalSeen: number;
detailsFetched: number;
progress: string; // "Найдено: N нов., детали: M"
}
// done/error:
{
status: "done" | "error";
label: string;
newReviews: number;
totalSeen: number;
detailsFetched: number;
syncedPlaces: number;
error?: string;
}
GET /api/delivery-reviews/places
{
places: Array<{
id: number;
name: string;
customName: string;
displayName: string;
city: string;
shortAddress: string;
address: string;
region: string;
type: "native" | "marketplace";
brand: string;
rating: number;
lastSyncedAt: string | null;
}>;
}
GET /api/delivery-reviews/order/[orderNr]
Path param: orderNr.
Response 200:
{
feedbackId: number;
orderNr: string;
placeId: number;
placeName: string;
eaterName: string;
rating: number | null;
comment: string;
predefinedComments: PredefinedComment[];
feedbackFilledAt: string | null;
courierFeedback: object;
dishFeedbacks: DishFeedback[];
orderDetails: OrderDetails | null;
}
Response 404: { "error": "Заказ не найден" }
7.9 Cron-эндпоинты
GET /api/cron/reviews / GET /api/cron/delivery-reviews
Synchronous-варианты синка, защищены Authorization: Bearer <CRON_SECRET>.
curl -H "Authorization: Bearer YOUR_CRON_SECRET" \
http://localhost:3000/api/cron/reviews
Response 401: { "error": "Unauthorized" }
Response 200 (skipped):
{ "skipped": true, "reason": "sync already running", "jobId": "..." }
Response 200 (done):
{ "done": true, "jobId": "...", "placeCount": 43, "newReviews": 5, "syncedPlaces": 43 }
8. Внешние интеграции (обзор)
Система интегрируется с несколькими внешними API агрегаторов. Точные эндпоинты намеренно не публикуются. Ниже — концептуальный контракт каждой интеграции.
8.1 Агрегатор A (HTTP, ключевая авторизация)
Транспорт: HTTPS через axios.
Auth: API-ключ в кастомном заголовке.
Концептуальная структура ответа:
{
"place": {
"rating": 4.5,
"available": true,
"availableByTime": true,
"availableByLocation": true,
"deliveryTypes": ["delivery"],
"deliveryTime": { "min": 25, "max": 40 }
}
}
Предикат «открыт»: все три флага available, availableByTime, availableByLocation истинны и есть хотя бы один тип доставки.
8.2 Агрегатор B (HTTP, ключевая авторизация)
Транспорт: HTTPS через axios.
Auth: API-ключ в кастомном заголовке.
Концептуальная структура ответа:
{
"isOpen": true,
"isTemporaryClosed": false,
"isClosedByTime": false,
"supportsDeliveryToSpecifiedLocation": true,
"deliveryConditions": [{ "minOrderSum": 500 }],
"averageDeliveryTimeMinutes": 35,
"rating": 4.6
}
8.3 Агрегатор C (требует прокси)
Транспорт: HTTPS через axios поверх HTTPS-прокси.
Особенности:
- Ручная обработка HTTP 307 редиректов (до 5 попыток).
- Накопление cookies между hop'ами редиректа.
- Кастомный набор заголовков, включая токен веб-клиента.
- In-memory кеш рейтингов (TTL настраивается через
EXTERNAL_RATINGS_CACHE_TTL_S).
Концептуальная структура ответа:
{
"next_deliveries": [
{
"estimate_minutes_min": 25,
"estimate_minutes_max": 40,
"delivery_forecast_text": "сегодня к 18:30"
}
],
"schedule": {
"closing_at": "2026-05-05T22:00:00Z",
"opening_at": "2026-05-06T09:00:00Z"
}
}
8.4 Агрегатор D (через браузерную автоматизацию)
Транспорт: headless Chromium через playwright.
Почему браузерная автоматизация? API этой площадки отвергает обычные HTTP-клиенты по fingerprint. Бот использует долгоживущий persistent profile, чтобы поддерживать cookies и стабильный fingerprint.
Концептуальные потоки:
- Зоны доставки — для каждого города получаем полигоны зон.
- Меню — для каждой зоны получаем дерево меню, расплющиваем продукты, ищем
isOnStop=true.
Концептуальная структура ответа меню:
{
"categories": [
{
"name": "Суши",
"products": [
{ "id": "uuid", "title": "Ролл", "isOnStop": false, "active": true, "price": 499 }
],
"children": [ /* вложенные категории */ ]
}
]
}
8.5 Сторонний скрапер (Maps-площадка)
Бот платит сторонней службе за получение актуальных рейтингов с публичной maps-площадки. Запускается раз в неделю или вручную админом.
Поток:
- POST для запуска прогона.
- Polling до завершения (4 сек × 150 попыток = до 10 минут).
- GET датасета.
- Матчинг результатов на внутренние ID ресторанов.
8.6 Delivery-площадка (через браузер)
Для одной из площадок отзывов дашборд использует playwright, чтобы запустить настоящую сессию, забрать cookies, и затем дёргать API. При истечении сессии возвращается типизированная ошибка SessionExpiredError, которую cron-handler ловит и пишет соответствующий error в delivery_sync_jobs.
9. Cron-задачи и фоновые процессы
9.1 Бот
| Задача | Период | Реализация |
|---|---|---|
checkAll | 5 мин | setInterval(checkAll, CHECK_INTERVAL) |
skuMonitorCycle | 5 мин | setInterval(..., SKU_MONITOR_INTERVAL_MS) |
checkLongStopAlerts | 5 мин | setInterval(..., 5 * 60 * 1000) |
checkAndSendNewReviewAlerts | 30 мин | cron.schedule('*/30 * * * *', ...) |
| Цикл daily-отчётов | каждый час | cron.schedule('0 * * * *', ...) (внутри сверка с reportHour юзера) |
| Сброс дедупликации daily-отчётов | 00:05 локального | cron.schedule('5 0 * * *', ...) |
| Обновление кеша зон | 2 мин | setInterval(...) |
| Обновление кеша рейтингов | 15 мин | setInterval(...) |
| Обновление рейтингов Maps | вс 23:00 | minute-tick guard |
9.2 Дашборд
| Задача | Триггер | Эндпоинт |
|---|---|---|
| Синк отзывов Maps | внешний cron | GET /api/cron/reviews |
| Синк отзывов Delivery | внешний cron | GET /api/cron/delivery-reviews |
Рекомендованный VPS-crontab:
0 * * * * curl -sH "Authorization: Bearer $CRON_SECRET" http://localhost:3000/api/cron/reviews >> /var/log/dashboard-cron.log 2>&1
0 * * * * curl -sH "Authorization: Bearer $CRON_SECRET" http://localhost:3000/api/cron/delivery-reviews >> /var/log/dashboard-cron.log 2>&1
10. Безопасность и аутентификация
10.1 Дашборд
- HttpOnly cookie
auth_token(TTL 24ч). - Сессии живут в коллекции
sessions, отзываются удалением документа. - Middleware (
middleware.ts) проверяет только наличие cookie на защищённых маршрутах. - Защищённые маршруты:
/dashboard,/stop-events,/sku-stops,/restaurants. - API-роуты исключены из matcher'а middleware; per-route проверка реализована внутри обработчиков, где требуется.
10.2 Telegram-бот
- Авторизованные handlers закрыты middleware
requireUser(User.findOne({ telegramId, status: 'approved' })). - Двухступенчатая регистрация: пользователь подаёт заявку → модератор одобряет в
ACCESS_REQUEST_CHAT_ID. - Доступ к ресторанам ограничен по
roleиregion(см.getAccessibleRestaurants).
10.3 Cron-эндпоинты
Заголовок Authorization: Bearer <CRON_SECRET> обязателен, если CRON_SECRET непустой (для удобства dev пустой секрет отключает проверку).
11. Развёртывание
11.1 Требования
- Linux (Ubuntu 22.04+ протестирован)
- Node.js 20+
- MongoDB 7.x с включённой авторизацией
- pm2 (
npm i -g pm2) - Исходящий трафик к API агрегаторов
11.2 Установка
git clone <repo>
cd restaurant-pulse
# Бот
cd bot
npm install
npx playwright install chromium
cp .env.example .env
node -c index.js
# Дашборд
cd ../dashboard
npm install
cp .env.local.example .env.local
npm run build
11.3 Переменные окружения
Бот (.env)
| Переменная | Default | Описание |
|---|---|---|
BOT_TOKEN | required | Токен Telegram Bot API |
MONGO_URI | required | mongodb://USER:PASS@HOST:PORT/DB?authSource=admin |
CHECK_INTERVAL | required | Интервал опроса (мс) |
BROWSER_HEADLESS | 'true' | Headless для браузерной автоматизации |
ACCESS_REQUEST_CHAT_ID | — | Chat ID группы модераторов |
ADMIN_REPORT_HOUR | 22 | Час daily-отчёта для старшего менеджмента |
REPORT_TIME_JSON | '{}' | Часы отчётов по регионам |
AGGREGATOR_A_* | — | Endpoint, ключ, имя заголовка, timeout |
AGGREGATOR_B_* | — | Endpoint, ключ, имя заголовка, timeout |
EXTERNAL_PROXY_* | — | Прокси host/port/user/pass для Агрегатора C |
EXTERNAL_API_TOKEN | '' | Токен веб-клиента Агрегатора C |
EXTERNAL_API_TIMEOUT_MS | 20000 | |
EXTERNAL_RATINGS_CACHE_TTL_S | 900 | TTL in-memory кеша рейтингов |
SKU_MONITOR_ENABLED | 'true' | Аварийный выключатель SKU-цикла |
SKU_MONITOR_INTERVAL_MS | 300000 | |
SKU_ALERT_THRESHOLD_MS | 7200000 | 2ч до первого SKU-алерта |
SKU_REPEAT_ALERT_MS | 86400000 | 24ч между повторными |
SKU_FETCH_TIMEOUT_MS | 10000 | |
DELIVERY_TIME_ALERT_COOLDOWN_MS | 900000 | Cooldown против флаппинга |
SCRAPER_TOKEN | — | Опционально, для стороннего скрапера |
Дашборд (.env.local)
| Переменная | Назначение |
|---|---|
MONGODB_URI | Подключение к MongoDB |
CRON_SECRET | Bearer-токен для cron-эндпоинтов |
11.4 Запуск
# Бот
cd bot
pm2 start index.js --name restaurant-pulse
pm2 save
# Дашборд
cd dashboard
pm2 start "npm run start" --name dashboard
pm2 save
# Автостарт при перезагрузке VPS
pm2 startup
11.5 Обновления
TS=$(date +%Y%m%d_%H%M%S)
cp bot/index.js bot/index.bak-pre-update-$TS.js # локальный снапшот для отката
git pull
cd bot && npm install && cd ..
cd dashboard && npm install && npm run build && cd ..
node -c bot/index.js # проверка синтаксиса перед рестартом
pm2 restart restaurant-pulse --update-env
pm2 restart dashboard
Лицензия
Эта документация опубликована как материал портфолио.
Сама система — проприетарное ПО. Архитектура, паттерны и технические подходы открыты для образовательного использования и обсуждения. Переиспользование кодовой базы, развёртывание производных систем или коммерческое использование — только с явного разрешения владельцев.
Приложение A: Глоссарий
| Термин | Значение |
|---|---|
| Operations | Внутренний канал уведомлений «объект в зоне» (время доставки, способы оплаты) |
| SKU | Stock Keeping Unit — отдельная позиция меню |
| Стоп | Состояние, когда ресторан или SKU исчез с агрегатора |
| Агрегатор | Платформа доставки еды |
| Стабилизация | 2-цикловое подтверждение, защищающее от флапов |
| Тихие часы | 23:00–08:00 локального времени; неурочные уведомления подавляются |
| pm2 | Process manager для Node.js |
Приложение B: Сопутствующие документы
USER_GUIDE.md— руководство для пользователей и администраторов.- Этот документ — техническая справка.
Restaurant Pulse — внутренняя система мониторинга. Документация выпущена как образец для портфолио.