From 6a9daffec715fbd3aabe2c47e4907b17c326788f Mon Sep 17 00:00:00 2001 From: Aleksey Sabilin Date: Wed, 22 Jul 2026 18:29:35 +0300 Subject: [PATCH] docs: specialist_invite contract (in-app + email). Fixes EventHub/EventHubSpec#7 --- EventHubBackSpec.md | 81 +++++++++++++++++++++++++++++++++++++++----- EventHubFrontSpec.md | 15 +++++--- 2 files changed, 82 insertions(+), 14 deletions(-) diff --git a/EventHubBackSpec.md b/EventHubBackSpec.md index 9c84d76..a6105cb 100644 --- a/EventHubBackSpec.md +++ b/EventHubBackSpec.md @@ -106,17 +106,74 @@ pending владельца, **не** меняет `calendar.type`. Legacy `downg #### Специалисты -- Специалист = существующий `user`, привязанный к commercial-календарю. -- API (владелец календаря): - - `GET/POST /v1/calendars/:id/specialists` - - `PUT/DELETE /v1/calendars/:id/specialists/:user_id` +- Специалист = существующий `user`, привязанный к commercial-календарю после **принятия приглашения**. +- **Не путать** с `calendar_share` (права read/write/admin — фаза 2). + +##### Приглашение (`specialist_invite`) + +Владелец commercial **не** вводит сырой `user_id` в продуктовом UI. Добавление — через invite: + +| Канал | Как | +|-------|-----| +| In-app | typeahead (lookup) → invite по `user_id` → уведомление invitee → accept/decline | +| 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/DELETE /v1/calendars/:id/specialists/:user_id` — deactivate / remove уже принятого + +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` опционален; если задан — только `active` specialist этого календаря (иначе `400`). -- **Confirm/decline:** владелец — любые booking календаря; `active` specialist — только booking - на событиях, где `event.specialist_id` = его `user_id`. +- **Confirm/decline booking:** владелец — любые booking календаря; `active` specialist — только + booking на событиях, где `event.specialist_id` = его `user_id`. #### Фаза 2 (вне текущего контракта реализации) -- `calendar_share` (`read` | `write` | `admin`): не путать с follow и платной subscription. +- `calendar_share` (`read` | `write` | `admin`): не путать с follow, specialist_invite и платной subscription. - Реальный эквайринг, waitlist, оплата услуги клиентом (B2C). ### 2.1.3. Share / приглашения (фаза 2) @@ -403,8 +460,14 @@ src/ - `DELETE /v1/calendars/:id` — удалить календарь. - `POST /v1/calendars/:id/follow` — отслеживать чужой календарь. - `DELETE /v1/calendars/:id/follow` — снять follow. -- `GET/POST /v1/calendars/:id/specialists` — список / добавить специалиста (владелец). -- `PUT/DELETE /v1/calendars/:id/specialists/:user_id` — обновить / убрать специалиста. +- `GET /v1/users/lookup?q=` — typeahead пользователей для invite (минимальный PII, rate-limit). +- `GET /v1/calendars/:id/specialists` — список специалистов (владелец). +- `PUT/DELETE /v1/calendars/:id/specialists/:user_id` — deactivate / убрать специалиста (владелец). +- `GET/POST /v1/calendars/:id/specialist-invites` — исходящие invite / создать (владелец). +- `DELETE /v1/calendars/:id/specialist-invites/:invite_id` — отменить pending (владелец). +- `GET /v1/user/specialist-invites` — входящие приглашения. +- `POST /v1/specialist-invites/:id/accept` | `…/decline` — ответ invitee. +- `POST /v1/specialist-invites/accept` `{ token }` — accept по email deep-link. - `GET /v1/calendars/:calendar_id/events` — события календаря. - `POST /v1/calendars/:calendar_id/events` — создать событие. - `GET /v1/events/:id` — событие. diff --git a/EventHubFrontSpec.md b/EventHubFrontSpec.md index 0bf7ffc..170864b 100644 --- a/EventHubFrontSpec.md +++ b/EventHubFrontSpec.md @@ -125,8 +125,13 @@ Redirects: `/discover` → `/search`; `/calendars/:id` → `/c/:id`; `/calendars - HTML month view владельца: `GET /v1/calendars/:calendar_id/view?month=YYYY-MM` — при переключении виджета на **прошедший месяц** (owner + вид «Месяц») автоматически подменяет клиентский grid серверным HTML (live/архив, задача #15). Текущий и будущие месяцы — React-виджет по `GET …/events`. - Создание / апгрейд `commercial` без подписки → `402` → `/subscription`; после оплаты — возврат к созданию/редактированию -- Владелец commercial: управление специалистами (`GET/POST/PUT/DELETE …/specialists`); - выбор `specialist_id` в форме события; баннер «подписка истекла / истекает» +- Владелец commercial: специалисты **через invite** (не сырой `user_id`): + typeahead `GET /v1/users/lookup` и/или email → `POST …/specialist-invites`; + список active + исходящие pending; deactivate/remove принятых. + Выбор `specialist_id` в форме события (только `active`). + Баннер «подписка истекла / истекает» при `booking_open=false`. +- Invitee: inbox входящих `GET /v1/user/specialist-invites` (More / уведомления) — + принять / отклонить; deep-link из email → accept по `token` после логина. ### 5.5. События - Список: `GET /v1/calendars/:calendar_id/events` @@ -206,12 +211,12 @@ E2E: `e2e/TESTIDS.md`. Моки (`npm run test:e2e`, `project=mock`) — тол ## 7. Ограничения MVP (Future) -Не в текущей волне commercial (§2.1.2 BackSpec уже в scope UI): -- приглашения / шаринг календаря (`calendar_share`) — фаза 2 +Не в текущей волне (кроме specialist_invite — см. §5.4 / BackSpec §2.1.2): +- шаринг календаря с правами (`calendar_share`) — фаза 2 (не путать со specialist_invite) - серверный logout / revoke session - загрузка файлов (вложения) - явный `client_type=mobile` (фаза 3 бэка) -- push-уведомления (таблица `notification` без полноценной доставки) +- полноценный push (сейчас in-app `notification` + email для specialist_invite) - waitlist / оплата услуги клиентом (B2C) Лайки/дизлайки отзывов: `PUT/DELETE /v1/reviews/:id/vote`, поле `my_vote` в ответах отзывов (EventHubBack#47).