Files
EventHubSpec/EventHubBackSpec.md
T

707 lines
57 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``normalize_owner_personals/1`):
- title в БД — **`Default`** (EN); UI — i18n «По умолчанию»;
- `confirmation=manual`;
- если personal нет — создаёт; если есть — оставляет **самый ранний** (`created_at`), title → `Default`;
- лишние personal: с событиями/специалистами → `type=commercial` (system path, без требования
subscription); пустые → soft-delete.
- Повторный вызов идемпотентен. Миграция `20260730200000_single_default_personal`
`backfill_single_personal/0` для всех владельцев.
- `POST /v1/calendars` с `type=personal` при уже существующем personal → **409**
`{error: "personal_exists"}`.
- `DELETE` единственного personal → **403** `{error: "default_calendar"}`.
- `PUT` `personal → commercial` на единственном personal → **403**
`{error: "default_calendar"}` (новый бизнес — отдельный create commercial).
**Новые поля (задача #12):**
- `short_name` — короткое уникальное имя для API и поиска
- `category` — категория (enum)
- `color` — цвет отображения
- `image_url` — изображение календаря
- `settings` — дополнительные настройки (map). Известные org-default ключи
(валидируются на PUT/PATCH; неизвестные ключи — passthrough, напр. `week_patterns`):
- `default_location``{ "address": "…", "lat"?: number, "lon"?: number }`;
`address` — непустая строка; `lat`/`lon` — оба или ни одного (числа)
- `default_duration_minutes` — integer 1..1440
- `default_recurrence``null` или
`{ "enabled": boolean, "freq": "DAILY"|"WEEKLY"|"MONTHLY", "interval": integer ≥ 1 }`
- Невалидное значение известного ключа → **400** `{error: "invalid_settings", key: "…"}`
(см. EventHub/EventHubBack#63, UI: EventHub/EventHubFront#44)
### 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` (нужна **уже active** подписка
или trial; auto-start trial при create **нет** — см. ниже и §2.9).
Периодический job (например раз в 60 с): помечает просроченные подписки `expired`, отменяет
pending владельца, **не** меняет `calendar.type`. Legacy `downgrade_user_calendars/1`
(type → personal) — удалить/не использовать.
Create / upgrade `commercial` без **уже active** подписки или trial → **`402`**.
Trial стартует **только** явным `POST /v1/subscription` с `action=start_trial`
(один раз, `trial_used`); при create commercial auto-start trial **нет**.
Планы/цены — §2.9.
В ответах календаря (user API): `booking_open` (boolean) — производное от
`type=commercial` ∧ calendar `active` ∧ подписка владельца active.
#### Запись и capacity
- `POST /v1/events/:id/bookings` только если календарь события commercial, `booking_open=true`,
событие `active`, есть свободная вместимость.
- Для **recurring** master в теле обязательно `occurrence_start` (ISO8601). Без поля → `400`
(`occurrence_start required`). Невалидное или отменённое вхождение → `400`.
Back материализует instance (`is_instance=true`, `master_id`) и вешает booking на его `id`.
Для `single` / уже материализованного instance тело опционально, `occurrence_start` игнорируется.
- **Pending занимает capacity** наравне с confirmed (защита от overbook при auto/timeout).
- Capacity: число booking со статусом `pending` | `confirmed`; `cancelled` и `expired` не считаются.
- Политика `confirmation` календаря при создании booking:
- `auto` → сразу `confirmed` + `confirmed_at`;
- `manual``pending`; confirm/decline — владелец или specialist (см. ниже);
- `{timeout, N}``pending`; через N секунд без решения: auto-confirm, если ещё есть
capacity, иначе `cancelled` (`timeout_full`).
- **Past-pending → `expired`:** если событие уже началось (`now >= event.start_time`), а booking
ещё `pending`, статус переводится в `expired` (lazy при чтении списков/`GET` booking и в
`process_timeout_bookings`). `expired` не actionable: Confirm/Decline → `409` (`Booking expired`).
- **Inbox:** `GET /v1/user/booking-requests` возвращает только ещё actionable pending
(до старта события); past-pending помечает `expired` и **не** включает в ответ.
- Участник: `DELETE /v1/bookings/:id` — отмена своей pending/confirmed; для уже `expired`
no-op успех (как для `cancelled`).
- WS: `booking_update` участнику и владельцу (и specialist при confirm на «своём» событии).
#### Специалисты
- Специалист = существующий `user`, привязанный к commercial-календарю
(`calendar_specialist`, `status` active|inactive).
- **Не путать** с `calendar_share` (права read/write/admin — фаза 2).
##### Owner как specialist (одиночки)
- При **create** `commercial` (и при системном upgrade personal→commercial) бэкенд
**идемпотентно** создаёт строку `calendar_specialist` на `owner_id`:
`status=active`, `name` из nickname (иначе email), `specialization=[]`.
- Owner-строка **неудаляема**: `DELETE /v1/calendars/:id/specialists/:owner_id`
`403` (`Owner specialist cannot be removed` / `owner_specialist_protected`).
- «Убрать себя из специалистов» = `PUT` с `status=inactive` (продуктовая галочка);
снова включить — `status=active`. Редактируются также `name` / `specialization`.
- Invite себе **не** требуется. Миграций/backfill старых календарей нет (wipe БД
на стендах при деплое).
##### Приглашение (`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 /v1/calendars/:id/specialists/:user_id` — update `name` / `specialization` / `status`
(в т.ч. owner)
- `DELETE /v1/calendars/:id/specialists/:user_id` — remove принятого; для
`user_id = owner_id`**403** (см. Owner как specialist)
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` опционален на `POST`/`PUT` события; если задан — персистится
и валидируется: только `active` specialist этого календаря (иначе `400`
`Invalid specialist_id for this calendar`).
- **Confirm/decline booking:** владелец — любые booking календаря; `active` specialist — только
booking на событиях, где `event.specialist_id` = его `user_id`.
- **Inbox к подтверждению:** `GET /v1/user/booking-requests` агрегирует **actionable** pending
по тем же правилам (owner — все события своих календарей; specialist — только свои слоты);
past-pending → `expired` и из ответа исключается.
#### Фаза 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. Материализация при записи участника
`POST /v1/events/:id/bookings` на мастер серии:
- в теле JSON: `occurrence_start` — время вхождения;
- если instance с этим `start_time` ещё нет — создаётся (`is_instance=true`, `master_id`);
- `booking.event_id` — id материализованного вхождения, не master.
Одиночные события бронируются как раньше (тело может быть пустым `{}`).
#### 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`, `expired`.
- **Capacity:** лимит слотов считают `pending` + `confirmed` (pending резервирует место;
`cancelled` / `expired` не занимают).
- **Истечение:** past-pending (событие уже началось) → `expired` (lazy + timeout job); не
путать с `specialist_invite.status=expired`.
- Участник может отменить свою запись (`DELETE`) для pending/confirmed; `expired`/`cancelled`
идемпотентный успех.
- Confirm/decline: владелец календаря — любые заявки; active specialist — только на событиях
со своим `specialist_id` (§2.1.2). На `expired` (и после lazy-mark) → `409`.
- При подтверждении фиксируется `confirmed_at`.
- Inbox `GET /v1/user/booking-requests` — только pending до старта события (§2.1.2).
### 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 стартует **только**
явным `POST /v1/subscription` (`action=start_trial`), не при create/upgrade commercial
(§2.1.2). Create/upgrade commercial без уже active sub/trial → `402`.
- Истечение: периодический 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/forgot-password``{email}` → всегда `200` (без enumeration); письмо со сбросом
только для `active` (stub `logic_email:send_password_reset/2`, как verification).
- `POST /v1/reset-password``{token, password}` (пароль ≥ 8 символов); успех → новый hash,
токен удалён, refresh-сессии user отозваны; `404`/`410`/`400`/`403`.
- `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/booking-requests`**actionable** pending-заявки к подтверждению, где
текущий пользователь — **owner** календаря события или **assigned specialist**
(`event.specialist_id` = user и specialist active). Past-pending помечается `expired` и
**не** попадает в ответ. Ответ — массив объектов booking + `role` (`owner`|`specialist`) +
вложенный `event` (`id`, `calendar_id`, `calendar_title`, `title`, `start_time`, `duration`,
`specialist_id`). Confirm/decline — существующий `PUT /v1/bookings/:id`.
- `GET /v1/user/reviews` — отзывы пользователя.
- `GET /v1/user/following` — календари, которые пользователь отслеживает (follow).
- `GET /v1/search` — поиск; пустой запрос (только auth + пагинация/`type`) — discovery tops.
В calendar-результатах — `image_url` (если задан на календаре). Upload/multipart
**не** входит в контракт: поле URL.
- `GET /v1/calendars` — список календарей.
- `POST /v1/calendars` — создать календарь (`commercial` → нужна уже active sub/trial;
иначе `402`; auto-start trial нет).
- `GET /v1/calendars/:id` — календарь (`following`, `booking_open` для текущего контекста).
- `PUT /v1/calendars/:id` — обновить календарь (`personal→commercial` → нужна уже
active sub/trial, иначе `402`).
- `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` — события календаря.
В JSON каждого события commercial-календаря — опциональное поле
`booking_occupancy`: `"free"` | `"pending"` | `"confirmed"`.
Считается по **active** bookings события (`pending` | `confirmed`);
`cancelled` / `expired` **не** дают занятость. Приоритет агрегата:
`confirmed` > `pending` > `free` (если есть хотя бы один confirmed →
`"confirmed"`; иначе если есть pending → `"pending"`; иначе `"free"`).
Для personal: поле можно omit или всегда `"free"`.
Virtual occurrences (expand списка): occupancy по **event id в ответе**
(материализованный instance, если он уже есть и его id отдан; иначе —
тот id, с которым Back отдаёт вхождение — обычно master / шаблон —
bookings смотрятся по этому id).
- `POST /v1/calendars/:calendar_id/events` — создать событие (тело может включать
опциональный `specialist_id`; invalid → `400`).
- `GET /v1/events/:id` — событие (тот же контракт `booking_occupancy`, что
у списка events выше).
- `PUT /v1/events/:id` — обновить событие (в т.ч. `specialist_id`; invalid → `400`).
- `DELETE /v1/events/:id` — удалить событие.
- `GET /v1/events/:id/occurrences` — вхождения повторяющегося события.
- `DELETE /v1/events/:id/occurrences/:start_time` — отменить вхождение серии.
- `POST /v1/events/:id/bookings` — запись на событие. Recurring master: тело
`{ "occurrence_start": "<ISO8601>" }` обязательно; booking на материализованный instance.
- `GET /v1/events/:id/bookings` — список бронирований события (владелец).
- `GET /v1/bookings/:id` — статус бронирования.
- `PUT /v1/bookings/:id` — подтвердить/отклонить (`confirm`|`decline`); владелец —
любые booking календаря; active specialist — только события со своим `specialist_id`.
На past-pending / уже `expired``409` (`Booking expired`); при полной вместимости на
confirm → `409` (`Event is full`).
- `DELETE /v1/bookings/:id` — отменить бронирование (участник; `expired`/`cancelled` — no-op `200`).
- `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``action=start_trial` | `activate` (+ `plan`, опционально
`payment_info`). Trial — только через `start_trial`, не через create commercial.
- `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/users/:id/verification-token` — get/create email-verify token (стенды).
- `GET /v1/admin/users/:id/password-reset-token` — get/create password-reset token (стенды).
- `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 (разработка)