Files
EventHubSpec/EventHubBackSpec.md
T

616 lines
49 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# ТЕХНИЧЕСКОЕ ЗАДАНИЕ (ТЗ) НА ПЛАТФОРМУ EVENTHUB
Версия: 1.5 (актуальная реализация: выполнены задачи #12#17; §2.1.2 commercial — контракт
к реализации)
## 1. ЦЕЛИ И НАЗНАЧЕНИЕ
EventHub — платформа для управления событиями с поддержкой календарей, записи участников
(включая специалистов), гибкого подтверждения, рейтингов, отзывов, модерации,
встроенного баг-трекера и платной подписки.
Целевая аудитория: владельцы календарей (бизнес), участники (клиенты), администраторы.
## 2. ФУНКЦИОНАЛЬНЫЕ ТРЕБОВАНИЯ
### 2.1. Календари
- CRUD календаря (название, описание, теги, владелец)
- Расшаривание по ссылке / приглашения с правами (`calendar_share`) — **фаза 2** (таблица есть;
user HTTP API и UI — Future, см. §2.1.3)
- Типы календарей: `personal` | `commercial` — семантика и коммерческий контур: **§2.1.2**
- Гибкое подтверждение заявок: `auto` | `manual` | `{timeout, N}` (секунды) — детали §2.1.2 / §2.3
- Теги календаря, рейтинг (средняя оценка, количество голосов)
- После успешной верификации email (`POST /v1/verify`, статус пользователя → `active`) система
идемпотентно создаёт дефолтный **personal**-календарь владельцу (`logic_calendar:ensure_default_calendar/1`):
название — `nickname` или «Мой календарь», `confirmation=manual`. Повторный вызов не создаёт дубликат,
если у пользователя уже есть active personal-календарь. Существующим пользователям без календаря
backfill не выполняется.
**Новые поля (задача #12):**
- `short_name` — короткое уникальное имя для API и поиска
- `category` — категория (enum)
- `color` — цвет отображения
- `image_url` — изображение календаря
- `settings` — дополнительные настройки (map)
### 2.1.1. Специалисты календаря
Таблица `calendar_specialist`: связь пользователя-специалиста с **commercial**-календарём.
Поля: `calendar_id`, `user_id`, `name` (отображаемое имя в этом календаре),
`specialization` (`[binary()]`), `status` (`active` | `inactive`).
Контракт CRUD, права confirm и связь с `event.specialist_id` — §2.1.2.
### 2.1.2. Коммерческие календари (продуктовый контракт)
Цель: владелец (бизнес) ведёт публичное расписание и принимает записи клиентов; участник находит
календарь, записывается, получает подтверждение, затем может оставить отзыв. Personal остаётся
бесплатным «дневником» без записи чужих.
#### Типы и видимость
| | `personal` | `commercial` |
|---|---|---|
| Подписка владельца | не требуется | нужна active (trial или paid), иначе **restricted** |
| Видимость чужим | только владелец (+ share — фаза 2) | публичный просмотр; в search/discover — только если **не** restricted |
| Booking чужим | **запрещён** (бэкенд `403`, код `personal_calendar`) | разрешён при active подписке владельца |
| Follow | нет (нет доступа) | да |
| Специалисты | не используются | CRUD владельца; `specialist_id` на событии |
| `confirmation` | не влияет на чужие booking | `auto` / `manual` / `{timeout,N}` |
- Смена `personal → commercial`: только при active подписке владельца; иначе `402`.
- Смена `commercial → personal`: разрешена владельцу; pending booking этого календаря → `cancelled`.
- Владелец **не** может создать booking на событие своего календаря.
#### Подписка и режим restricted (не downgrade type)
При истечении/отсутствии active подписки календари **остаются** `type=commercial` (данные,
специалисты, история сохраняются). Включается **restricted mode** — ограничение коммерческого
функционала; при новой активации подписки функционал **сразу восстанавливается** без смены type.
| Подписка владельца | Поведение |
|---|---|
| `active` и `expires_at > now` | полный commercial |
| иначе (`expired` / нет / `cancelled`) | restricted |
**Restricted:**
- из search / discovery tops — **исключить**;
- deep-link `GET /v1/calendars/:id` (и события) — **просмотр разрешён**; в JSON календаря
флаг `booking_open: false` (и при active — `true`); UI: «Запись временно недоступна»;
- новые booking → `403` (`subscription_inactive`);
- все `pending` booking календарей владельца → `cancelled` (причина `subscription_inactive`),
слоты освобождаются;
- уже `confirmed`**оставляем**;
- owner: просмотр/редактирование своих событий и настроек, CRUD specialists — **разрешены**;
- create нового commercial / upgrade type → `402`.
Периодический job (например раз в 60 с): помечает просроченные подписки `expired`, отменяет
pending владельца, **не** меняет `calendar.type`. Legacy `downgrade_user_calendars/1`
(type → personal) — удалить/не использовать.
Первое создание commercial без подписки: **auto-start trial** (один раз, `trial_used`);
повтор без подписки → `402`. Планы/цены — §2.9.
В ответах календаря (user API): `booking_open` (boolean) — производное от
`type=commercial` ∧ calendar `active` ∧ подписка владельца active.
#### Запись и capacity
- `POST /v1/events/:id/bookings` только если календарь события commercial, `booking_open=true`,
событие `active`, есть свободная вместимость.
- **Pending занимает capacity** наравне с confirmed (защита от overbook при auto/timeout).
- Capacity: число booking со статусом `pending` | `confirmed`; `cancelled` не считаются.
- Политика `confirmation` календаря при создании booking:
- `auto` → сразу `confirmed` + `confirmed_at`;
- `manual``pending`; confirm/decline — владелец или specialist (см. ниже);
- `{timeout, N}``pending`; через N секунд без решения: auto-confirm, если ещё есть
capacity, иначе `cancelled` (`timeout_full`).
- Участник: `DELETE /v1/bookings/:id` — отмена своей pending/confirmed.
- WS: `booking_update` участнику и владельцу (и specialist при confirm на «своём» событии).
#### Специалисты
- Специалист = существующий `user`, привязанный к commercial-календарю после **принятия приглашения**.
- **Не путать** с `calendar_share` (права read/write/admin — фаза 2).
##### Приглашение (`specialist_invite`)
Владелец commercial **не** вводит сырой `user_id` в продуктовом UI. Добавление — через invite:
| Канал | Как |
|-------|-----|
| In-app | typeahead (lookup) → invite по `user_id` → уведомление invitee → accept/decline |
| Email | invite по `email` (юзер есть или ещё нет) → письмо со ссылкой → после логина/регистрации тот же accept |
Модель `specialist_invite` (логическая; таблица/поля — на усмотрение Back при миграции):
- `id`, `calendar_id`, `inviter_id` (owner)
- `invitee_user_id` (если известен) и/или `invitee_email`
- `name` / `specialization` (опционально, подставляются в `calendar_specialist` при accept)
- `status`: `pending` | `accepted` | `declined` | `expired` | `cancelled`
- `token` (для email deep-link), `created_at`, `expires_at` (рекомендуемо: 7 суток)
- Уникальность pending: одна активная pending-пара на (`calendar_id` + user) или (`calendar_id` + email)
Правила:
- Создавать invite может только владелец commercial; при `booking_open=false` (restricted) —
создание invite **запрещено** (`403` / `402` по политике подписки календаря).
- Invitee: только `pending``accepted` | `declined`; owner может `cancelled` для своего pending.
- **Accept** → создаётся / активируется `calendar_specialist` (`status=active`); invite → `accepted`.
- Повторный invite тому же active specialist → `409` / no-op по политике Back.
- Email для незарегистрированного: после регистрации/verify с тем же email — pending invite
привязывается к `user_id` (или accept по token).
- Каналы при создании pending: запись `notification` invitee (если user известен) **и** email
(если задан email; если только user_id — email на адрес профиля, если есть).
##### Lookup для typeahead
- `GET /v1/users/lookup?q=` (auth, rate-limit): поиск по **точному email** и/или prefix `nickname`
среди `active` (+ verified) пользователей.
- Ответ — минимум PII: `{ id, nickname }` и **маскированный** email (или email только при
точном совпадении запроса с полным email). Не админский список пользователей.
##### API специалистов / invite
Владелец:
- `GET /v1/calendars/:id/specialists` — active/inactive специалисты
- `GET /v1/calendars/:id/specialist-invites` — исходящие invite (фильтр status)
- `POST /v1/calendars/:id/specialist-invites` — тело: `{ user_id }` **или** `{ email }`,
опционально `name`, `specialization`
- `DELETE /v1/calendars/:id/specialist-invites/:invite_id` — отмена pending (`cancelled`)
- `PUT/DELETE /v1/calendars/:id/specialists/:user_id` — deactivate / remove уже принятого
Invitee:
- `GET /v1/user/specialist-invites` — входящие (`pending` и история по желанию)
- `POST /v1/specialist-invites/:id/accept` | `…/decline`
- `POST /v1/specialist-invites/accept` с `{ token }` — accept по email-ссылке (гость → логин)
Legacy: прямой `POST /v1/calendars/:id/specialists` **не используется клиентским UI**;
допускается только как внутренний/тестовый путь или удаляется после миграции на invite.
Продуктовый путь: invite → accept → specialist.
- `event.specialist_id` опционален; если задан — только `active` specialist этого календаря
(иначе `400`).
- **Confirm/decline booking:** владелец — любые booking календаря; `active` specialist — только
booking на событиях, где `event.specialist_id` = его `user_id`.
#### Фаза 2 (вне текущего контракта реализации)
- `calendar_share` (`read` | `write` | `admin`): не путать с follow, specialist_invite и платной subscription.
- Реальный эквайринг, waitlist, оплата услуги клиентом (B2C).
### 2.1.3. Share / приглашения (фаза 2)
Таблица `calendar_share` (`calendar_id`, `user_id`, `rights`: `read` | `write` | `admin`)
существует в схеме. User HTTP API и клиентский UI — Future до отдельной задачи.
### 2.2. События (расширенная версия с повторяющимися событиями)
#### 2.2.1. Типы событий и модель хранения
События могут быть одиночными (`event_type = single`) или повторяющимися (`event_type = recurring`).
Для повторяющихся событий:
- Мастер-событие содержит правило повторения (`recurrence_rule`) и является шаблоном.
- При создании повторяющегося события генерируются экземпляры (instances) на определённый
период (например, на месяц вперёд), которые хранятся как отдельные записи с полем
`is_instance = true` и ссылкой на мастер (`master_id`).
- При изменении мастера можно выбрать обновление всех будущих экземпляров или только мастер-записи.
- При удалении мастера удаляются все связанные экземпляры.
- Для поддержки исключений (отмена отдельного вхождения) используется таблица
`recurrence_exception`.
#### 2.2.2. Правила генерации вхождений при поиске
При поиске событий на заданный диапазон дат система должна:
- Включать все одиночные события, попадающие в диапазон.
- Для повторяющихся событий генерировать виртуальные вхождения на основе `recurrence_rule`,
исключая те, что помечены как исключения.
- Возвращать как одиночные, так и сгенерированные вхождения в едином списке.
#### 2.2.3. Материализация при записи участника
При записи участника на конкретное вхождение повторяющегося события:
- Система материализует (создаёт) физическую запись события для этого вхождения, если оно ещё
не было материализовано (например, для хранения количества записавшихся).
- Материализованное событие имеет `is_instance = true` и ссылается на `master_id`.
- Запись участника (`booking`) всегда привязывается к конкретному экземпляру (материализованному
или одиночному событию).
#### 2.2.4. Изменение и удаление серий
- При редактировании мастера можно применить изменения ко всем будущим экземплярам или создать
новый мастер с отдельной серией.
- При удалении мастера удаляются все связанные экземпляры, если на них нет активных записей.
Если есть активные записи, мастер-событие помечается как `cancelled`, а существующие записи
остаются.
#### 2.2.5. Структура записей (records.hrl)
Актуальная структура записей включает дополнительные поля, добавленные в рамках задачи #12:
- `event` — добавлены `attachments :: [binary()] | undefined`, `edit_history :: [map()] | undefined`
- `booking` — добавлены `notes :: binary() | undefined`, `reminder_sent :: boolean()`
- `review` — добавлены `likes :: non_neg_integer()`, `dislikes :: non_neg_integer()`,
`edited_at :: calendar:datetime() | undefined`
- `review_vote` — голос пользователя за отзыв: `id`, `review_id`, `user_id`,
`value :: like | dislike`, `created_at`, `updated_at` (уникальность пары review+user)
- `calendar_follow` — follow чужого календаря: `id`, `calendar_id`, `user_id`, `created_at`
(уникальность пары calendar+user; не путать с `calendar_share` и платной `subscription`)
#### 2.2.6. Требования к реализации
- Все операции с событиями должны быть транзакционными.
- Генерация вхождений должна быть эффективной (использовать `calendar:datetime_to_gregorian_seconds`
и кэширование).
- При поиске событий для календаря учитывать права доступа пользователя.
### 2.2.7. Follow чужого календаря
- Пользователь может **отслеживать** (follow) чужой календарь, к которому у него есть доступ
(`logic_calendar:can_access/2`) и который ему не принадлежит.
- Follow **не** даёт право на отзыв — gate отзывов остаётся confirmed booking (§2.4).
- Follow **не** связан с платной подпиской (`subscription`) и с `calendar_share` (права invite).
- API: `POST/DELETE /v1/calendars/:id/follow` (идемпотентно), `GET /v1/user/following`
(список active доступных календарей); в `GET /v1/calendars/:id` — поле `following` (boolean).
### 2.3. Запись участников и подтверждение
- Запись доступна только на событиях **commercial**-календаря с `booking_open=true` (§2.1.2).
- На personal → `403` (`personal_calendar`); при restricted commercial → `403` (`subscription_inactive`).
- В зависимости от `confirmation` календаря: auto / manual / timeout (§2.1.2).
- Статусы: `pending`, `confirmed`, `cancelled`.
- **Capacity:** лимит слотов считают `pending` + `confirmed` (pending резервирует место).
- Участник может отменить свою запись (`DELETE`).
- Confirm/decline: владелец календаря — любые заявки; active specialist — только на событиях
со своим `specialist_id` (§2.1.2).
- При подтверждении фиксируется `confirmed_at`.
### 2.4. Отзывы и рейтинги
- Пользователи могут оставлять отзывы (рейтинг 1–5 и комментарий) на события или календари.
- **Право на отзыв (`logic_review:can_review/3`):**
- `event` — только при `confirmed` booking пользователя на это событие;
- `calendar` — только при `confirmed` booking на **любое** событие этого календаря;
- иначе create возвращает `cannot_review`.
- Жалоба (`report`) на event/calendar **не** требует booking.
- Отзыв можно редактировать (сохраняется `edited_at`).
- Отзывы могут быть скрыты модератором.
- При добавлении/изменении/удалении отзыва пересчитывается средний рейтинг события
(`rating_avg`, `rating_count`).
- Голосование за отзывы (user API, JWT):
- таблица `review_vote` (один голос на пару `review_id` + `user_id`: `like` | `dislike`);
- счётчики `likes` / `dislikes` на `review` обновляются в одной транзакции с голосом;
- `PUT /v1/reviews/:id/vote` body `{"value":"like"|"dislike"}` — поставить/сменить
(идемпотентно при том же value);
- `DELETE /v1/reviews/:id/vote` — снять голос (идемпотентно);
- нельзя голосовать за свой отзыв (`403`); скрытый/удалённый/отсутствующий → `404`;
- в ответах `GET /v1/reviews`, `GET /v1/reviews/:id` (и при сериализации review) —
поле `my_vote`: `"like"` | `"dislike"` | `null`.
- Возможность пожаловаться на отзыв (создание `report`).
### 2.5. Поиск и фильтрация
- **Discovery (пустой поиск):** `GET /v1/search` без `q`, `tags`, `from`/`to`, `lat`/`lon`, `sort`
возвращает топ публичных календарей и событий по рейтингу из `stats_tops`
(`core_calendar:get_top_calendars_by_rating/1`, `core_event:get_top_events_by_rating/1`).
Параметр `type` (`event` | `calendar`) ограничивает один тип; без `type` — оба в `results`.
Если tops пусты или после фильтра `can_access` ничего не осталось — fallback на полный scan
(как при фильтрованном поиске без `q`).
В элементах `events` есть `calendar_id` и `calendar_title` (для UI-дедупа).
- Полнотекстовый поиск по названиям событий, календарей, тегам (при наличии `q` или любого
фильтра из списка выше).
- Фильтрация по дате, категории, местоположению, рейтингу.
- Пагинация результатов (`limit`, `offset`; default `limit=20`, max `100`).
- Поиск должен учитывать права доступа (не показывать скрытые/заблокированные календари).
### 2.6. Расширенные возможности
- Локация события (`location` запись с адресом, широтой, долготой).
- Онлайн-ссылка (`online_link`).
- Вложения к событию (`attachments`).
- История изменений события (`edit_history`).
- Заметки пользователя к бронированию (`notes`).
- Напоминания о событии (поле `reminder_sent` в бронировании, логика отправки не реализована).
### 2.7. Модерация и безопасность
- Пользователи могут отправлять жалобы (`report`) на календари, события, отзывы.
- Модераторы могут просматривать жалобы и принимать меры (скрывать контент, блокировать).
- Список запрещённых слов (`banned_word`) для фильтрации контента.
- **Автомодерация** (`logic_automoderation`):
- Настройки (`automod_settings`, singleton): `keyword_action` (`reject` | `flag` | `censor`, default `flag`), `match_mode` (`word_boundary` | `substring`, default `word_boundary`), `report_threshold` (integer ≥ 1, default `3`).
- При create/update calendar (`title`, `description`), event (`title`, `description`), review (`comment`) выполняется scan бан-слов.
- `reject` — сохранение отклоняется (`400`, content banned); `censor` — маскирование `***` + hit в очередь; `flag` — сохранение + soft-hide + hit (`review``hidden`, `event`/`calendar``frozen`, reason `auto:keyword`).
- Порог жалоб: считаются только `pending`; при достижении порога — soft-hide (`auto:report_threshold`) + hit; dismissed/reviewed не учитываются.
- Очередь `automod_hit`: `open` | `approved` | `rejected`; approve восстанавливает сущность, reject оставляет/усиливает блокировку.
- WS админам: `automod_hit`; аудит от системного актора `system`.
- Аудит действий администраторов (`admin_audit`).
### 2.8. Баг-трекер (автоматический)
- Источники тикетов (`source`): `backend` (HTTP 500 / внутренние ошибки), `frontend` (клиентский crash), `manual` (ручной репорт).
- При HTTP 500 сервер асинхронно регистрирует тикет через `logic_ticket:report_error` (ответы по путям `/tickets` не репортятся — защита от рекурсии).
- Клиент и ручной репорт: `POST /v1/tickets` с `error_message`, опционально `stacktrace`, `context`, `source` (`frontend` | `manual`). Ручной `manual` — сценарий end-user клиентов, не Admin SPA.
- Тикет содержит `error_hash` (sha256 от source + сообщение + fingerprint стека; для `manual` — source + user_id + сообщение), стектрейс, JSON-контекст, счётчик повторений.
- Дедуп: открытый тикет (`open` / `in_progress`) с тем же `error_hash` — увеличивается `count` и `last_seen`; после `resolved`/`closed` повтор создаёт новый тикет (регрессия).
- Новые тикеты лимитируются (~30/мин на узел); повторы по hash не ограничиваются.
- При создании нового тикета администраторам уходит WS `ticket_created`.
- Администраторы могут просматривать тикеты (фильтр `source`), назначать ответственных, менять статус.
### 2.9. Платная подписка
- Пользователи могут оформить подписку (`subscription`) с разными планами: monthly, quarterly,
biannual, annual.
- Цены (`logic_subscription:plan_price/1`, minor units): monthly **999**, quarterly **2499**,
biannual **4499**, annual **7999**; trial — 0. Платёжный шлюз — заглушка (`process_payment` → ok).
- Статус подписки: `active`, `expired`, `cancelled`.
- Отслеживание использования пробного периода (`trial_used`); trial стартует автоматически при
первой попытке создать/апгрейднуть commercial (§2.1.2).
- Истечение: периодический job → `expired`; commercial-календари **не** меняют type —
переходят в restricted; после `activate` / новой active подписки функционал восстанавливается
(§2.1.2). Не использовать legacy downgrade type → personal.
### 2.10. Административная панель
- Управление пользователями (просмотр, блокировка, изменение ролей).
- Просмотр статистики платформы.
- Управление жалобами и тикетами.
- Управление подписками.
- Аудит действий администраторов.
#### 2.10.1. Ролевая модель администраторов
- `superadmin` — полный доступ, может управлять другими администраторами.
- `admin` — управление пользователями, контентом, подписками.
- `moderator` — модерация контента (жалобы, отзывы, очередь автомодерации).
- `support` — работа с тикетами.
#### 2.10.2. Эндпоинты для управления ролями и аудитом
- `GET /v1/admin/me` — информация о текущем администраторе.
- `GET /v1/admin/admins` — список администраторов (только для superadmin).
- `POST /v1/admin/admins/:id` — изменение роли администратора (superadmin).
- `GET /v1/admin/audit` — просмотр аудита.
#### 2.10.3. Статистика для дашборда с учётом ролей
- `GET /v1/admin/stats` — агрегированная статистика дашборда (totals, by_day, breakdowns по роли).
- Section endpoints: `GET /v1/admin/{users|events|calendars|reviews|reports|tickets|subscriptions}/stats`.
- Источник агрегатов: `stats_collector` (Mnesia `detailed` subscribe) → ETS → upsert в `stats_counter` / `stats_daily` (#42, развитие #16).
- Totals (`*_total`) — `mnesia:table_info(size)` где достаточно; by_status / by_role / by_type / by_day — из счётчиков.
- Tops (все 9 полей section stats) — ETS `stats_tops` (#45, #46); avg resolution — running sum/count (#44); per-admin — индексы (#43).
- Retention дневных бакетов: 730 дней. При старте — load из Mnesia или backfill.
### 2.11. Real-time уведомления (WebSocket)
- Пользовательский WebSocket (порт 8081): подписка на обновления событий календаря.
- Административный WebSocket (порт 8446): подписка на новые жалобы и тикеты.
- Создана таблица `notification` для хранения уведомлений (тип, заголовок, тело, флаг прочитано).
Полноценная логика доставки уведомлений будет реализована позже.
### 2.12. Инфраструктура развертывания (Docker Compose)
- Docker Swarm с тремя репликами `eventhub-node{1..3}`.
- Traefik в качестве reverse-прокси и балансировщика.
- Prometheus для сбора метрик, Grafana для визуализации.
- Observer Web для мониторинга Erlang-узлов.
- Автоматическая ротация логов.
## 3. НЕФУНКЦИОНАЛЬНЫЕ ТРЕБОВАНИЯ
### 3.1. Производительность и масштабирование
- Поддержка 100 000+ пользователей.
- Горизонтальное масштабирование добавлением новых узлов.
- Все персистентные таблицы хранятся в `disc_copies` на каждом узле (задача #13).
- Сессионные таблицы: `auth_session` (единая модель refresh JWT) в `disc_copies` для user и admin API.
- Legacy-таблицы `session` и `admin_session` (`ram_copies`) сохранены в схеме, user/admin hot path используют `auth_session`.
- Индексы созданы для часто запрашиваемых полей (calendar_id, start_time, event_type, status и др.) (задача #13).
- Полная репликация горячих таблиц между всеми узлами кластера (задача #14).
- Автоматическое обнаружение узлов через DNS-имя `eventhub-node` или статический список (задача #14).
- Периодическая очистка «мёртвых» узлов из схемы Mnesia (каждые 30 секунд) (задача #14).
- Архивирование исторических данных (старше 30 дней) в отдельные Mnesia-узлы с `disc_only_copies` (задача #15).
- Пагинация всех списков.
### 3.2. Надёжность
- Супервизорное дерево OTP.
- Философия "let it crash" — быстрый перезапуск упавших процессов.
- Автоматическое восстановление после падения ноды (Mnesia).
- Валидация входных данных и единый формат ошибок.
- Механизм миграций схемы данных: миграции в `src/migrations/`, реестр в `migration_engine`; при старте после init_tables вызывается `ensure_applied/0` с глобальной блокировкой в Mnesia — один узел применяет pending, join-узлы ждут синхронизации; поддерживается откат (задачи #17, #24).
### 3.3. Безопасность
- JWT-аутентификация с разделением ролей (user, admin, superadmin, moderator, support).
- `EVENTHUB_ENV`: `dev` — допускаются documented fallbacks; `stage`/`prod` — fail-fast при слабых или отсутствующих `JWT_SECRET`, `ADMIN_JWT_SECRET` и паролях seed-админов (#25).
- Проверка прав доступа к календарям и событиям.
- Пароли хэшируются с использованием Argon2.
- Защита от несанкционированного просмотра архивных данных (только владелец календаря) (задача #15).
### 3.4. Наблюдаемость
- Логирование в JSON-формате.
- Экспорт метрик для Prometheus (HTTP-эндпоинт `/metrics`).
- Встроенный Observer Web для мониторинга Erlang-системы.
- Сбор admin-статистики через триггеры Mnesia (`detailed` subscribe): upsert-счётчики `stats_counter` и дневные бакеты `stats_daily` (#42; ранее пробный append-only `stats` в #16).
### 3.5. CI/CD
- Контейнеризация (Docker).
- Makefile для автоматизации задач.
- Возможность развёртывания в Kubernetes (в будущем).
## 4. СТЕК ТЕХНОЛОГИЙ (С ВЕРСИЯМИ)
- Erlang/OTP 28
- Mnesia (встроенная БД)
- Cowboy 2.12 (HTTP-сервер)
- JWT (jose 1.11.10)
- Prometheus (prometheus 4.11.0, prometheus_cowboy 2.1.0)
- Docker Compose v3.8
- Traefik v3.1
- Grafana 11.2
- Prometheus 2.55
- Observer Web
- Logrotate
## 5. ИЕРАРХИЧЕСКАЯ СТРУКТУРА КОДА
```
src/
├── core/ — бизнес-логика (core_user, core_event, core_booking, core_review, ...)
├── handlers/ — обработчики HTTP (handler_login, handler_calendar_view, ...)
├── infra/ — инфраструктура (infra_mnesia, infra_sup, cluster_discovery,
│ archive_manager, archive_controller, stats_collector,
│ migration_engine, ...)
├── archive/ — архивирование и рендеринг (archive_controller, archive_manager,
│ calendar_html_renderer, archive_fetcher)
├── migrations/ — файлы миграций
└── eventhub_app.erl — точка входа приложения
```
## 6. ОСНОВНЫЕ API (КРАТКО)
### Пользовательские (порт 8080)
- `GET /health` — health + build identity: `status`, `service`, `version`, `build`, `git_sha`, `built_at` (как admin health).
- `POST /v1/register` — регистрация.
- `POST /v1/verify` — подтверждение email; при успехе — `active` и дефолтный personal-календарь.
- `POST /v1/login` — вход.
- `POST /v1/refresh` — обновление токена.
- `GET /v1/user/me` — профиль пользователя.
- `PATCH /v1/user/me` — частичное обновление своего профиля: `language` (`ru`|`en`),
`nickname`, `timezone`, `phone`, `avatar_url`, `preferences`; смена пароля —
пара `current_password` + `password` (неверный текущий → `403`). Нельзя менять
email/role/status; неизвестные поля → `400`. Ответ — полный профиль как GET.
- `GET /v1/user/bookings` — бронирования пользователя.
- `GET /v1/user/reviews` — отзывы пользователя.
- `GET /v1/user/following` — календари, которые пользователь отслеживает (follow).
- `GET /v1/search` — поиск; пустой запрос (только auth + пагинация/`type`) — discovery tops.
- `GET /v1/calendars` — список календарей.
- `POST /v1/calendars` — создать календарь (`commercial` → подписка/trial; иначе `402`).
- `GET /v1/calendars/:id` — календарь (`following`, `booking_open` для текущего контекста).
- `PUT /v1/calendars/:id` — обновить календарь (`personal→commercial` → gate подписки).
- `DELETE /v1/calendars/:id` — удалить календарь.
- `POST /v1/calendars/:id/follow` — отслеживать чужой календарь.
- `DELETE /v1/calendars/:id/follow` — снять follow.
- `GET /v1/users/lookup?q=` — typeahead пользователей для invite (минимальный PII, rate-limit).
- `GET /v1/calendars/:id/specialists` — список специалистов (владелец).
- `PUT/DELETE /v1/calendars/:id/specialists/:user_id` — deactivate / убрать специалиста (владелец).
- `GET/POST /v1/calendars/:id/specialist-invites` — исходящие invite / создать (владелец).
- `DELETE /v1/calendars/:id/specialist-invites/:invite_id` — отменить pending (владелец).
- `GET /v1/user/specialist-invites` — входящие приглашения.
- `POST /v1/specialist-invites/:id/accept` | `…/decline` — ответ invitee.
- `POST /v1/specialist-invites/accept` `{ token }` — accept по email deep-link.
- `GET /v1/calendars/:calendar_id/events` — события календаря.
- `POST /v1/calendars/:calendar_id/events` — создать событие.
- `GET /v1/events/:id` — событие.
- `PUT /v1/events/:id` — обновить событие.
- `DELETE /v1/events/:id` — удалить событие.
- `GET /v1/events/:id/occurrences` — вхождения повторяющегося события.
- `DELETE /v1/events/:id/occurrences/:start_time` — отменить вхождение серии.
- `POST /v1/events/:id/bookings` — запись на событие.
- `GET /v1/events/:id/bookings` — список бронирований события (владелец).
- `GET /v1/bookings/:id` — статус бронирования.
- `PUT /v1/bookings/:id` — подтвердить/отклонить бронирование (владелец).
- `DELETE /v1/bookings/:id` — отменить бронирование (участник).
- `POST /v1/reviews` — создать отзыв.
- `GET /v1/reviews` — список отзывов (поле `my_vote` для текущего пользователя).
- `GET /v1/reviews/:id` — отзыв по ID (`my_vote`).
- `PUT /v1/reviews/:id` — обновить отзыв.
- `DELETE /v1/reviews/:id` — удалить свой отзыв.
- `PUT /v1/reviews/:id/vote` — поставить/сменить голос (`{"value":"like"|"dislike"}`).
- `DELETE /v1/reviews/:id/vote` — снять голос.
- `POST /v1/reports` — пожаловаться (`target_type`: `event` | `calendar` | `review`).
- `GET /v1/reports` — список жалоб пользователя.
- `GET /v1/tickets` — тикеты пользователя.
- `POST /v1/tickets` — создать или обновить тикет (дедуп по `error_hash`): body `{ error_message, stacktrace?, context?, source?: frontend|manual }`; ответ 201 (или 429 при rate-limit новых).
- `GET /v1/tickets/:id` — статус тикета.
- `GET /v1/subscription` — подписка пользователя.
- `POST /v1/subscription` — активировать подписку.
- `GET /v1/calendars/:calendar_id/view?month=YYYY-MM` — HTML-календарь (владелец), включая архив.
### WebSocket (порт 8081)
- `WS /ws` — пользовательский канал real-time (подписка на обновления событий календаря; детали — §2.11).
### Административные (порт 8445)
- `GET /admin/health` и `GET /v1/admin/health` — состояние сервера + build identity: `status`, `service`, `version` (`MAJOR.MINOR` из `EventHubSpec/VERSION`), `build` (CI `run_number`, per-repo), `git_sha`, `built_at`. `/admin/health` — CT/Traefik; `/v1/admin/health` — Admin UI.
- `GET /v1/admin/stats` — агрегированная статистика дашборда.
- `POST /v1/admin/login` — вход администратора.
- `POST /v1/admin/refresh` — обновление пары access/refresh JWT администратора.
- `GET /v1/admin/users`, `GET /v1/admin/users/:id` — пользователи.
- `GET /v1/admin/reports`, `GET /v1/admin/reports/:id` — жалобы.
- `DELETE /v1/admin/reviews/:id` — удалить отзыв.
- `GET /v1/admin/banned-words`, `POST /v1/admin/banned-words` — запрещённые слова.
- `GET /v1/admin/automod/settings`, `PUT /v1/admin/automod/settings` — настройки автомодерации (чтение: admin/moderator; запись: admin/superadmin).
- `GET /v1/admin/automod/hits`, `PUT /v1/admin/automod/hits/:id` — очередь срабатываний (`status`: `approved` | `rejected`; фильтры `status`, `trigger`; доступ: admin/moderator).
- `GET /v1/admin/tickets/stats` — статистика тикетов: `total_tickets`, `open`, `in_progress`, `resolved`, `closed`, `total_errors`.
- `GET /v1/admin/tickets` — список с фильтрами `status`, `source`, `assigned_to`, `q`; `GET /v1/admin/tickets/:id` — управление тикетами.
- `GET /v1/admin/subscriptions`, `POST /v1/admin/subscriptions/:id` — подписки.
- `PUT /v1/admin/:target_type/:id` — модерация.
- `GET /v1/admin/me` — профиль администратора.
- `GET /v1/admin/admins`, `POST /v1/admin/admins/:id` — управление администраторами.
- `GET /v1/admin/audit` — аудит.
## 6.1. Аутентификация и авторизация
### Общие принципы
- Пользователи и администраторы используют разные эндпоинты и JWT-секреты.
- Access-токен — короткоживущий stateless JWT (HS256), **TTL 86400 с (24 ч)**`eventhub_auth:generate_token/4`.
- Refresh-токен — 30 дней; подписанный JWT в единой session-модели (`auth_session`).
- Все защищённые эндпоинты требуют заголовок `Authorization: Bearer <access_token>`.
- **Source of truth:** `EventHubSpec` (этот документ); Swagger в репозитории — вторичная справка, синхронизируется по факту кода.
### Единая session-модель (`auth_session`)
Таблица Mnesia `auth_session` (`disc_copies`):
| Поле | Описание |
|------|----------|
| `session_id` | Первичный ключ сессии устройства |
| `family_id` | Семейство ротаций одной сессии |
| `subject_id` | ID пользователя или администратора |
| `subject_type` | `user` \| `admin` |
| `client_type` | `admin` \| `web` \| `mobile` |
| `current_jti` | Актуальный jti refresh JWT |
| `expires_at` | Срок жизни сессии (30 дней) |
| `revoked` | Флаг отзыва |
**Refresh JWT (admin)** — claims:
- `typ=refresh`, `aud=admin`, `sub=<admin_id>`
- `sid=<session_id>`, `fid=<family_id>`, `jti=<current_jti>`
- `client=admin`, `exp`, `iat`
**Refresh JWT (user, фаза 2)** — claims:
- `typ=refresh`, `aud=user`, `sub=<user_id>`
- `sid`, `fid`, `jti` — как у admin
- `client=web` (фаза 3: `mobile`), `exp`, `iat`
- Подпись: `JWT_SECRET` (user JWK), отдельно от admin
**Поток login admin** (`POST /v1/admin/login`):
1. Проверка email/password.
2. Создание записи `auth_session`.
3. Ответ: `{ token, refresh_token, user }`.
**Поток refresh admin** (`POST /v1/admin/refresh`):
1. Верификация подписи и срока refresh JWT.
2. Сверка `jti` из JWT с `current_jti` в Mnesia.
3. При совпадении — ротация: новый `jti`, новая пара токенов.
4. При несовпадении (reuse) — `revoke_family`, ответ 401.
**Поток login user** (`POST /v1/login`):
1. Проверка email/password.
2. Создание записи `auth_session` (`subject_type=user`, `client_type=web`).
3. Ответ: `{ token, refresh_token, user }`.
**Поток refresh user** (`POST /v1/refresh`):
1. Верификация refresh JWT (`aud=user`).
2. Сверка `jti` с `current_jti` в Mnesia, ротация при успехе.
3. Reuse — `revoke_family`, 401.
**Фазы внедрения:**
- Фаза 1 (#23): admin API — реализовано.
- Фаза 2 (#26): user login + `/v1/refresh` — реализовано.
- Фаза 3: client web + mobile (явный `client_type`, тот же контракт).
### Legacy (не используется login/refresh user)
- Таблица `session` (`ram_copies`) и opaque refresh в `core_session` — оставлены в кодовой базе, hot path user API переведён на `auth_session`.
## 7. ВЕРСИОНИРОВАНИЕ И СТАТУС
Текущая версия: 1.5 (MVP, альфа). Включает:
- Гибридную модель повторяющихся событий
- Раздельную JWT-аутентификацию (пользователи/администраторы)
- Полноценную ролевую модель администрирования
- Версионированное админ-API (/v1/admin/...)
- Расширенную инфраструктуру (Traefik, WAF, мониторинг, failover)
- Аудит действий администраторов
- Расширенную статистику для дашборда
- Улучшенную обработку ошибок и валидацию
**Новое в версии 1.5:**
- Расширенная структура данных (новые поля и таблицы, задача #12)
- Дисковое хранение и индексы (задача #13)
- Репликация между узлами кластера с автоочисткой (задача #14)
- Архивирование исторических данных и серверный рендеринг календаря (задача #15)
- Сбор статистики через триггеры Mnesia: upsert `stats_counter` / `stats_daily` (#42, развитие #16)
- Механизм миграций схемы данных (задача #17)
## 8. ОГРАНИЧЕНИЯ И ДОПУЩЕНИЯ
- Система не поддерживает транзакционную целостность между несколькими таблицами на уровне
приложения (полагаемся на Mnesia).
- В текущей версии отсутствует полноценная система уведомлений (только таблица).
- Загрузка файлов (вложения) пока не реализована.
- Серверный рендеринг календаря работает только для владельца календаря.
- Автоматическое архивирование через `archive_controller` в локальном режиме использует
`slave:start`, который устарел; в production планируется `peer`.
## 9. ТРЕБОВАНИЯ К ОКРУЖЕНИЮ
- Erlang/OTP 28
- Docker Engine 27.3.1+
- Docker Compose v3.8
- Linux (продакшен) или WSL2 (разработка)