From 4a96834d8e9a16285e5e2a613ab9d80a55ba9d81 Mon Sep 17 00:00:00 2001 From: Aleksey Sabilin Date: Mon, 17 Aug 2026 23:23:25 +0300 Subject: [PATCH] docs(geo): OSM/Photon canon; stage without Photon. Refs EventHub/EventHubSpec#21 --- EventHubBackSpec.md | 17 +++- EventHubFrontSpec.md | 10 ++ GEO.md | 224 +++++++++++++++++++++++++++++++++++++++++++ README.md | 1 + 4 files changed, 251 insertions(+), 1 deletion(-) create mode 100644 GEO.md diff --git a/EventHubBackSpec.md b/EventHubBackSpec.md index 0f4f23d..b255560 100644 --- a/EventHubBackSpec.md +++ b/EventHubBackSpec.md @@ -270,6 +270,10 @@ Legacy: прямой `POST /v1/calendars/:id/specialists` **не использ - История календаря: hot = текущий месяц + будущее; warm = 3 закрытых месяца; cold = gzip-файлы. Контракт: [`ARCHIVE.md`](ARCHIVE.md). Код: Back#76. +#### Фаза 3 / гео (Spec#19, канон Spec#21) +- Стек: свой Photon + 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`, @@ -401,11 +405,17 @@ API: - Полнотекстовый поиск по названиям событий, календарей, тегам (при наличии `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` запись с адресом, широтой, долготой). +- Локация события (`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`). @@ -613,6 +623,11 @@ src/ (в т.ч. свои). С 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 адреса (Photon); **без токена** + IP rate-limit. +- `POST /v1/geo/geocode` — `{q, lang?}` → `{address, lat, lon, source}` (Bearer). +- `POST /v1/geo/reverse` — `{lat, lon, lang?}` (Bearer). Photon down → `503` `geo_unavailable`. - `GET /v1/calendars` — список календарей (auth): owned ∪ shared (`role`, `share_rights`, `mirror_to_default`). - `POST /v1/calendars` — создать календарь (`commercial` → нужна уже active sub/trial; diff --git a/EventHubFrontSpec.md b/EventHubFrontSpec.md index 2d85f64..4af5d41 100644 --- a/EventHubFrontSpec.md +++ b/EventHubFrontSpec.md @@ -220,6 +220,10 @@ Redirects: `/discover` → `/search`; `/calendars/:id` → `/c/:id`; `/calendars cover (`.eh-discover-media--photo`); иначе mood wash + initials (`.eh-discover-media--fallback`, не fake photo-hero). Битый `image_url` → fallback. Cover upload владельца: `POST /v1/calendars/:id/cover` (Front#69); URL также можно задать через `PUT` `image_url`. +**Гео (волна 2, [GEO.md](GEO.md), Front#74):** основной путь — «рядом со мной» (GPS) или +центр по адресу (suggest), не ручные lat/lon. Радиус опционален (пресеты 1/5/10/25/50 км +и «без ограничения»). В выдаче — `distance_km`. Список, не map-first. + ### 5.4. Календари - Список своих: `GET /v1/calendars` - CRUD: `POST/PUT/DELETE /v1/calendars`, `GET /v1/calendars/:id` (`following`, `booking_open`) @@ -406,6 +410,12 @@ legal stubs; позиция по подписке **владельца**; DNS/SP Канон: [`ARCHIVE.md`](ARCHIVE.md). Hot = текущий месяц + будущее; warm = 3 мес; cold = файлы. UI истории — React, не iframe. Код: Back#76, Front#73. +## 7.2. Фаза 3 — гео (Spec#19 / Spec#21) + +Канон: [`GEO.md`](GEO.md). Стек: MapLibre + OpenFreeMap + свой Photon. +Пикер адреса/пина; карточка с мини-картой и outbound Google/Яндекс/OSM; +Discover ближайшие с опциональным радиусом. Код: Back#77, Front#74, DevOps#16. + ## 8. Источники истины - Маршруты и поведение: `EventHubBack/src/eventhub_app.erl` + handlers diff --git a/GEO.md b/GEO.md new file mode 100644 index 0000000..3543743 --- /dev/null +++ b/GEO.md @@ -0,0 +1,224 @@ +# Гео и карты (Фаза 3, волна 2) + +Канон геокодирования, хранения координат и UX карты. Трекер: [Spec#21](https://git.sabilin.com/EventHub/EventHubSpec/issues/21), эпик [Spec#19](https://git.sabilin.com/EventHub/EventHubSpec/issues/19). + +Реализация: [Back#77](https://git.sabilin.com/EventHub/EventHubBack/issues/77), [Front#74](https://git.sabilin.com/EventHub/EventHubFront/issues/74), [DevOps#16](https://git.sabilin.com/EventHub/EventHubDevOps/issues/16). Этот документ — контракт; код подтягивается после него. + +## Цель + +Владелец задаёт место события (и org-default календаря) через поиск адреса и пин на карте. Координаты **хранятся у нас**. Участник ищет **ближайшие** commercial-календари и события с опциональным радиусом. Гость видит мини-карту и может открыть ту же точку в привычном приложении карт. + +Критерий выигрыша: не зависеть от ToS Google/Яндекс на кэш геокода; не блокировать create события, если геокодер лежит. + +## Почему не Google / Яндекс как провайдеры + +Событие — общая сущность: владелец пишет `lat`/`lon`, участники и Discover читают те же числа. + +- **Google Geocoding:** кэш lat/lng до 30 дней, затем удалить; бессрочно — `place_id`. Исключение «кэш изолирован на одного end-user» **не покрывает** общую точку события. Результат Google нельзя показывать на чужой карте. +- **Яндекс (бесплатный API):** данные геокодера нельзя сохранять в свою БД. Коммерческая лицензия — отдельный договор и обязательный логотип / «Открыть в Картах». + +**Не используем:** Google Maps JavaScript API, Geocoding/Places, Яндекс JS API / Геокодер, ключи карт. + +**Разрешено:** outbound-ссылки на Google Maps и Яндекс.Карты с **нашими** координатами (Photon или пин пользователя). Это не вызов их API и не хранение их ответа. + +## Стек (вариант Ц) + +| Слой | Выбор | Не берём | +|------|--------|----------| +| Карта в браузере | [MapLibre GL JS](https://maplibre.org/) | Leaflet + растр `tile.openstreetmap.org` (политика тайлов OSMF) | +| Тайлы | [OpenFreeMap](https://openfreemap.org/) публичный инстанс, без ключа | Carto/MapTiler Free (не для коммерции EventHub); свой tile-server | +| Геокод | **свой Photon** (Komoot), прокси только через EventHubBack | публичный Nominatim OSMF (1 rps, autocomplete запрещён); `photon.komoot.io` | +| Хранение | `address` + `lat`/`lon` в EventHub | `place_id` Google, сырой ответ Яндекса | + +Данные OSM — [ODbL](https://www.openstreetmap.org/copyright). На карте видимая атрибуция: «© OpenStreetMap contributors» со ссылкой на copyright. Не выгружаем базу OSM оптом через Photon. + +``` +Front (MapLibre) --tiles--> OpenFreeMap +Front --suggest/geocode/reverse--> Back /v1/geo --> Photon (self-host) +Back --persist--> event.location / calendar.settings.default_location +``` + +Front **не** ходит в Photon и Nominatim напрямую. + +## Модель данных (уже есть) + +- Событие: `location` = `{ address, lat?, lon? }`. +- Календарь: `settings.default_location` = `{ address, lat?, lon? }` (как сейчас: `address` непустой; `lat`/`lon` оба или ни одного). + +Волна 2: + +- **Address-only разрешён.** Create/update события с адресом без координат **не** отбрасывает `location` (сейчас `parse_location` без пары lat/lon даёт `undefined`). +- `lat`/`lon` — оба или ни одного; не пара `0, 0` как «пусто» в API (пусто = поля отсутствуют / `null`). +- Поиск «рядом»: см. § «Ближайшие календари и события». Сейчас geo режет только события; календари по `default_location` — волна 2 (Back#77). + +## API геокодирования (контракт Back#77) + +`POST` geocode/reverse: user Bearer (пикер). `GET /v1/geo/suggest`: **без токена** (Discover «центр по адресу» для гостя) + rate-limit по IP; с Bearer — тот же лимит на user. Photon с браузера не светим. + +Кэш одинаковых `q`+`lang` (ETS/Mnesia, TTL дни). Таймаут апстрима ~3 s. + +### `GET /v1/geo/suggest?q=&lang=` + +Typeahead. Клиент debounce (≥300 ms). Пустой / слишком короткий `q` → `400` `{error: "invalid_query"}`. + +Ответ `200`: + +```json +{ + "results": [ + { "address": "Москва, Красная площадь", "lat": 55.7539, "lon": 37.6208 } + ] +} +``` + +Пустой список — валидный `200`, не ошибка. + +### `POST /v1/geo/geocode` + +Тело: `{ "q": "…", "lang": "ru" }` (`lang` опционален, иначе язык пользователя / `ru`). + +- `200`: `{ "address": "…", "lat": 55.75, "lon": 37.62, "source": "photon" }` +- нет хита: `404` `{error: "not_found"}` +- Photon недоступен / таймаут: `503` `{error: "geo_unavailable"}` — **не** 500 + +### `POST /v1/geo/reverse` + +Тело: `{ "lat": 55.75, "lon": 37.62, "lang": "ru" }`. Подпись пина после drag. + +Тот же shape, что geocode (`source: "photon"`). Нет хита → `200` с `address` из округлённых координат или короткий fallback «lat, lon», без падения пикера. `503` только если Photon недоступен. + +### Degrade + +- Geo-эндпоинты: `503` / пустой suggest, не 500 на весь API. +- Create/update события и `default_location`: **не** зависят от Photon. Текстовый адрес сохраняется. +- IFT/stage без Photon: сервис живой; e2e пикера пропускают или проверяют degrade (текстовое поле). + +## Ops (контракт DevOps#16) + +Не API-ключи Google/Яндекс. + +- Compose-сервис **Photon** (JAR + OpenSearch) + volume индекса. Не публиковать Photon в интернет; только сеть compose → EventHubBack. +- Env бэкенда: `PHOTON_URL` (например `http://photon:2322`), `PHOTON_TIMEOUT_MS`. Нет map API keys. +- Выгрузка OSM: **минимум РФ** (+ СНГ/крупные города, если диск позволяет). Planet — если влезает. Обновление индекса — периодический job, не realtime. +- CSP (Traefik / Front): тайлы OpenFreeMap, не Google/Yandex script. + +Ориентир CSP (уточнить по фактическому style URL): + +``` +connect-src 'self' https://tiles.openfreemap.org +img-src 'self' data: blob: https://tiles.openfreemap.org +worker-src 'self' blob: +``` + +Geo — same-origin `/v1/geo`. IFT/stage: Photon в compose; без контейнера — degrade, не падение EventHub. + +**Stage:** свой Photon не запускаем (`PHOTON_URL` пустой). Не используем `photon.komoot.io` как апстрим. Пикер адреса — на IFT при включённом Photon; на stage — текстовый адрес / пин. GPS «рядом» работает без геокодера. + +## Front (контракт Front#74) + +Библиотека: MapLibre GL JS. Стиль OpenFreeMap (например Liberty). Ключей в репозитории Front нет. + +### Пикер + +Форма события и org `default_location` (не сырые lat/lon как основной путь): + +1. Поле адреса + suggest (`GET /v1/geo/suggest`). +2. Выбор хита → подставить address/lat/lon. +3. Мини-карта, перетаскиваемый пин → `POST /v1/geo/reverse`. +4. Ручной lat/lon можно спрятать (advanced), не убирать из контракта API. + +Photon `503`: тост/подсказка, карта и ручной адрес остаются. + +### Карточка / просмотр + +Если есть валидные `lat`/`lon`: + +- мини-карта MapLibre с пином и атрибуцией OSM; +- **рядом** ссылки (`target=_blank`, `rel="noopener noreferrer"`): + - Google Maps: `https://www.google.com/maps?q={lat},{lon}` + - Яндекс.Карты: `https://yandex.ru/maps/?pt={lon},{lat}&z=16` (порядок **lon,lat**) + - OpenStreetMap: `https://www.openstreetmap.org/?mlat={lat}&mlon={lon}#map=16/{lat}/{lon}` + +Только адрес без координат: текст адреса, без карты и без deep-link. + +i18n ru/en. + +### Discover — ближайшие + +Не map-first: тот же список `/search`. Ручной ввод lat/lon **не** основной путь. + +**Центр поиска** (нужны оба `lat` и `lon`): + +1. «Рядом со мной» — `navigator.geolocation` (гость и логин). Отказ/ошибка — без падения; подсказка выбрать адрес. +2. «Искать у адреса» — suggest (`GET /v1/geo/suggest`) → lat/lon центра. Не GPS. + +**Радиус опционален** (км, UI-пресеты **1 / 5 / 10 / 25 / 50**): + +| Радиус | Query | Смысл | +|--------|--------|--------| +| не выбран / «без ограничения» | `lat`,`lon` без `radius` | все точки с координатами, **сортировка по dist** | +| выбран пресет | `lat`,`lon`,`radius` | только ≤ N км, сортировка по dist | + +Дефолт после «рядом со мной»: **10 км** (как сейчас на бэке), пользователь может сменить или снять лимит. `radius` без пары lat/lon игнорируется. + +Фильтр `type` (`calendar` / `event` / оба) работает как сейчас. `q`/даты/теги сочетаются с geo. + +В карточке результата — дистанция (`distance_km` с бэка), не сырые координаты. + +## Ближайшие календари и события (контракт Back#77) + +Тот же `GET /v1/search`. Geo-режим: есть `lat` и `lon` → **не** discovery-tops. + +### Что сравниваем + +| Тип | Точка | Нет координат | +|-----|--------|----------------| +| `event` | `event.location.lat/lon` | вне geo-выборки | +| `calendar` | `settings.default_location.lat/lon` | вне geo-выборки (сейчас календари geo **не** фильтруются — это дыра волны 2) | + +Personal по-прежнему не в search. ACL `can_access` без изменений. Restricted commercial — как в обычном search. + +Формула: haversine, км (уже есть `logic_search:distance/4`). + +### `radius` + +- Задан: integer **1..100** км; вне диапазона → `400`. Фильтр `distance ≤ radius`. +- Не задан: **без отсечения** по dist; только сущности с валидными координатами. +- Default бэка `radius=10`, если клиент прислал lat/lon **без** ключа radius — **менять:** отсутствие ключа = без лимита. Front при «рядом» **явно** шлёт `radius=10` (или выбранный пресет). + +### Сортировка + +Geo-режим, клиент не передал `sort` → **`distance` asc**. Явный `sort=title|created_at|start_time` — как сейчас, но только внутри уже отфильтрованных по радиусу. + +Новое значение: `sort=distance` (км по возрастанию; `order=desc` — дальние первыми, редко нужно). + +### Ответ + +В каждом элементе `events[]` / `calendars[]` при geo-запросе: + +```json +"distance_km": 1.4 +``` + +Округление 1 знак. Без geo-параметров поле **нет** (не `null`). + +Календари в geo-выдаче могут отдавать краткий `default_location` (`address`, `lat`, `lon`) — чтобы Front не гадал. + +Индекс/PostGIS — **не** волна 2: тот же in-memory scan, что сейчас. + +## Non-goals волны 2 + +- Turn-by-turn, пробки, трекинг курьеров/сотрудников. +- Map-first Discover / каталог пинами на всю выдачу. +- Свой tile-server / self-host OpenFreeMap. +- Публичный Nominatim или `photon.komoot.io` как fallback (чтобы не сесть на чужой fair-use). +- Google/Yandex JS SDK, ключи, Places Autocomplete. +- Пакетная геокодировка существующих событий без координат (можно later). + +## Критерии приёмки канона + +- [x] Этот документ согласован (Spec#21, вариант Ц). +- [ ] DevOps#16: Photon в compose, `PHOTON_URL`, CSP OpenFreeMap; без Google/Yandex keys. +- [ ] Back#77: `/v1/geo/*`, кэш, address-only location; search: geo по календарям, optional radius, `sort=distance`, `distance_km`; тесты (mock Photon). +- [ ] Front#74: пикер + мини-карта + outbound Google/Яндекс/OSM; Discover ближайшие (GPS/адрес, пресеты радиуса); e2e/mock. +- [ ] После тестов: BackSpec/FrontSpec сверены с кодом. diff --git a/README.md b/README.md index c1d921e..89bba86 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,7 @@ ## 6. [UX backlog (Client UI)](UX-BACKLOG.md) ## 7. [Карта архитектуры для Zed/агентов](ZED-ARCHITECTURE.md) ## 8. [Архив календаря (Фаза 3)](ARCHIVE.md) +## 9. [Гео и карты (Фаза 3)](GEO.md) # **Репозитории разработки EventHub** ## 1. [EventHub Backend](https://git.sabilin.com/EventHub/EventHubBack)