Files
EventHubSpec/EventHubBackSpec.md
T

60 KiB
Raw Blame History

ТЕХНИЧЕСКОЕ ЗАДАНИЕ (ТЗ) НА ПЛАТФОРМУ 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/1normalize_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_personalbackfill_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_recurrencenull или { "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;
    • manualpending; 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_id403 (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: только pendingaccepted | 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_id403 (см. 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 (вне текущего контракта реализации; трекер Spec#8)

  • Pilot / ops: SMTP / transactional email; backup; secrets / certs / alerts; legal stubs; позиция и боевой эквайринг подписки владельца (не оплата услуги клиентом); DNS/SPF без покупки домена без явного ок.
  • Waitlist на событие (после уведомлений).
  • Серверный logout / revoke session; загрузка файлов (avatar/cover); полноценные уведомления / reminders.
  • calendar_share (read | write | admin): низкий приоритет; не путать с follow, specialist_invite и платной subscription. User HTTP API и UI — до отдельной задачи (таблица в схеме есть, см. §2.1.3).
  • client_type=mobile — хвост, когда появится нативный клиент.
  • Не в фазе 2: оплата услуги клиентом (B2C) — вне горизонта продукта.

2.1.3. Share / приглашения (фаза 2)

Таблица calendar_share (calendar_id, user_id, rights: read | write | admin) существует в схеме. User HTTP API и клиентский UI — не стартовать, пока нет явного сценария соредактора personal (совместная работа сейчас: specialist_invite + booking inbox).

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 (reviewhidden, event/calendarfrozen, 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.
  • Пилот (Spec#18, вариант A): боевого PSP нет. process_payment — заглушка → ok. Владелец получает commercial через: (1) POST /v1/subscription action=start_trial, и/или (2) admin activate (POST /v1/admin/subscriptions/:id). Реальный эквайринг подписки владельца — отдельная задача после пилота (не блокер soft-launch).
  • Вне горизонта: оплата услуги клиентом (B2C) — не в фазе 2 / не в пилоте.
  • Статус подписки: 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. Почта: logic_email через SMTP (SMTP_HOST и др.); без хоста — log-only. HTML+plain шаблоны: priv/email/layout.html (CalenTIQ). Ссылки: PUBLIC_APP_URL + /verify?token=, /invites?token=, /reset-password?token= (бренд CalenTIQ).
  • POST /v1/reset-password{token, password} (пароль ≥ 8 символов); успех → новый hash, токен удалён, refresh-сессии user отозваны; 404/410/400/403.
  • POST /v1/login — вход.
  • POST /v1/refresh — обновление токена.
  • POST /v1/logout{ refresh_token } → отзыв текущей auth_session; после этого refresh той же сессии → 401. Access JWT до истечения TTL не отзывается. Невалидный refresh → 401; отсутствие поля → 400. Клиент чистит storage даже при ошибке сети.
  • 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-requestsactionable pending-заявки к подтверждению, где текущий пользователь — owner календаря события или assigned specialist (event.specialist_id = user и specialist active). Учитываются и материализованные occurrence (is_instance). Past-pending помечается expired и не попадает в ответ.
  • GET /v1/user/studio-bookings — pending и confirmed на тех же календарях (журнал студии). Форма ответа как у booking-requests (role + вложенный event).
  • GET /v1/user/reviews — отзывы пользователя.
  • GET /v1/user/following — календари, которые пользователь отслеживает (follow).
  • GET /v1/search — поиск; без токена (гость) — только commercial по can_access; пустой запрос + пагинация/type — discovery tops. С Bearer — как раньше (включая свои personal). В calendar-результатах — image_url (если задан на календаре). Upload/multipart не входит в контракт: поле URL.
  • GET /v1/calendars — список календарей (auth).
  • POST /v1/calendars — создать календарь (commercial → нужна уже active sub/trial; иначе 402; auto-start trial нет).
  • GET /v1/calendars/:id — календарь или unique short_name. Без токена для active commercial (following: false); personal без доступа → 403. С сессией: 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 — список специалистов (гость на commercial — да; personal — как can_access).
  • 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 — события календаря (гость: commercial). В 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 / уже expired409 (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/subscriptionaction=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 администратора.
  • POST /v1/admin/logout{ refresh_token } → отзыв admin auth_session (как user logout).
  • 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 (разработка)