Fixes EventHub/EventHubSpec#20 Co-authored-by: Cursor <cursoragent@cursor.com>
67 KiB
ТЕХНИЧЕСКОЕ ЗАДАНИЕ (ТЗ) НА ПЛАТФОРМУ EVENTHUB
Версия: 1.5 (актуальная реализация: выполнены задачи #12–#17; §2.1.2 commercial — контракт к реализации)
1. ЦЕЛИ И НАЗНАЧЕНИЕ
EventHub — платформа для управления событиями с поддержкой календарей, записи участников (включая специалистов), гибкого подтверждения, рейтингов, отзывов, модерации, встроенного баг-трекера и платной подписки.
Целевая аудитория: владельцы календарей (бизнес), участники (клиенты), администраторы.
2. ФУНКЦИОНАЛЬНЫЕ ТРЕБОВАНИЯ
2.1. Календари
- CRUD календаря (название, описание, теги, владелец)
- Расшаривание по ссылке / приглашения с правами (
calendar_share) — invite/accept/revoke + ACLread|write|admin(Back#73); personal и commercial (заместитель); UI — Front; не путать соspecialist_invite/ follow. - Типы календарей:
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"}.PUTpersonal → commercialна единственном personal → 403{error: "default_calendar"}(новый бизнес — отдельный create commercial).
- title в БД —
Новые поля (задача #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..1440default_recurrence—nullили{ "enabled": boolean, "freq": "DAILY"|"WEEKLY"|"MONTHLY", "interval": integer ≥ 1 }waitlist_enabled— boolean (только смысл для commercial; defaultfalse): лист ожидания на полных слотах (Back#72)- Невалидное значение известного ключа → 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 grant | публичный просмотр; в 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(нужна уже 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 при чтении списков/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 на «своём» событии).
Лист ожидания (commercial, опционально)
Включается флагом settings.waitlist_enabled=true на commercial-календаре
(default false; personal игнорируется).
POST /v1/events/:id/waitlist— встать в очередь: commercial,booking_open,waitlist_enabled, слот полный (pending+confirmed≥ capacity), нет своей активной booking, ещё не в очереди. Иначе400:waitlist_disabled|not_full|already_booked|already_on_waitlist| …DELETE /v1/events/:id/waitlist— выйти из очереди (left);404если не в waiting.GET /v1/events/:id/waitlist— гость:{enabled, joined, position?, total}; owner/specialist: список waiting (FIFO поcreated_at).- Promote: при освобождении места (cancel / decline / expire / timeout-cancel)
первый
waiting→ booking по политикеconfirmationкалендаря; статус entrypromoted; email + in-appwaitlist_promoted.
Специалисты
- Специалист = существующий
user, привязанный к commercial-календарю (calendar_specialist,statusactive|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 |
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 /v1/calendars/:id/specialists/:user_id— updatename/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|…/declinePOST /v1/specialist-invites/acceptс{ token }— accept по email-ссылке (гость → логин)
Legacy: прямой POST /v1/calendars/:id/specialists не используется клиентским UI;
допускается только как внутренний/тестовый путь или удаляется после миграции на invite.
Продуктовый путь: invite → accept → specialist.
event.specialist_idопционален наPOST/PUTсобытия; если задан — персистится и валидируется: толькоactivespecialist этого календаря (иначе400Invalid specialist_id for this calendar).- 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 (вне текущего контракта реализации; трекер Spec#8)
- Pilot / ops: SMTP / transactional email; backup; secrets / certs / alerts; legal stubs;
позиция и боевой эквайринг подписки владельца (не оплата услуги клиентом); DNS/SPF
для
calentiq.com(домен в использовании; чеклистBRANDING.md). - Waitlist на событие — сделано (Back#72): таблица
waitlist_entry; APIPOST/DELETE/GET /v1/events/:id/waitlist; только commercial сsettings.waitlist_enabled=true; join при полном слоте; FIFO promote при cancel/decline/expire + email/in-appwaitlist_promoted. UI join — Front#70. - Серверный logout / revoke session; загрузка файлов avatar/cover — сделано (Back#71); вложения к событию — позже.
- Email-reminder booking — сделано (Back#70, §2.6); Web Push + prefs —
сделано (Back#75):
GET/PUT /v1/notifications/prefs,GET /v1/push/vapid-public-key,POST/DELETE /v1/push/subscriptions; UI — Front#71. calendar_share(read|write|admin): соредактор personal / заместитель commercial; invite/accept/revoke + grants; personal не в search/discovery. Не путать с follow, specialist_invite и платной subscription.- Управление своими сессиями +
client_type/device_name— сделано (Back#74):GET/DELETE /v1/sessions,POST /v1/sessions/revoke-others; login принимаетclient_type(web|mobile) иdevice_name, сохраняетUser-Agent. UI списка — Front#72. Kick-on-login / нативный клиент — по-прежнему future. - Не в фазе 2: оплата услуги клиентом (B2C) — вне горизонта продукта.
Фаза 3 / архив (Spec#19, канон Spec#20)
- История календаря: hot = текущий месяц + будущее; warm = 3 закрытых месяца;
cold = gzip-файлы. Контракт:
ARCHIVE.md. Код: Back#76.
2.1.3. Share / приглашения (соредактор и заместитель)
Таблица calendar_share (id, calendar_id, user_id, rights: read | write | admin,
mirror_to_default) и calendar_share_invite (зеркало specialist_invite + rights).
- Invite создаёт owner или share
admin(personal и commercial). - Accept → upsert grant; для personal
mirror_to_defaultdefaulttrue(выбор invitee); commercial mirror игнорируется / false. - ACL:
can_accesspersonal = owner | share; commercial public view + share edit;can_edit= owner | sharewrite|admin. DELETE calendar / смена type — только owner. GET /v1/calendars= owned ∪ shared (role,share_rights,mirror_to_default).- Personal никогда не discoverable в
/v1/search.
API:
GET/POST /v1/calendars/:id/share-invites,DELETE …/:invite_idGET /v1/calendars/:id/shares,DELETE/PUT …/shares/:user_idPUT /v1/user/shares/:calendar_id{mirror_to_default}GET /v1/user/share-invites,POST /v1/share-invites/:id/accept|decline,POST /v1/share-invites/accept{token}
Не путать со specialist_invite (booking team) и calendar_follow (публичный follow).
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()] | 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в бронировании; joblogic_booking:process_reminders/0(тикsubscription_worker, ~15s) шлёт email клиенту записи и in-appevent_reminder, еслиstatus=confirmed,reminder_sent=false, событиеactiveи старт в окнеREMINDER_LEAD_HOURS(default 24). Флаг ставится до отправки (один раз). Шаблон CalenTIQ; ссылка/c/:calendarId/e/:eventId. Prefs:user.preferences.notify_email/notify_push(default true);GET/PUT /v1/notifications/prefs→{ email, push }. Web Push (Back#75): таблицаpush_subscription; VAPID envVAPID_PUBLIC_KEY/VAPID_PRIVATE_KEY/VAPID_SUBJECT; send на reminder и waitlist promote (еслиnotify_push); 404/410 удаляет subscription. Verify / invite / reset password — email всегда (не через prefs).
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. - Пилот (Spec#18, вариант A): боевого PSP нет.
process_payment— заглушка → ok. Владелец получает commercial через: (1)POST /v1/subscriptionaction=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(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).
- Архив календаря (Фаза 3, ARCHIVE.md, Spec#20): hot
disc_copies(текущий месяц + будущее) +month_snapshotdisc_only(3 закрытых месяца) + gzip-файлы старше. Не extra-node и не построчныйevent_archive(#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.
- Архив месяца: тот же ACL, что у живого календаря (
can_access: owner | share). Guest/public deep-archive чужой студии — не в волне 1 (ARCHIVE.md).
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_events, ...)
├── infra/ — infra_mnesia, infra_sup, month_archive_worker, stats_collector,
│ migration_engine, ...
├── logic/ — в т.ч. logic_month_archive (снимки месяца)
├── 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— вход; опциональноclient_type(web|mobile, defaultweb),device_name(max 120);User-Agentпишется в сессию. Ответ:{ token, refresh_token, session_id, user }. Неверныйclient_type→400.POST /v1/refresh— обновление токена; ответ{ token, refresh_token, session_id }.POST /v1/logout—{ refresh_token }→ отзыв текущейauth_session; после этого refresh той же сессии →401. Access JWT до истечения TTL не отзывается. Невалидный refresh →401; отсутствие поля →400. Клиент чистит storage даже при ошибке сети.GET /v1/sessions— список активных своих user-сессий (Bearer):session_id,client_type,device_name,user_agent,created_at,updated_at,expires_at.DELETE /v1/sessions/:id— отзыв своей сессии; чужая/нет →404.POST /v1/sessions/revoke-others— Bearer +{ refresh_token }: оставить сессию из refresh, отозвать остальные; чужой subject →403; невалидный refresh →401.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). Учитываются и материализованные 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. Personal никогда не в выдаче (в т.ч. свои). С Bearer — commercial + доступные; busy для Time Arc берётся из personal отдельно. В calendar-результатах —image_url(если задан на календаре). Upload/multipart не входит в контракт: поле URL.GET /v1/calendars— список календарей (auth): owned ∪ shared (role,share_rights,mirror_to_default).POST /v1/calendars— создать календарь (commercial→ нужна уже active sub/trial; иначе402; auto-start trial нет).GET /v1/calendars/:id— календарь или uniqueshort_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/POST /v1/calendars/:id/share-invites— исходящие share invite / создать (owner|admin).DELETE /v1/calendars/:id/share-invites/:invite_id— отменить pending.GET /v1/calendars/:id/shares— принятые grants;DELETE/PUT …/shares/:user_id.PUT /v1/user/shares/:calendar_id— свойmirror_to_default.GET /v1/user/share-invites— входящие share invites.POST /v1/share-invites/:id/accept|…/decline;POST /v1/share-invites/accept{token}.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— список бронирований события (владелец).POST /v1/events/:id/waitlist— лист ожидания (commercial +settings.waitlist_enabled); см. §2.1.2 «Лист ожидания».DELETE /v1/events/:id/waitlist— выйти из очереди.GET /v1/events/:id/waitlist— статус (гость) или список (owner/specialist).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—action=start_trial|activate(+plan, опциональноpayment_info). Trial — только черезstart_trial, не через create commercial.GET /v1/calendars/:calendar_id/events?from&to— события диапазона: hot для живого окна; archived month — изmonth_snapshot/ файла, тот же JSON (ARCHIVE.md, Back#76).
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 администратора.POST /v1/admin/logout—{ refresh_token }→ отзыв adminauth_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 |
Флаг отзыва |
device_name |
Человекочитаемая метка устройства (с login; может быть пустой) |
user_agent |
User-Agent с login (может быть пустой) |
created_at / updated_at |
Создание / последняя ротация или отзыв |
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) — claims:
typ=refresh,aud=user,sub=<user_id>sid,fid,jti— как у adminclient=web|mobile(из сессии; refresh не меняет device/UA)- Подпись:
JWT_SECRET(user JWK), отдельно от admin
Поток login admin (POST /v1/admin/login):
- Проверка email/password.
- Создание записи
auth_session. - Ответ:
{ token, refresh_token, session_id, user }.
Поток refresh admin (POST /v1/admin/refresh):
- Верификация подписи и срока refresh JWT.
- Сверка
jtiиз JWT сcurrent_jtiв Mnesia. - При совпадении — ротация: новый
jti, новая пара токенов +session_id. - При несовпадении (reuse) —
revoke_family, ответ 401.
Поток login user (POST /v1/login):
- Проверка email/password.
- Создание
auth_session(subject_type=user,client_type,device_name,user_agent). - Ответ:
{ token, refresh_token, session_id, user }.
Поток refresh user (POST /v1/refresh):
- Верификация refresh JWT (
aud=user). - Сверка
jtiсcurrent_jtiв Mnesia, ротация при успехе;clientберётся из сессии. - Reuse —
revoke_family, 401.
Фазы внедрения:
- Фаза 1 (#23): admin API — реализовано.
- Фаза 2 (#26): user login +
/v1/refresh— реализовано. - Фаза 3 (Back#74): явный
client_typeweb|mobile, device meta, list/revoke sessions — реализовано. Kick-on-login / concurrent policy по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).
- В текущей версии отсутствует полноценная система уведомлений (только таблица).
- Загрузка файлов: avatar/cover —
POST /v1/user/me/avatar,POST /v1/calendars/:id/cover(multipart fieldfile); MIME jpeg/png/webp (magic bytes), лимитUPLOAD_MAX_BYTES(default 2 MiB) → 413/415; хранение local diskUPLOAD_DIR(default/app/data/uploadsна volumeeventhub-data); отдачаGET /v1/media/:kind/:owner/:file(public). Вложения к событию — пока не реализованы. - HTML
GET …/viewудалён; архив читается JSON-ом (Spec#20 / ARCHIVE.md). archive_controller+ extra-node (slave/peer) удалены; архив —month_snapshotна тех же нодах (Back#76, ARCHIVE.md).
9. ТРЕБОВАНИЯ К ОКРУЖЕНИЮ
- Erlang/OTP 28
- Docker Engine 27.3.1+
- Docker Compose v3.8
- Linux (продакшен) или WSL2 (разработка)