docs(geo): OSM/Photon canon; stage without Photon. Refs EventHub/EventHubSpec#21
This commit is contained in:
+16
-1
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 сверены с кодом.
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user