Files
EventHubSpec/GEO.md
T

226 lines
14 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Гео и карты (Фаза 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
font-src 'self' data: https://fonts.gstatic.com 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 сверены с кодом.