52 KiB
ТЕХНИЧЕСКОЕ ЗАДАНИЕ (ТЗ) НА ПЛАТФОРМУ 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); - все
pendingbooking календарей владельца →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и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 при чтении списков/GETbooking и в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_share(права read/write/admin — фаза 2).
Приглашение (specialist_invite)
Владелец commercial не вводит сырой user_id в продуктовом UI. Добавление — через invite:
| Канал | Как |
|---|---|
| In-app | typeahead (lookup) → invite по user_id → уведомление invitee → accept/decline |
invite по email (юзер есть или ещё нет) → письмо со ссылкой → после логина/регистрации тот же accept |
Модель specialist_invite (логическая; таблица/поля — на усмотрение Back при миграции):
id,calendar_id,inviter_id(owner)invitee_user_id(если известен) и/илиinvitee_emailname/specialization(опционально, подставляются вcalendar_specialistпри accept)status:pending|accepted|declined|expired|cancelledtoken(для 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: запись
notificationinvitee (если user известен) и email (если задан email; если только user_id — email на адрес профиля, если есть).
Lookup для typeahead
GET /v1/users/lookup?q=(auth, rate-limit): поиск по точному email и/или prefixnicknameсреди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,specializationDELETE /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|…/declinePOST /v1/specialist-invites/acceptс{ token }— accept по email-ссылке (гость → логин)
Legacy: прямой POST /v1/calendars/:id/specialists не используется клиентским UI;
допускается только как внутренний/тестовый путь или удаляется после миграции на invite.
Продуктовый путь: invite → accept → specialist.
event.specialist_idопционален; если задан — толькоactivespecialist этого календаря (иначе400).- Confirm/decline booking: владелец — любые booking календаря;
activespecialist — только 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. Материализация при записи участника
При записи участника на конкретное вхождение повторяющегося события:
- Система материализует (создаёт) физическую запись события для этого вхождения, если оно ещё не было материализовано (например, для хранения количества записавшихся).
- Материализованное событие имеет
is_instance = trueи ссылается наmaster_id. - Запись участника (
booking) всегда привязывается к конкретному экземпляру (материализованному или одиночному событию).
2.2.4. Изменение и удаление серий
- При редактировании мастера можно применить изменения ко всем будущим экземплярам или создать новый мастер с отдельной серией.
- При удалении мастера удаляются все связанные экземпляры, если на них нет активных записей.
Если есть активные записи, мастер-событие помечается как
cancelled, а существующие записи остаются.
2.2.5. Структура записей (records.hrl)
Актуальная структура записей включает дополнительные поля, добавленные в рамках задачи #12:
event— добавленыattachments :: [binary()] | undefined,edit_history :: [map()] | undefinedbooking— добавленыnotes :: binary() | undefined,reminder_sent :: boolean()review— добавленыlikes :: non_neg_integer(),dislikes :: non_neg_integer(),edited_at :: calendar:datetime() | undefinedreview_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— только приconfirmedbooking пользователя на это событие;calendar— только приconfirmedbooking на любое событие этого календаря;- иначе 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/votebody{"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; defaultlimit=20, max100). - Поиск должен учитывать права доступа (не показывать скрытые/заблокированные календари).
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, defaultflag),match_mode(word_boundary|substring, defaultword_boundary),report_threshold(integer ≥ 1, default3). - При 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, reasonauto: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(Mnesiadetailedsubscribe) → 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 (
detailedsubscribe): upsert-счётчикиstats_counterи дневные бакетыstats_daily(#42; ранее пробный append-onlystatsв #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(stublogic_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→ подписка/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— подтвердить/отклонить (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-op200).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(CIrun_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— как у adminclient=web(фаза 3:mobile),exp,iat- Подпись:
JWT_SECRET(user JWK), отдельно от admin
Поток login admin (POST /v1/admin/login):
- Проверка email/password.
- Создание записи
auth_session. - Ответ:
{ token, refresh_token, user }.
Поток refresh admin (POST /v1/admin/refresh):
- Верификация подписи и срока refresh JWT.
- Сверка
jtiиз JWT сcurrent_jtiв Mnesia. - При совпадении — ротация: новый
jti, новая пара токенов. - При несовпадении (reuse) —
revoke_family, ответ 401.
Поток login user (POST /v1/login):
- Проверка email/password.
- Создание записи
auth_session(subject_type=user,client_type=web). - Ответ:
{ token, refresh_token, user }.
Поток refresh user (POST /v1/refresh):
- Верификация refresh JWT (
aud=user). - Сверка
jtiсcurrent_jtiв Mnesia, ротация при успехе. - 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 (разработка)