02 · Технический справочник

API документация

Оглавление

  1. Обзор системы
  2. Технологический стек
  3. Структура репозитория
  4. Архитектура
  5. База данных MongoDB
  6. Telegram-бот
  7. REST API дашборда
  8. Внешние интеграции (обзор)
  9. Cron-задачи и фоновые процессы
  10. Безопасность и аутентификация
  11. Развёртывание

1. Обзор системы

Система состоит из двух независимо развёрнутых сервисов, разделяющих один экземпляр MongoDB:

КомпонентТехнологияРоль
Telegram-ботNode.js + TelegrafОпрос агрегаторов, push-уведомления, ежедневные отчёты
Веб-дашбордNext.js 16 + React 19Аналитика, фильтры, графики, отзывы клиентов
MongoDB7.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-бот

ПакетВерсияНазначение
telegraf4.xКлиент Telegram Bot API
mongoose8.xODM для MongoDB
playwright1.xHeadless Chromium для отдельных интеграций
axios1.xHTTP-клиент для агрегаторов
https-proxy-agent7.xПрокси для одного из агрегаторов
node-cron3.xПланировщик ежедневных отчётов
dotenv17.xЗагрузка .env

Дашборд

ПакетВерсияНазначение
next16.2.4Фреймворк (App Router, Turbopack)
react / react-dom19.2.4UI-библиотека
mongodb^7.2.0Native Node.js driver (без Mongoose)
tailwindcss^4Utility-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Главное меню
/checkrequireUserРучной запуск checkAll()
/stoprequireUserСписок текущих стопов
/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.

Алгоритм:

  1. Загрузить все RestaurantState в stateMap.
  2. Разбить рестораны на батчи по CHECK_BATCH_SIZE = 3.
  3. Для каждого ресторана:
    • fetchRestaurant(restaurant) — параллельный опрос всех агрегаторов.
    • Для каждого sourceResult:
      • Сравнить с предыдущим состоянием.
      • Применить 2-цикловую стабилизацию для смены available.
      • При подтверждённой смене: handleStopEventChange (открыть/закрыть StopEvent) + notifyUsersAboutSourceStatusChange.
      • При смене deliveryTime ≥ 15 мин (с cooldown): notifyUsersAboutSourceDeliveryChange.
      • При смене paymentTypes (только Агрегатор D): notifyUsersAboutPaymentTypesChange.
    • Сохранить через updateOne в restaurantstates.
  4. Sleep 1200 + random*500 мс между батчами.

6.6 Цикл SKU-мониторинга skuMonitorCycle()

  • Период: SKU_MONITOR_INTERVAL_MS (по умолчанию 5 мин).
  • Аварийный выключатель: SKU_MONITOR_ENABLED=false отключает цикл целиком.
  • Mutex: isSkuMonitorRunning.

Алгоритм:

  1. Для каждого города из справочника:
    • Прочитать кешированные зоны доставки.
    • Для каждой зоны:
      • Определить рестораны в зоне (point-in-polygon).
      • Получить меню для (city, zone) через клиент на headless-браузере.
      • Расплющить дерево категорий, отфильтровать isOnStop=true.
      • compareSkuStops(restaurant, zone, currentlyOnStop):
        • Открыть новые SkuStopEvent для появившихся.
        • Закрыть события снятых со стопа.
  2. checkSkuStopAlerts:
    • Найти события с elapsed ≥ 2ч и alertCount=0 → первый алерт.
    • Найти события с (now - lastAlertSentAt) ≥ 24ч → повторный алерт.

6.7 Уведомления

Семь типов push-сообщений, все через bot.telegram.sendMessage с задержкой 30 мс между получателями.

ФункцияТриггерКлюч prefsФорма сообщения
notifyUsersAboutSourceStatusChangeподтверждённая смена availablerestaurantStop«Филиал / Агрегатор / Дата / Статус» (через batch-механизм)
notifyUsersAboutLongStopстоп ≥ N часовrestaurantStop«Ресторан в стопе N часов» (раз в час)
notifyUsersAboutSourceDeliveryChangediff deliveryTime ≥ 15 минoperations«Время доставки: было X → стало Y»
notifyUsersAboutPaymentTypesChangeсмена paymentTypesoperations«Способы оплаты: было … стало …»
notifyUsersAboutSkuStop (первый)SKU на стопе ≥ 2чskuStop«🍱 SKU надолго на стопе»
notifyUsersAboutSkuStop (повтор)SKU всё ещё на стопе, +24чskuStop«⚠️ Напоминание: SKU всё ещё на стопе»
checkAndSendNewReviewAlertsновая запись в reviews или delivery_reviewsreviewsФорматированный отзыв

