From 8e8e361e779f2a8b1fafedb2ca12db8c41361f28 Mon Sep 17 00:00:00 2001 From: Aleksey Sabilin Date: Fri, 14 Aug 2026 10:52:30 +0300 Subject: [PATCH] docs(spec): POST bookings occurrence_start. Refs EventHub/EventHubBack#67 --- EventHubBackSpec.md | 73 +++++++++++++++++++++++++++++++++++---------- 1 file changed, 58 insertions(+), 15 deletions(-) diff --git a/EventHubBackSpec.md b/EventHubBackSpec.md index 242344c..fd7f09d 100644 --- a/EventHubBackSpec.md +++ b/EventHubBackSpec.md @@ -19,10 +19,20 @@ EventHub — платформа для управления событиями - Гибкое подтверждение заявок: `auto` | `manual` | `{timeout, N}` (секунды) — детали §2.1.2 / §2.3 - Теги календаря, рейтинг (средняя оценка, количество голосов) - После успешной верификации email (`POST /v1/verify`, статус пользователя → `active`) система - идемпотентно создаёт дефолтный **personal**-календарь владельцу (`logic_calendar:ensure_default_calendar/1`): - название — `nickname` или «Мой календарь», `confirmation=manual`. Повторный вызов не создаёт дубликат, - если у пользователя уже есть active personal-календарь. Существующим пользователям без календаря - backfill не выполняется. + идемпотентно нормализует **единственный 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 и поиска @@ -105,6 +115,10 @@ Trial стартует **только** явным `POST /v1/subscription` с `a - `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: @@ -123,12 +137,26 @@ Trial стартует **только** явным `POST /v1/subscription` с `a #### Специалисты -- Специалист = существующий `user`, привязанный к commercial-календарю после **принятия приглашения**. +- Специалист = существующий `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: +Владелец commercial **не** вводит сырой `user_id` в продуктовом UI для **других** +специалистов. Добавление команды — через invite: | Канал | Как | |-------|-----| @@ -172,7 +200,10 @@ Trial стартует **только** явным `POST /v1/subscription` с `a - `POST /v1/calendars/:id/specialist-invites` — тело: `{ user_id }` **или** `{ email }`, опционально `name`, `specialization` - `DELETE /v1/calendars/:id/specialist-invites/:invite_id` — отмена pending (`cancelled`) -- `PUT/DELETE /v1/calendars/:id/specialists/:user_id` — deactivate / remove уже принятого +- `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: @@ -223,12 +254,11 @@ Legacy: прямой `POST /v1/calendars/:id/specialists` **не использ - Возвращать как одиночные, так и сгенерированные вхождения в едином списке. #### 2.2.3. Материализация при записи участника -При записи участника на конкретное вхождение повторяющегося события: -- Система материализует (создаёт) физическую запись события для этого вхождения, если оно ещё - не было материализовано (например, для хранения количества записавшихся). -- Материализованное событие имеет `is_instance = true` и ссылается на `master_id`. -- Запись участника (`booking`) всегда привязывается к конкретному экземпляру (материализованному - или одиночному событию). +`POST /v1/events/:id/bookings` на мастер серии: +- в теле JSON: `occurrence_start` — время вхождения; +- если instance с этим `start_time` ещё нет — создаётся (`is_instance=true`, `master_id`); +- `booking.event_id` — id материализованного вхождения, не master. +Одиночные события бронируются как раньше (тело может быть пустым `{}`). #### 2.2.4. Изменение и удаление серий - При редактировании мастера можно применить изменения ко всем будущим экземплярам или создать @@ -510,14 +540,27 @@ src/ - `POST /v1/specialist-invites/:id/accept` | `…/decline` — ответ invitee. - `POST /v1/specialist-invites/accept` `{ token }` — accept по email deep-link. - `GET /v1/calendars/:calendar_id/events` — события календаря. + В 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` — событие. +- `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` — запись на событие. +- `POST /v1/events/:id/bookings` — запись на событие. Recurring master: тело + `{ "occurrence_start": "" }` обязательно; booking на материализованный instance. - `GET /v1/events/:id/bookings` — список бронирований события (владелец). - `GET /v1/bookings/:id` — статус бронирования. - `PUT /v1/bookings/:id` — подтвердить/отклонить (`confirm`|`decline`); владелец —