Files
EventHubSpec/GEO.md
T

14 KiB
Raw Blame History

Гео и карты (Фаза 3, волна 2)

Канон геокодирования, хранения координат и UX карты. Трекер: Spec#21, эпик Spec#19.

Реализация: Back#77, Front#74, DevOps#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 Leaflet + растр tile.openstreetmap.org (политика тайлов OSMF)
Тайлы OpenFreeMap публичный инстанс, без ключа 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. На карте видимая атрибуция: «© 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). Пустой / слишком короткий q400 {error: "invalid_query"}.

Ответ 200:

{
  "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-режим, клиент не передал sortdistance asc. Явный sort=title|created_at|start_time — как сейчас, но только внутри уже отфильтрованных по радиусу.

Новое значение: sort=distance (км по возрастанию; order=desc — дальние первыми, редко нужно).

Ответ

В каждом элементе events[] / calendars[] при geo-запросе:

"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).

Критерии приёмки канона

  • Этот документ согласован (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 сверены с кодом.