# ТЕХНИЧЕСКОЕ ЗАДАНИЕ (ТЗ) НА ПЛАТФОРМУ EVENTHUB Версия: 1.5 (актуальная реализация: выполнены задачи #12–#17; §2.1.2 commercial — контракт к реализации) ## 1. ЦЕЛИ И НАЗНАЧЕНИЕ EventHub — платформа для управления событиями с поддержкой календарей, записи участников (включая специалистов), гибкого подтверждения, рейтингов, отзывов, модерации, встроенного баг-трекера и платной подписки. Целевая аудитория: владельцы календарей (бизнес), участники (клиенты), администраторы. ## 2. ФУНКЦИОНАЛЬНЫЕ ТРЕБОВАНИЯ ### 2.1. Календари - CRUD календаря (название, описание, теги, владелец) - Расшаривание по ссылке / приглашения с правами (`calendar_share`) — invite/accept/revoke + ACL `read|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"}`. - `PUT` `personal → commercial` на единственном personal → **403** `{error: "default_calendar"}` (новый бизнес — отдельный create commercial). **Новые поля (задача #12):** - `short_name` — короткое уникальное имя для API и поиска - `category` — категория (enum) - `color` — цвет отображения - `image_url` — изображение календаря - `settings` — дополнительные настройки (map). Известные org-default ключи (валидируются на PUT/PATCH; неизвестные ключи — passthrough, напр. `week_patterns`): - `default_location` — `{ "address": "…", "lat"?: number, "lon"?: number }`; `address` — непустая строка; `lat`/`lon` — оба или ни одного (числа) - `default_duration_minutes` — integer 1..1440 - `default_recurrence` — `null` или `{ "enabled": boolean, "freq": "DAILY"|"WEEKLY"|"MONTHLY", "interval": integer ≥ 1 }` - `waitlist_enabled` — boolean (только смысл для commercial; default `false`): лист ожидания на полных слотах (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`); - все `pending` booking календарей владельца → `cancelled` (причина `subscription_inactive`), слоты освобождаются; - уже `confirmed` — **оставляем**; - owner: просмотр/редактирование своих событий и настроек, CRUD specialists — **разрешены**; - create нового commercial / upgrade type → `402` (нужна **уже active** подписка или trial; auto-start trial при create **нет** — см. ниже и §2.9). Периодический job (например раз в 60 с): помечает просроченные подписки `expired`, отменяет pending владельца, **не** меняет `calendar.type`. Legacy `downgrade_user_calendars/1` (type → personal) — удалить/не использовать. Create / upgrade `commercial` без **уже active** подписки или trial → **`402`**. Trial стартует **только** явным `POST /v1/subscription` с `action=start_trial` (один раз, `trial_used`); при create commercial auto-start trial **нет**. Планы/цены — §2.9. В ответах календаря (user API): `booking_open` (boolean) — производное от `type=commercial` ∧ calendar `active` ∧ подписка владельца active. #### Запись и capacity - `POST /v1/events/:id/bookings` только если календарь события commercial, `booking_open=true`, событие `active`, есть свободная вместимость. - Для **recurring** master в теле обязательно `occurrence_start` (ISO8601). Без поля → `400` (`occurrence_start required`). Невалидное или отменённое вхождение → `400`. Back материализует instance (`is_instance=true`, `master_id`) и вешает booking на его `id`. Для `single` / уже материализованного instance тело опционально, `occurrence_start` игнорируется. - **Pending занимает capacity** наравне с confirmed (защита от overbook при auto/timeout). - Capacity: число booking со статусом `pending` | `confirmed`; `cancelled` и `expired` не считаются. - Политика `confirmation` календаря при создании booking: - `auto` → сразу `confirmed` + `confirmed_at`; - `manual` → `pending`; confirm/decline — владелец или specialist (см. ниже); - `{timeout, N}` → `pending`; через N секунд без решения: auto-confirm, если ещё есть capacity, иначе `cancelled` (`timeout_full`). - **Past-pending → `expired`:** если событие уже началось (`now >= event.start_time`), а booking ещё `pending`, статус переводится в `expired` (lazy при чтении списков/`GET` booking и в `process_timeout_bookings`). `expired` не actionable: Confirm/Decline → `409` (`Booking expired`). - **Inbox:** `GET /v1/user/booking-requests` возвращает только ещё actionable pending (до старта события); past-pending помечает `expired` и **не** включает в ответ. - Участник: `DELETE /v1/bookings/:id` — отмена своей pending/confirmed; для уже `expired` — no-op успех (как для `cancelled`). - WS: `booking_update` участнику и владельцу (и specialist при confirm на «своём» событии). #### Лист ожидания (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` календаря; статус entry `promoted`; email + in-app `waitlist_promoted`. #### Специалисты - Специалист = существующий `user`, привязанный к commercial-календарю (`calendar_specialist`, `status` active|inactive). - **Не путать** с `calendar_share` (права read/write/admin — фаза 2). ##### Owner как specialist (одиночки) - При **create** `commercial` (и при системном upgrade personal→commercial) бэкенд **идемпотентно** создаёт строку `calendar_specialist` на `owner_id`: `status=active`, `name` из nickname (иначе email), `specialization=[]`. - Owner-строка **неудаляема**: `DELETE /v1/calendars/:id/specialists/:owner_id` → `403` (`Owner specialist cannot be removed` / `owner_specialist_protected`). - «Убрать себя из специалистов» = `PUT` с `status=inactive` (продуктовая галочка); снова включить — `status=active`. Редактируются также `name` / `specialization`. - Invite себе **не** требуется. Миграций/backfill старых календарей нет (wipe БД на стендах при деплое). ##### Приглашение (`specialist_invite`) Владелец commercial **не** вводит сырой `user_id` в продуктовом UI для **других** специалистов. Добавление команды — через invite: | Канал | Как | |-------|-----| | In-app | typeahead (lookup) → invite по `user_id` → уведомление invitee → accept/decline | | Email | invite по `email` (юзер есть или ещё нет) → письмо со ссылкой → после логина/регистрации тот же accept | Модель `specialist_invite` (логическая; таблица/поля — на усмотрение Back при миграции): - `id`, `calendar_id`, `inviter_id` (owner) - `invitee_user_id` (если известен) и/или `invitee_email` - `name` / `specialization` (опционально, подставляются в `calendar_specialist` при accept) - `status`: `pending` | `accepted` | `declined` | `expired` | `cancelled` - `token` (для email deep-link), `created_at`, `expires_at` (рекомендуемо: 7 суток) - Уникальность pending: одна активная pending-пара на (`calendar_id` + user) или (`calendar_id` + email) Правила: - Создавать invite может только владелец commercial; при `booking_open=false` (restricted) — создание invite **запрещено** (`403` / `402` по политике подписки календаря). - Invitee: только `pending` → `accepted` | `declined`; owner может `cancelled` для своего pending. - **Accept** → создаётся / активируется `calendar_specialist` (`status=active`); invite → `accepted`. - Повторный invite тому же active specialist → `409` / no-op по политике Back. - Email для незарегистрированного: после регистрации/verify с тем же email — pending invite привязывается к `user_id` (или accept по token). - Каналы при создании pending: запись `notification` invitee (если user известен) **и** email (если задан email; если только user_id — email на адрес профиля, если есть). ##### Lookup для typeahead - `GET /v1/users/lookup?q=` (auth, rate-limit): поиск по **точному email** и/или prefix `nickname` среди `active` (+ verified) пользователей. - Ответ — минимум PII: `{ id, nickname }` и **маскированный** email (или email только при точном совпадении запроса с полным email). Не админский список пользователей. ##### API специалистов / invite Владелец: - `GET /v1/calendars/:id/specialists` — active/inactive специалисты - `GET /v1/calendars/:id/specialist-invites` — исходящие invite (фильтр status) - `POST /v1/calendars/:id/specialist-invites` — тело: `{ user_id }` **или** `{ email }`, опционально `name`, `specialization` - `DELETE /v1/calendars/:id/specialist-invites/:invite_id` — отмена pending (`cancelled`) - `PUT /v1/calendars/:id/specialists/:user_id` — update `name` / `specialization` / `status` (в т.ч. owner) - `DELETE /v1/calendars/:id/specialists/:user_id` — remove принятого; для `user_id = owner_id` → **403** (см. Owner как specialist) Invitee: - `GET /v1/user/specialist-invites` — входящие (`pending` и история по желанию) - `POST /v1/specialist-invites/:id/accept` | `…/decline` - `POST /v1/specialist-invites/accept` с `{ token }` — accept по email-ссылке (гость → логин) Legacy: прямой `POST /v1/calendars/:id/specialists` **не используется клиентским UI**; допускается только как внутренний/тестовый путь или удаляется после миграции на invite. Продуктовый путь: invite → accept → specialist. - `event.specialist_id` опционален на `POST`/`PUT` события; если задан — персистится и валидируется: только `active` specialist этого календаря (иначе `400` `Invalid specialist_id for this calendar`). - **Confirm/decline booking:** владелец — любые booking календаря; `active` specialist — только booking на событиях, где `event.specialist_id` = его `user_id`. - **Inbox к подтверждению:** `GET /v1/user/booking-requests` агрегирует **actionable** pending по тем же правилам (owner — все события своих календарей; specialist — только свои слоты); past-pending → `expired` и из ответа исключается. #### Фаза 2 (вне текущего контракта реализации; трекер Spec#8) - **Pilot / ops:** SMTP / transactional email; backup; secrets / certs / alerts; legal stubs; позиция и боевой эквайринг **подписки владельца** (не оплата услуги клиентом); DNS/SPF для `calentiq.com` (домен в использовании; чеклист `BRANDING.md`). - Waitlist на событие — **сделано** (Back#72): таблица `waitlist_entry`; API `POST/DELETE/GET /v1/events/:id/waitlist`; только commercial с `settings.waitlist_enabled=true`; join при полном слоте; FIFO promote при cancel/decline/expire + email/in-app `waitlist_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`](ARCHIVE.md). Код: Back#76. #### Фаза 3 / гео (Spec#19, канон Spec#21) - Стек: OpenCage (внешний API, прокси через бэк) + MapLibre/OpenFreeMap; Google/Яндекс **не** провайдеры (только outbound-ссылки с карточки). Контракт: [`GEO.md`](GEO.md). Код: Back#77. ### 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_default` default `true` (выбор invitee); commercial mirror игнорируется / false. - ACL: `can_access` personal = owner | share; commercial public view + share edit; `can_edit` = owner | share `write|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_id` - `GET /v1/calendars/:id/shares`, `DELETE/PUT …/shares/:user_id` - `PUT /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()] | 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` или любого фильтра из списка выше). - Фильтрация по дате, категории, местоположению, рейтингу. - **Гео (волна 2, [GEO.md](GEO.md)):** `lat`+`lon` выключают discovery-tops. События — по `event.location`; commercial-календари — по `settings.default_location`. `radius` **опционален** (1..100 км); без ключа — без отсечения, сортировка `distance`. В ответе `distance_km`. Без координат сущность в geo-выборку не входит. - Пагинация результатов (`limit`, `offset`; default `limit=20`, max `100`). - Поиск должен учитывать права доступа (не показывать скрытые/заблокированные календари). ### 2.6. Расширенные возможности - Локация события (`location` запись с адресом, широтой, долготой). Address-only допустим; lat/lon оба или ни одного. Геокодер: [`GEO.md`](GEO.md) (`GET /v1/geo/suggest`, `POST /v1/geo/geocode`, `POST /v1/geo/reverse`). - Онлайн-ссылка (`online_link`). - Вложения к событию (`attachments`). - История изменений события (`edit_history`). - Заметки пользователя к бронированию (`notes`). - Напоминания о событии: поле `reminder_sent` в бронировании; job `logic_booking:process_reminders/0` (тик `subscription_worker`, ~15s) шлёт email клиенту записи и in-app `event_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 env `VAPID_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`, default `flag`), `match_mode` (`word_boundary` | `substring`, default `word_boundary`), `report_threshold` (integer ≥ 1, default `3`). - При create/update calendar (`title`, `description`), event (`title`, `description`), review (`comment`) выполняется scan бан-слов. - `reject` — сохранение отклоняется (`400`, content banned); `censor` — маскирование `***` + hit в очередь; `flag` — сохранение + soft-hide + hit (`review` → `hidden`, `event`/`calendar` → `frozen`, reason `auto:keyword`). - Порог жалоб: считаются только `pending`; при достижении порога — soft-hide (`auto:report_threshold`) + hit; dismissed/reviewed не учитываются. - Очередь `automod_hit`: `open` | `approved` | `rejected`; approve восстанавливает сущность, reject оставляет/усиливает блокировку. - WS админам: `automod_hit`; аудит от системного актора `system`. - Аудит действий администраторов (`admin_audit`). ### 2.8. Баг-трекер (автоматический) - Источники тикетов (`source`): `backend` (HTTP 500 / внутренние ошибки), `frontend` (клиентский crash), `manual` (ручной репорт). - При HTTP 500 сервер асинхронно регистрирует тикет через `logic_ticket:report_error` (ответы по путям `/tickets` не репортятся — защита от рекурсии). - Клиент и ручной репорт: `POST /v1/tickets` с `error_message`, опционально `stacktrace`, `context`, `source` (`frontend` | `manual`). Ручной `manual` — сценарий end-user клиентов, не Admin SPA. - Тикет содержит `error_hash` (sha256 от source + сообщение + fingerprint стека; для `manual` — source + user_id + сообщение), стектрейс, JSON-контекст, счётчик повторений. - Дедуп: открытый тикет (`open` / `in_progress`) с тем же `error_hash` — увеличивается `count` и `last_seen`; после `resolved`/`closed` повтор создаёт новый тикет (регрессия). - Новые тикеты лимитируются (~30/мин на узел); повторы по hash не ограничиваются. - При создании нового тикета администраторам уходит WS `ticket_created`. - Администраторы могут просматривать тикеты (фильтр `source`), назначать ответственных, менять статус. ### 2.9. Платная подписка - Пользователи могут оформить подписку (`subscription`) с разными планами: monthly, quarterly, biannual, annual. - Цены (`logic_subscription:plan_price/1`, minor units): monthly **999**, quarterly **2499**, biannual **4499**, annual **7999**; trial — 0. - **Пилот (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). - Архив календаря (Фаза 3, [ARCHIVE.md](ARCHIVE.md), Spec#20): hot `disc_copies` (текущий месяц + будущее) + `month_snapshot` `disc_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](ARCHIVE.md)). ### 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_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`, default `web`), `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. Гео: `lat`+`lon` (и опциональный `radius` 1..100) — ближайшие события и календари; `sort=distance`; в элементах `distance_km` ([GEO.md](GEO.md)). - `GET /v1/geo/suggest` — typeahead адреса (OpenCage); **без токена** + IP rate-limit. - `POST /v1/geo/geocode` — `{q, lang?}` → `{address, lat, lon, source}` (Bearer). - `POST /v1/geo/reverse` — `{lat, lon, lang?}` (Bearer). OpenCage недоступен/квота → `503` `geo_unavailable`. - `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` — календарь **или 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/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": "" }` обязательно; 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-op `200`). - `POST /v1/reviews` — создать отзыв. - `GET /v1/reviews` — список отзывов (поле `my_vote` для текущего пользователя). - `GET /v1/reviews/:id` — отзыв по ID (`my_vote`). - `PUT /v1/reviews/:id` — обновить отзыв. - `DELETE /v1/reviews/:id` — удалить свой отзыв. - `PUT /v1/reviews/:id/vote` — поставить/сменить голос (`{"value":"like"|"dislike"}`). - `DELETE /v1/reviews/:id/vote` — снять голос. - `POST /v1/reports` — пожаловаться (`target_type`: `event` | `calendar` | `review`). - `GET /v1/reports` — список жалоб пользователя. - `GET /v1/tickets` — тикеты пользователя. - `POST /v1/tickets` — создать или обновить тикет (дедуп по `error_hash`): body `{ error_message, stacktrace?, context?, source?: frontend|manual }`; ответ 201 (или 429 при rate-limit новых). - `GET /v1/tickets/:id` — статус тикета. - `GET /v1/subscription` — подписка пользователя. - `POST /v1/subscription` — `action=start_trial` | `activate` (+ `plan`, опционально `payment_info`). Trial — только через `start_trial`, не через create commercial. - `GET /v1/calendars/:calendar_id/events?from&to` — события диапазона: **hot** для живого окна; archived month — из `month_snapshot` / файла, тот же JSON ([ARCHIVE.md](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` (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 `. - **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=` - `sid=`, `fid=`, `jti=` - `client=admin`, `exp`, `iat` **Refresh JWT (user)** — claims: - `typ=refresh`, `aud=user`, `sub=` - `sid`, `fid`, `jti` — как у admin - `client=web` \| `mobile` (из сессии; refresh не меняет device/UA) - Подпись: `JWT_SECRET` (user JWK), отдельно от admin **Поток login admin** (`POST /v1/admin/login`): 1. Проверка email/password. 2. Создание записи `auth_session`. 3. Ответ: `{ token, refresh_token, session_id, user }`. **Поток refresh admin** (`POST /v1/admin/refresh`): 1. Верификация подписи и срока refresh JWT. 2. Сверка `jti` из JWT с `current_jti` в Mnesia. 3. При совпадении — ротация: новый `jti`, новая пара токенов + `session_id`. 4. При несовпадении (reuse) — `revoke_family`, ответ 401. **Поток login user** (`POST /v1/login`): 1. Проверка email/password. 2. Создание `auth_session` (`subject_type=user`, `client_type`, `device_name`, `user_agent`). 3. Ответ: `{ token, refresh_token, session_id, user }`. **Поток refresh user** (`POST /v1/refresh`): 1. Верификация refresh JWT (`aud=user`). 2. Сверка `jti` с `current_jti` в Mnesia, ротация при успехе; `client` берётся из сессии. 3. Reuse — `revoke_family`, 401. **Фазы внедрения:** - Фаза 1 (#23): admin API — реализовано. - Фаза 2 (#26): user login + `/v1/refresh` — реализовано. - Фаза 3 (Back#74): явный `client_type` web|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 field `file`); MIME jpeg/png/webp (magic bytes), лимит `UPLOAD_MAX_BYTES` (default 2 MiB) → 413/415; хранение local disk `UPLOAD_DIR` (default `/app/data/uploads` на volume `eventhub-data`); отдача `GET /v1/media/:kind/:owner/:file` (public). Вложения к событию — пока не реализованы. - HTML `GET …/view` **удалён**; архив читается JSON-ом (Spec#20 / [ARCHIVE.md](ARCHIVE.md)). - `archive_controller` + extra-node (`slave`/`peer`) **удалены**; архив — `month_snapshot` на тех же нодах (Back#76, [ARCHIVE.md](ARCHIVE.md)). ## 9. ТРЕБОВАНИЯ К ОКРУЖЕНИЮ - Erlang/OTP 28 - Docker Engine 27.3.1+ - Docker Compose v3.8 - Linux (продакшен) или WSL2 (разработка)