14da77fd7e
Fixes EventHub/EventHubSpec#20 Co-authored-by: Cursor <cursoragent@cursor.com>
822 lines
67 KiB
Markdown
822 lines
67 KiB
Markdown
# ТЕХНИЧЕСКОЕ ЗАДАНИЕ (ТЗ) НА ПЛАТФОРМУ 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.
|
||
|
||
### 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` или любого
|
||
фильтра из списка выше).
|
||
- Фильтрация по дате, категории, местоположению, рейтингу.
|
||
- Пагинация результатов (`limit`, `offset`; default `limit=20`, max `100`).
|
||
- Поиск должен учитывать права доступа (не показывать скрытые/заблокированные календари).
|
||
|
||
### 2.6. Расширенные возможности
|
||
- Локация события (`location` запись с адресом, широтой, долготой).
|
||
- Онлайн-ссылка (`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.
|
||
- `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": "<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-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 <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` — как у 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 (разработка) |