Паттерн фильтрации:

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/authLogout
GET/api/auth/verifyПроверка сессии
GET/api/dashboardДанные главной страницы
GET/api/restaurantsСписок ресторанов
GET/api/stop-eventsСтоп-события (пагинация)
GET/api/sku-stopsSKU-стопы (пагинация)
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/reviewsCron-точка для синка отзывов
GET/api/cron/delivery-reviewsCron-точка для синка 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Описание
dateFromYYYY-MM-DDstartedAt >= dateFromT00:00:00Z
dateToYYYY-MM-DDstartedAt <= dateToT23:59:59Z
citystringТочное совпадение по city
aggregatorstringТочное совпадение по aggregator
qstringRegex по restaurantName
minDurationMinint0Минимальная длительность в минутах

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"Активные / прошедшие
pageint11-based
sortFieldstring"startedAt"
sortDir"asc" | "desc""desc"
citystring
aggregatorstring
qstringRegex по restaurantName
dateFrom / dateToYYYY-MM-DDТолько в режиме past
minDurationMinint0Только в режиме 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"
pageint1
sortFieldstring"startedAt"
sortDirstring"desc"
citystring
restaurantIdint
qstringRegex по productTitle или restaurantName
dateFrom / dateToYYYY-MM-DDТолько past
longOnly"1" | "true"Только active: startedAt <= now - 2h
minDurationMinint0Только 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Описание
placeIdstring
citystring
ratings"1,2,3"CSV из 1–5
hasText"true"Непустой text
hasReply"true"Непустой ownerReply
dateFrom / dateToYYYY-MM-DDПо publishedAt
pageint1
limitint20 (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" } — синк одного места; пустое тело — синк всех.

Логика:

  1. Сбросить зависшие джобы (status: 'running' старше 30 мин) → пометить error.
  2. Если running уже есть → 409.
  3. Создать sync_jobs со status: 'running'.
  4. Запустить runSync в фоне (fire-and-forget).
  5. Сразу ответить.

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Описание
placeIdint
ratings"1,2,3"
hasText"true"
service"type1" | "type2"
dateFrom / dateToYYYY-MM-DDПо feedbackFilledAt
pageint1
limitint20 (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.

Концептуальные потоки:

  1. Зоны доставки — для каждого города получаем полигоны зон.
  2. Меню — для каждой зоны получаем дерево меню, расплющиваем продукты, ищем isOnStop=true.

Концептуальная структура ответа меню:

{
  "categories": [
    {
      "name": "Суши",
      "products": [
        { "id": "uuid", "title": "Ролл", "isOnStop": false, "active": true, "price": 499 }
      ],
      "children": [ /* вложенные категории */ ]
    }
  ]
}

8.5 Сторонний скрапер (Maps-площадка)

Бот платит сторонней службе за получение актуальных рейтингов с публичной maps-площадки. Запускается раз в неделю или вручную админом.

Поток:

  1. POST для запуска прогона.
  2. Polling до завершения (4 сек × 150 попыток = до 10 минут).
  3. GET датасета.
  4. Матчинг результатов на внутренние ID ресторанов.

8.6 Delivery-площадка (через браузер)

Для одной из площадок отзывов дашборд использует playwright, чтобы запустить настоящую сессию, забрать cookies, и затем дёргать API. При истечении сессии возвращается типизированная ошибка SessionExpiredError, которую cron-handler ловит и пишет соответствующий error в delivery_sync_jobs.


9. Cron-задачи и фоновые процессы

9.1 Бот

ЗадачаПериодРеализация
checkAll5 минsetInterval(checkAll, CHECK_INTERVAL)
skuMonitorCycle5 минsetInterval(..., SKU_MONITOR_INTERVAL_MS)
checkLongStopAlerts5 минsetInterval(..., 5 * 60 * 1000)
checkAndSendNewReviewAlerts30 минcron.schedule('*/30 * * * *', ...)
Цикл daily-отчётовкаждый часcron.schedule('0 * * * *', ...) (внутри сверка с reportHour юзера)
Сброс дедупликации daily-отчётов00:05 локальногоcron.schedule('5 0 * * *', ...)
Обновление кеша зон2 минsetInterval(...)
Обновление кеша рейтингов15 минsetInterval(...)
Обновление рейтингов Mapsвс 23:00minute-tick guard

9.2 Дашборд

ЗадачаТриггерЭндпоинт
Синк отзывов Mapsвнешний cronGET /api/cron/reviews
Синк отзывов Deliveryвнешний cronGET /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_TOKENrequiredТокен Telegram Bot API
MONGO_URIrequiredmongodb://USER:PASS@HOST:PORT/DB?authSource=admin
CHECK_INTERVALrequiredИнтервал опроса (мс)
BROWSER_HEADLESS'true'Headless для браузерной автоматизации
ACCESS_REQUEST_CHAT_IDChat ID группы модераторов
ADMIN_REPORT_HOUR22Час 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_MS20000
EXTERNAL_RATINGS_CACHE_TTL_S900TTL in-memory кеша рейтингов
SKU_MONITOR_ENABLED'true'Аварийный выключатель SKU-цикла
SKU_MONITOR_INTERVAL_MS300000
SKU_ALERT_THRESHOLD_MS72000002ч до первого SKU-алерта
SKU_REPEAT_ALERT_MS8640000024ч между повторными
SKU_FETCH_TIMEOUT_MS10000
DELIVERY_TIME_ALERT_COOLDOWN_MS900000Cooldown против флаппинга
SCRAPER_TOKENОпционально, для стороннего скрапера

Дашборд (.env.local)

ПеременнаяНазначение
MONGODB_URIПодключение к MongoDB
CRON_SECRETBearer-токен для 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Внутренний канал уведомлений «объект в зоне» (время доставки, способы оплаты)
SKUStock Keeping Unit — отдельная позиция меню
СтопСостояние, когда ресторан или SKU исчез с агрегатора
АгрегаторПлатформа доставки еды
Стабилизация2-цикловое подтверждение, защищающее от флапов
Тихие часы23:00–08:00 локального времени; неурочные уведомления подавляются
pm2Process manager для Node.js

Приложение B: Сопутствующие документы

  • USER_GUIDE.md — руководство для пользователей и администраторов.
  • Этот документ — техническая справка.

Restaurant Pulse — внутренняя система мониторинга. Документация выпущена как образец для портфолио.