diff --git a/docker/.env.example b/docker/.env.example
index 55d4d0c..823f374 100644
--- a/docker/.env.example
+++ b/docker/.env.example
@@ -43,4 +43,8 @@ REMINDER_LEAD_HOURS=24
# Uploads (avatar/cover) — каталог на volume /app/data (Back#71)
UPLOAD_DIR=/app/data/uploads
UPLOAD_MAX_BYTES=2097152
+# Photon (DevOps#16). Пусто = /v1/geo → 503. Включить: PHOTON_REPLICAS=1 и PHOTON_URL=http://photon:2322
+PHOTON_URL=
+PHOTON_TIMEOUT_MS=3000
+PHOTON_REPLICAS=0
diff --git a/docker/docker-compose.swarm.yml b/docker/docker-compose.swarm.yml
index 836a02f..ba2b406 100644
--- a/docker/docker-compose.swarm.yml
+++ b/docker/docker-compose.swarm.yml
@@ -70,6 +70,8 @@ services:
- ADMIN_SUPPORT_PASSWORD=${ADMIN_SUPPORT_PASSWORD}
- CLUSTER_MODE=true
- DNS_NAME=eventhub-node
+ - PHOTON_URL=${PHOTON_URL:-}
+ - PHOTON_TIMEOUT_MS=${PHOTON_TIMEOUT_MS:-3000}
networks:
eventhub-net:
aliases:
@@ -233,6 +235,29 @@ services:
max_attempts: 3
window: 120s
+ # OSM geocoder (komoot Photon). Internal only — do not publish :2322.
+ # Default replicas 0. Enable: PHOTON_REPLICAS=1 PHOTON_URL=http://photon:2322
+ photon:
+ image: ${PHOTON_IMAGE:-rtuszik/photon-docker:latest}
+ environment:
+ - REGION=${PHOTON_REGION:-russia}
+ - UPDATE_STRATEGY=${PHOTON_UPDATE_STRATEGY:-PARALLEL}
+ volumes:
+ - photon-data:/photon/data
+ networks:
+ eventhub-net:
+ aliases:
+ - photon
+ deploy:
+ replicas: ${PHOTON_REPLICAS:-0}
+ resources:
+ limits:
+ memory: ${PHOTON_MEMORY_LIMIT:-1536M}
+ reservations:
+ memory: 128M
+ restart_policy:
+ condition: any
+
networks:
eventhub-net:
driver: overlay
@@ -243,4 +268,5 @@ volumes:
prometheus-data:
grafana-data:
traefik-logs:
- loglynx-data:
\ No newline at end of file
+ loglynx-data:
+ photon-data:
\ No newline at end of file
diff --git a/src/eventhub_app.erl b/src/eventhub_app.erl
index 0f8df81..6aea888 100755
--- a/src/eventhub_app.erl
+++ b/src/eventhub_app.erl
@@ -108,6 +108,9 @@ start_http() ->
{"/v1/user/shares/:calendar_id", handler_calendar_shares, []},
{"/v1/users/lookup", handler_users_lookup, []},
{"/v1/search", handler_search, []},
+ {"/v1/geo/suggest", handler_geo, []},
+ {"/v1/geo/geocode", handler_geo, []},
+ {"/v1/geo/reverse", handler_geo, []},
{"/v1/calendars", handler_calendars, []},
{"/v1/calendars/:id", handler_calendar_by_id, []},
{"/v1/calendars/:id/cover", handler_calendar_cover, []},
diff --git a/src/handlers/admin/admin_handler_event_by_id.erl b/src/handlers/admin/admin_handler_event_by_id.erl
index 65a06f0..a8be0db 100644
--- a/src/handlers/admin/admin_handler_event_by_id.erl
+++ b/src/handlers/admin/admin_handler_event_by_id.erl
@@ -206,14 +206,7 @@ convert_field({<<"start_time">>, Val}) ->
convert_field({<<"duration">>, Val}) -> {duration, Val};
convert_field({<<"recurrence">>, Val}) -> {recurrence_rule, jsx:encode(Val)};
convert_field({<<"specialist_id">>, Val}) -> {specialist_id, Val};
-convert_field({<<"location">>, Val}) when is_map(Val) ->
- Loc = #location{
- address = maps:get(<<"address">>, Val, undefined),
- lat = maps:get(<<"lat">>, Val, undefined),
- lon = maps:get(<<"lon">>, Val, undefined)
- },
- {location, Loc};
-convert_field({<<"location">>, Val}) -> {location, Val};
+convert_field({<<"location">>, Val}) -> {location, handler_utils:parse_location(Val)};
convert_field({<<"tags">>, Val}) -> {tags, Val};
convert_field({<<"capacity">>, Val}) -> {capacity, Val};
convert_field({<<"online_link">>, Val}) -> {online_link, Val};
diff --git a/src/handlers/handler_event_by_id.erl b/src/handlers/handler_event_by_id.erl
index 0cee659..8e38f32 100644
--- a/src/handlers/handler_event_by_id.erl
+++ b/src/handlers/handler_event_by_id.erl
@@ -249,14 +249,7 @@ convert_field({<<"recurrence">>, Val}) ->
RuleJson = jsx:encode(Val),
{recurrence_rule, RuleJson};
convert_field({<<"specialist_id">>, Val}) -> {specialist_id, Val};
-convert_field({<<"location">>, Val}) when is_map(Val) ->
- Loc = #location{
- address = maps:get(<<"address">>, Val, undefined),
- lat = maps:get(<<"lat">>, Val, undefined),
- lon = maps:get(<<"lon">>, Val, undefined)
- },
- {location, Loc};
-convert_field({<<"location">>, Val}) -> {location, Val};
+convert_field({<<"location">>, Val}) -> {location, handler_utils:parse_location(Val)};
convert_field({<<"tags">>, Val}) -> {tags, Val};
convert_field({<<"capacity">>, Val}) -> {capacity, Val};
convert_field({<<"online_link">>, Val}) -> {online_link, Val};
diff --git a/src/handlers/handler_events.erl b/src/handlers/handler_events.erl
index 123cf38..8a9e767 100644
--- a/src/handlers/handler_events.erl
+++ b/src/handlers/handler_events.erl
@@ -302,14 +302,7 @@ update_event_fields(UserId, EventId, Location, Decoded) ->
end.
parse_location(undefined) -> undefined;
-parse_location(LocationMap) when is_map(LocationMap) ->
- case LocationMap of
- #{<<"lat">> := Lat, <<"lon">> := Lon} ->
- Address = maps:get(<<"address">>, LocationMap, <<"">>),
- #location{address = Address, lat = Lat, lon = Lon};
- _ -> undefined
- end;
-parse_location(_) -> undefined.
+parse_location(LocationMap) -> handler_utils:parse_location(LocationMap).
expand_recurring_events(UserId, Events, From, To) ->
lists:flatmap(fun(Event) ->
diff --git a/src/handlers/handler_geo.erl b/src/handlers/handler_geo.erl
new file mode 100644
index 0000000..eab4839
--- /dev/null
+++ b/src/handlers/handler_geo.erl
@@ -0,0 +1,162 @@
+%%%-------------------------------------------------------------------
+%%% @doc GET /v1/geo/suggest, POST /v1/geo/geocode, POST /v1/geo/reverse.
+%%% @end
+%%%-------------------------------------------------------------------
+-module(handler_geo).
+-behaviour(cowboy_handler).
+
+-export([init/2, trails/0]).
+
+init(Req, Opts) ->
+ handle(Req, Opts).
+
+trails() ->
+ [
+ #{
+ path => <<"/v1/geo/suggest">>,
+ method => <<"GET">>,
+ description => <<"Address typeahead (Photon). Public + IP rate-limit.">>,
+ tags => [<<"Geo">>],
+ parameters => [
+ #{name => <<"q">>, in => <<"query">>, required => true, schema => #{type => string}},
+ #{name => <<"lang">>, in => <<"query">>, schema => #{type => string}}
+ ],
+ responses => #{
+ 200 => #{description => <<"OK">>},
+ 400 => #{description => <<"invalid_query">>},
+ 429 => #{description => <<"rate limited">>},
+ 503 => #{description => <<"geo_unavailable">>}
+ }
+ },
+ #{
+ path => <<"/v1/geo/geocode">>,
+ method => <<"POST">>,
+ description => <<"Forward geocode (Photon). Bearer required.">>,
+ tags => [<<"Geo">>],
+ responses => #{
+ 200 => #{description => <<"OK">>},
+ 401 => #{description => <<"Unauthorized">>},
+ 404 => #{description => <<"not_found">>},
+ 503 => #{description => <<"geo_unavailable">>}
+ }
+ },
+ #{
+ path => <<"/v1/geo/reverse">>,
+ method => <<"POST">>,
+ description => <<"Reverse geocode (Photon). Bearer required.">>,
+ tags => [<<"Geo">>],
+ responses => #{
+ 200 => #{description => <<"OK">>},
+ 401 => #{description => <<"Unauthorized">>},
+ 503 => #{description => <<"geo_unavailable">>}
+ }
+ }
+ ].
+
+handle(Req, _Opts) ->
+ Path = cowboy_req:path(Req),
+ Method = cowboy_req:method(Req),
+ case {Path, Method} of
+ {<<"/v1/geo/suggest">>, <<"GET">>} -> suggest(Req);
+ {<<"/v1/geo/geocode">>, <<"POST">>} -> geocode(Req);
+ {<<"/v1/geo/reverse">>, <<"POST">>} -> reverse(Req);
+ {_, <<"GET">>} when Path =:= <<"/v1/geo/geocode">> orelse Path =:= <<"/v1/geo/reverse">> ->
+ handler_utils:send_error(Req, 405, <<"Method not allowed">>);
+ {<<"/v1/geo/suggest">>, _} ->
+ handler_utils:send_error(Req, 405, <<"Method not allowed">>);
+ _ ->
+ handler_utils:send_error(Req, 404, <<"Not found">>)
+ end.
+
+suggest(Req) ->
+ Qs = cowboy_req:parse_qs(Req),
+ Q = proplists:get_value(<<"q">>, Qs, <<>>),
+ Lang = proplists:get_value(<<"lang">>, Qs, <<>>),
+ Key = <<"s:", (client_key(Req))/binary>>,
+ reply_suggest(Req, logic_geo:suggest(Q, Lang, Key)).
+
+geocode(Req) ->
+ case handler_utils:auth_user(Req) of
+ {ok, UserId, Req1} ->
+ {ok, Body, Req2} = cowboy_req:read_body(Req1),
+ case decode_map(Body) of
+ {ok, Map} ->
+ Q = maps:get(<<"q">>, Map, <<>>),
+ Lang = maps:get(<<"lang">>, Map, <<>>),
+ Key = <<"g:", UserId/binary>>,
+ reply_hit(Req2, logic_geo:geocode(Q, Lang, Key), true);
+ error ->
+ handler_utils:send_error(Req2, 400, <<"Invalid JSON">>)
+ end;
+ {error, Code, Message, Req1} ->
+ handler_utils:send_error(Req1, Code, Message)
+ end.
+
+reverse(Req) ->
+ case handler_utils:auth_user(Req) of
+ {ok, UserId, Req1} ->
+ {ok, Body, Req2} = cowboy_req:read_body(Req1),
+ case decode_map(Body) of
+ {ok, Map} ->
+ Lat = maps:get(<<"lat">>, Map, undefined),
+ Lon = maps:get(<<"lon">>, Map, undefined),
+ Lang = maps:get(<<"lang">>, Map, <<>>),
+ Key = <<"r:", UserId/binary>>,
+ reply_hit(Req2, logic_geo:reverse(Lat, Lon, Lang, Key), false);
+ error ->
+ handler_utils:send_error(Req2, 400, <<"Invalid JSON">>)
+ end;
+ {error, Code, Message, Req1} ->
+ handler_utils:send_error(Req1, Code, Message)
+ end.
+
+reply_suggest(Req, {ok, Hits}) ->
+ handler_utils:send_json(Req, 200, #{<<"results">> => Hits});
+reply_suggest(Req, {error, invalid_query}) ->
+ handler_utils:send_error(Req, 400, <<"invalid_query">>);
+reply_suggest(Req, {error, rate_limited}) ->
+ handler_utils:send_error(Req, 429, <<"rate_limited">>);
+reply_suggest(Req, {error, unavailable}) ->
+ handler_utils:send_error(Req, 503, <<"geo_unavailable">>).
+
+reply_hit(Req, {ok, Hit}, _NotFound404) ->
+ handler_utils:send_json(Req, 200, Hit);
+reply_hit(Req, {error, not_found}, true) ->
+ handler_utils:send_error(Req, 404, <<"not_found">>);
+reply_hit(Req, {error, invalid_query}, _) ->
+ handler_utils:send_error(Req, 400, <<"invalid_query">>);
+reply_hit(Req, {error, rate_limited}, _) ->
+ handler_utils:send_error(Req, 429, <<"rate_limited">>);
+reply_hit(Req, {error, unavailable}, _) ->
+ handler_utils:send_error(Req, 503, <<"geo_unavailable">>).
+
+decode_map(Body) when is_binary(Body) ->
+ try jsx:decode(Body, [return_maps]) of
+ Map when is_map(Map) -> {ok, Map};
+ _ -> error
+ catch
+ _:_ -> error
+ end.
+
+client_key(Req) ->
+ case cowboy_req:header(<<"x-real-ip">>, Req) of
+ undefined ->
+ case cowboy_req:header(<<"x-forwarded-for">>, Req) of
+ undefined -> peer_ip(Req);
+ <<>> -> peer_ip(Req);
+ Fwd ->
+ case binary:split(Fwd, <<",">>) of
+ [First | _] -> string:trim(First);
+ _ -> peer_ip(Req)
+ end
+ end;
+ <<>> -> peer_ip(Req);
+ IP -> IP
+ end.
+
+peer_ip(Req) ->
+ case cowboy_req:peer(Req) of
+ {{A, B, C, D}, _} ->
+ iolist_to_binary(io_lib:format("~B.~B.~B.~B", [A, B, C, D]));
+ _ -> <<"unknown">>
+ end.
diff --git a/src/handlers/handler_search.erl b/src/handlers/handler_search.erl
index de612b0..889c002 100644
--- a/src/handlers/handler_search.erl
+++ b/src/handlers/handler_search.erl
@@ -32,7 +32,7 @@ trails() ->
#{name => <<"limit">>, in => <<"query">>, schema => #{type => integer}, description => <<"Maximum results per page">>},
#{name => <<"offset">>, in => <<"query">>, schema => #{type => integer}, description => <<"Offset for pagination">>},
#{name => <<"tags">>, in => <<"query">>, schema => #{type => string}, description => <<"Comma-separated tags">>},
- #{name => <<"sort">>, in => <<"query">>, schema => #{type => string, enum => [<<"start_time">>, <<"created_at">>, <<"title">>]}, description => <<"Field to sort by">>},
+ #{name => <<"sort">>, in => <<"query">>, schema => #{type => string, enum => [<<"start_time">>, <<"created_at">>, <<"title">>, <<"distance">>]}, description => <<"Field to sort by">>},
#{name => <<"order">>, in => <<"query">>, schema => #{type => string, enum => [<<"asc">>, <<"desc">>]}, description => <<"Sort order">>},
#{name => <<"lat">>, in => <<"query">>, schema => #{type => number, format => float}, description => <<"Latitude for geo search">>},
#{name => <<"lon">>, in => <<"query">>, schema => #{type => number, format => float}, description => <<"Longitude for geo search">>},
@@ -79,18 +79,22 @@ search(Req) ->
Qs = cowboy_req:parse_qs(Req1),
Type = proplists:get_value(<<"type">>, Qs, undefined),
Query = proplists:get_value(<<"q">>, Qs, undefined),
- Params = parse_params(Qs),
- case logic_search:search(Type, Query, UserId, Params) of
- {ok, Total, Results} ->
- Response = #{
- total => Total,
- limit => maps:get(limit, Params, 20),
- offset => maps:get(offset, Params, 0),
- results => Results
- },
- handler_utils:send_json(Req1, 200, Response);
- {error, _} ->
- handler_utils:send_error(Req1, 500, <<"Search failed">>)
+ case parse_params(Qs) of
+ {error, invalid_radius} ->
+ handler_utils:send_error(Req1, 400, <<"invalid_radius">>);
+ Params ->
+ case logic_search:search(Type, Query, UserId, Params) of
+ {ok, Total, Results} ->
+ Response = #{
+ total => Total,
+ limit => maps:get(limit, Params, 20),
+ offset => maps:get(offset, Params, 0),
+ results => Results
+ },
+ handler_utils:send_json(Req1, 200, Response);
+ {error, _} ->
+ handler_utils:send_error(Req1, 500, <<"Search failed">>)
+ end
end;
{error, Code, Message, Req1} ->
handler_utils:send_error(Req1, Code, Message)
@@ -103,7 +107,8 @@ search(Req) ->
%% @private Собирает карту параметров для поискового движка.
%% Не кладёт sort/tags/geo/даты, если их нет в QS — иначе
%% logic_search:is_discovery_request/2 никогда не сработает (Back#50).
--spec parse_params(cowboy_req:qs()) -> map().
+%% radius без ключа при lat/lon — без отсечения (GEO.md).
+-spec parse_params(cowboy_req:qs()) -> map() | {error, invalid_radius}.
parse_params(Qs) ->
Params0 = #{
limit => parse_int_param(Qs, <<"limit">>, 20),
@@ -125,12 +130,22 @@ parse_params(Qs) ->
end,
Params1#{sort => Sort, order => Order}
end,
- Params3 = case {parse_float_param(Qs, <<"lat">>), parse_float_param(Qs, <<"lon">>)} of
- {{ok, Lat}, {ok, Lon}} ->
- Radius = parse_int_param(Qs, <<"radius">>, 10),
- Params2#{lat => Lat, lon => Lon, radius => Radius};
- _ -> Params2
- end,
+ case {parse_float_param(Qs, <<"lat">>), parse_float_param(Qs, <<"lon">>)} of
+ {{ok, Lat}, {ok, Lon}} ->
+ case parse_optional_radius(Qs) of
+ {error, invalid_radius} = E -> E;
+ Radius ->
+ Params3 = case Radius of
+ undefined -> Params2#{lat => Lat, lon => Lon};
+ R -> Params2#{lat => Lat, lon => Lon, radius => R}
+ end,
+ with_dates(Qs, Params3)
+ end;
+ _ ->
+ with_dates(Qs, Params2)
+ end.
+
+with_dates(Qs, Params3) ->
case {parse_datetime_param(Qs, <<"from">>), parse_datetime_param(Qs, <<"to">>)} of
{{ok, From}, {ok, To}} -> Params3#{from => From, to => To};
{{ok, From}, error} -> Params3#{from => From};
@@ -138,6 +153,18 @@ parse_params(Qs) ->
_ -> Params3
end.
+parse_optional_radius(Qs) ->
+ case proplists:get_value(<<"radius">>, Qs) of
+ undefined -> undefined;
+ <<>> -> undefined;
+ Val ->
+ N = handler_utils:parse_int_qs(Val, 0),
+ case N >= 1 andalso N =< 100 of
+ true -> N;
+ false -> {error, invalid_radius}
+ end
+ end.
+
-spec parse_int_param(cowboy_req:qs(), binary(), integer()) -> integer().
parse_int_param(Qs, Key, Default) ->
handler_utils:parse_int_qs(proplists:get_value(Key, Qs), Default).
@@ -146,7 +173,18 @@ parse_int_param(Qs, Key, Default) ->
parse_float_param(Qs, Key) ->
case proplists:get_value(Key, Qs) of
undefined -> error;
- Val -> {ok, binary_to_float(Val)}
+ <<>> -> error;
+ Val ->
+ try binary_to_float(Val) of
+ F -> {ok, F}
+ catch
+ _:_ ->
+ try binary_to_integer(Val) of
+ I -> {ok, float(I)}
+ catch
+ _:_ -> error
+ end
+ end
end.
-spec parse_datetime_param(cowboy_req:qs(), binary()) -> {ok, calendar:datetime()} | error.
diff --git a/src/handlers/handler_utils.erl b/src/handlers/handler_utils.erl
index c782b4a..2301a7a 100755
--- a/src/handlers/handler_utils.erl
+++ b/src/handlers/handler_utils.erl
@@ -33,7 +33,10 @@
is_superadmin/1,
pagination_headers/2,
maybe_report_internal_error/3,
- report_and_send_error/4
+ report_and_send_error/4,
+ parse_location/1,
+ location_to_json/1,
+ meaningful_coords/2
]).
-export([admin_to_json/1, audit_to_json/1]).
@@ -317,11 +320,7 @@ audit_reason(_) -> <<>>.
%% (confirmed > pending > free; cancelled/expired и past-pending → не занятость).
-spec event_to_json(#event{}) -> map().
event_to_json(Event) ->
- LocationJson = case Event#event.location of
- undefined -> null;
- #location{address = Addr, lat = Lat, lon = Lon} ->
- #{address => Addr, lat => Lat, lon => Lon}
- end,
+ LocationJson = location_to_json(Event#event.location),
RecurrenceJson = case Event#event.recurrence_rule of
undefined -> null;
Rule -> try jsx:decode(Rule, [return_maps]) of
@@ -647,4 +646,62 @@ trails_for_crud(Path, _Resource, GetSchema, UpdateSchema) ->
200 => #{description => <<"Record deleted">>}
}
}
- ].
\ No newline at end of file
+ ].
+
+%%%===================================================================
+%%% Location
+%%%===================================================================
+
+-spec parse_location(term()) -> #location{} | undefined.
+parse_location(undefined) -> undefined;
+parse_location(null) -> undefined;
+parse_location(LocationMap) when is_map(LocationMap) ->
+ Addr0 = maps:get(<<"address">>, LocationMap, maps:get(address, LocationMap, <<>>)),
+ Addr = case Addr0 of
+ A when is_binary(A) -> A;
+ _ -> <<>>
+ end,
+ Lat = to_coord(maps:get(<<"lat">>, LocationMap,
+ maps:get(lat, LocationMap, undefined))),
+ Lon = to_coord(maps:get(<<"lon">>, LocationMap,
+ maps:get(lon, LocationMap, undefined))),
+ Coords = meaningful_coords(Lat, Lon),
+ HasAddr = Addr =/= <<>> andalso Addr =/= <<"undefined">>,
+ case {HasAddr, Coords, Lat =/= undefined orelse Lon =/= undefined} of
+ {false, false, _} -> undefined;
+ {_, true, _} ->
+ #location{address = Addr, lat = Lat, lon = Lon};
+ {true, false, false} ->
+ #location{address = Addr, lat = undefined, lon = undefined};
+ {true, false, true} ->
+ %% одна координата без пары — храним только адрес
+ #location{address = Addr, lat = undefined, lon = undefined};
+ _ -> undefined
+ end;
+parse_location(_) -> undefined.
+
+-spec location_to_json(undefined | #location{}) -> null | map().
+location_to_json(undefined) -> null;
+location_to_json(#location{address = Addr, lat = Lat, lon = Lon}) ->
+ AddrJ = case Addr of
+ undefined -> null;
+ <<>> -> null;
+ A -> A
+ end,
+ case meaningful_coords(Lat, Lon) of
+ true -> #{address => AddrJ, lat => Lat, lon => Lon};
+ false -> #{address => AddrJ, lat => null, lon => null}
+ end.
+
+-spec meaningful_coords(term(), term()) -> boolean().
+meaningful_coords(Lat, Lon) ->
+ is_number(Lat) andalso is_number(Lon)
+ andalso not (Lat == 0 andalso Lon == 0)
+ andalso Lat >= -90 andalso Lat =< 90
+ andalso Lon >= -180 andalso Lon =< 180.
+
+to_coord(undefined) -> undefined;
+to_coord(null) -> undefined;
+to_coord(N) when is_integer(N) -> float(N);
+to_coord(N) when is_float(N) -> N;
+to_coord(_) -> undefined.
\ No newline at end of file
diff --git a/src/logic/logic_geo.erl b/src/logic/logic_geo.erl
new file mode 100644
index 0000000..72b788e
--- /dev/null
+++ b/src/logic/logic_geo.erl
@@ -0,0 +1,316 @@
+%%%-------------------------------------------------------------------
+%%% @doc Прокси к self-host Photon: suggest / geocode / reverse.
+%%% Кэш и rate-limit в ETS. Без PHOTON_URL — {error, unavailable}.
+%%% @end
+%%%-------------------------------------------------------------------
+-module(logic_geo).
+-export([suggest/3, geocode/3, reverse/4, ensure/0]).
+-export([format_address/1]).
+
+-define(CACHE, eventhub_geo_cache).
+-define(RL, eventhub_geo_rl).
+-define(MIN_Q, 3).
+-define(CACHE_TTL_SEC, 604800).
+-define(RL_WINDOW_SEC, 60).
+-define(RL_SUGGEST, 40).
+-define(RL_WRITE, 20).
+
+-spec ensure() -> ok.
+ensure() ->
+ case ets:info(?CACHE) of
+ undefined ->
+ ets:new(?CACHE, [named_table, public, set, {read_concurrency, true}]);
+ _ -> ok
+ end,
+ case ets:info(?RL) of
+ undefined ->
+ ets:new(?RL, [named_table, public, set, {write_concurrency, true}]);
+ _ -> ok
+ end,
+ ok.
+
+-spec suggest(binary(), binary(), binary()) ->
+ {ok, [map()]} | {error, invalid_query | rate_limited | unavailable}.
+suggest(Q, Lang, RlKey) ->
+ ensure(),
+ case normalize_q(Q) of
+ {error, invalid_query} = E -> E;
+ {ok, Qn} ->
+ case allow(RlKey, ?RL_SUGGEST) of
+ false -> {error, rate_limited};
+ true ->
+ LangN = lang(Lang),
+ CacheKey = {suggest, Qn, LangN},
+ case cache_get(CacheKey) of
+ {ok, Hits} -> {ok, Hits};
+ miss ->
+ case photon_get(<<"/api">>, [{<<"q">>, Qn}, {<<"lang">>, LangN},
+ {<<"limit">>, <<"8">>}]) of
+ {ok, Body} ->
+ Hits = features_to_hits(Body),
+ cache_put(CacheKey, Hits),
+ {ok, Hits};
+ {error, _} -> {error, unavailable}
+ end
+ end
+ end
+ end.
+
+-spec geocode(binary(), binary(), binary()) ->
+ {ok, map()} | {error, invalid_query | not_found | rate_limited | unavailable}.
+geocode(Q, Lang, RlKey) ->
+ ensure(),
+ case normalize_q(Q) of
+ {error, invalid_query} = E -> E;
+ {ok, Qn} ->
+ case allow(RlKey, ?RL_WRITE) of
+ false -> {error, rate_limited};
+ true ->
+ LangN = lang(Lang),
+ CacheKey = {geocode, Qn, LangN},
+ case cache_get(CacheKey) of
+ {ok, Hit} -> {ok, Hit};
+ miss ->
+ case photon_get(<<"/api">>, [{<<"q">>, Qn}, {<<"lang">>, LangN},
+ {<<"limit">>, <<"1">>}]) of
+ {ok, Body} ->
+ case features_to_hits(Body) of
+ [Hit | _] ->
+ Out = Hit#{<<"source">> => <<"photon">>},
+ cache_put(CacheKey, Out),
+ {ok, Out};
+ [] -> {error, not_found}
+ end;
+ {error, _} -> {error, unavailable}
+ end
+ end
+ end
+ end.
+
+-spec reverse(number(), number(), binary(), binary()) ->
+ {ok, map()} | {error, invalid_query | rate_limited | unavailable}.
+reverse(Lat, Lon, Lang, RlKey) when is_number(Lat), is_number(Lon) ->
+ ensure(),
+ case {valid_lat(Lat), valid_lon(Lon)} of
+ {true, true} ->
+ case allow(RlKey, ?RL_WRITE) of
+ false -> {error, rate_limited};
+ true ->
+ LangN = lang(Lang),
+ LatR = round_coord(Lat),
+ LonR = round_coord(Lon),
+ CacheKey = {reverse, LatR, LonR, LangN},
+ case cache_get(CacheKey) of
+ {ok, Hit} -> {ok, Hit};
+ miss ->
+ LatB = float_to_bin(LatR),
+ LonB = float_to_bin(LonR),
+ case photon_get(<<"/reverse">>, [{<<"lat">>, LatB}, {<<"lon">>, LonB},
+ {<<"lang">>, LangN}]) of
+ {ok, Body} ->
+ Hit = case features_to_hits(Body) of
+ [H | _] -> H#{<<"source">> => <<"photon">>};
+ [] ->
+ #{<<"address">> => fallback_addr(LatR, LonR),
+ <<"lat">> => LatR,
+ <<"lon">> => LonR,
+ <<"source">> => <<"photon">>}
+ end,
+ cache_put(CacheKey, Hit),
+ {ok, Hit};
+ {error, _} -> {error, unavailable}
+ end
+ end
+ end;
+ _ -> {error, invalid_query}
+ end;
+reverse(_, _, _, _) ->
+ {error, invalid_query}.
+
+%% ——— internals ———
+
+normalize_q(Q) when is_binary(Q) ->
+ T = string:trim(Q),
+ case string:length(T) >= ?MIN_Q of
+ true -> {ok, T};
+ false -> {error, invalid_query}
+ end;
+normalize_q(_) ->
+ {error, invalid_query}.
+
+lang(<<>>) -> <<"ru">>;
+lang(undefined) -> <<"ru">>;
+lang(L) when is_binary(L), byte_size(L) >= 2 -> binary:part(L, 0, 2);
+lang(_) -> <<"ru">>.
+
+valid_lat(Lat) -> is_number(Lat) andalso Lat >= -90 andalso Lat =< 90.
+valid_lon(Lon) -> is_number(Lon) andalso Lon >= -180 andalso Lon =< 180.
+
+round_coord(N) when is_integer(N) -> float(N);
+round_coord(N) when is_float(N) -> round(N * 100000) / 100000.
+
+float_to_bin(N) when is_integer(N) -> integer_to_binary(N);
+float_to_bin(N) when is_float(N) ->
+ iolist_to_binary(io_lib:format("~.6f", [N])).
+
+fallback_addr(Lat, Lon) ->
+ iolist_to_binary(io_lib:format("~.5f, ~.5f", [float(Lat), float(Lon)])).
+
+allow(Key, Limit) ->
+ Now = erlang:monotonic_time(second),
+ case ets:lookup(?RL, Key) of
+ [{Key, Count, Start}] when Now - Start < ?RL_WINDOW_SEC ->
+ case Count < Limit of
+ true ->
+ ets:update_counter(?RL, Key, {2, 1}),
+ true;
+ false -> false
+ end;
+ _ ->
+ ets:insert(?RL, {Key, 1, Now}),
+ true
+ end.
+
+cache_get(Key) ->
+ Now = erlang:system_time(second),
+ case ets:lookup(?CACHE, Key) of
+ [{Key, Exp, Val}] when Exp > Now -> {ok, Val};
+ [_] -> ets:delete(?CACHE, Key), miss;
+ [] -> miss
+ end.
+
+cache_put(Key, Val) ->
+ Exp = erlang:system_time(second) + ?CACHE_TTL_SEC,
+ ets:insert(?CACHE, {Key, Exp, Val}),
+ ok.
+
+photon_url() ->
+ case os:getenv("PHOTON_URL") of
+ false -> <<>>;
+ "" -> <<>>;
+ Url -> list_to_binary(string:trim(Url))
+ end.
+
+timeout_ms() ->
+ case os:getenv("PHOTON_TIMEOUT_MS") of
+ false -> 3000;
+ "" -> 3000;
+ S ->
+ try list_to_integer(S) of
+ N when N > 0 -> N;
+ _ -> 3000
+ catch
+ _:_ -> 3000
+ end
+ end.
+
+photon_get(Path, Qs) ->
+ case photon_url() of
+ <<>> -> {error, unavailable};
+ Base0 ->
+ Base = strip_slash(Base0),
+ Query = uri_string:compose_query(Qs),
+ Url = binary_to_list(<>),
+ http_get(Url)
+ end.
+
+strip_slash(B) ->
+ case binary:last(B) of
+ $/ -> binary:part(B, 0, byte_size(B) - 1);
+ _ -> B
+ end.
+
+http_get(Url) ->
+ Fun = case application:get_env(eventhub, geo_http_get) of
+ {ok, F} when is_function(F, 1) -> F;
+ _ -> fun default_http_get/1
+ end,
+ Fun(Url).
+
+default_http_get(Url) ->
+ _ = application:ensure_all_started(inets),
+ Headers = [{"user-agent", "EventHub/1.0 (geo; +https://calentiq.com)"}],
+ HttpOpts = [{timeout, timeout_ms()}],
+ case httpc:request(get, {Url, Headers}, HttpOpts, [{body_format, binary}]) of
+ {ok, {{_, Code, _}, _, Body}} when Code >= 200, Code < 300, is_binary(Body) ->
+ {ok, Body};
+ {ok, {{_, Code, _}, _, _}} ->
+ {error, {http_status, Code}};
+ {error, Reason} ->
+ {error, Reason}
+ end.
+
+features_to_hits(Body) when is_binary(Body) ->
+ try jsx:decode(Body, [return_maps]) of
+ Map when is_map(Map) ->
+ Feats = maps:get(<<"features">>, Map, []),
+ lists:filtermap(fun feature_to_hit/1, Feats);
+ _ -> []
+ catch
+ _:_ -> []
+ end.
+
+feature_to_hit(#{<<"geometry">> := #{<<"coordinates">> := [Lon, Lat | _]}} = F) ->
+ Props = maps:get(<<"properties">>, F, #{}),
+ Addr = format_address(Props),
+ {true, #{
+ <<"address">> => Addr,
+ <<"lat">> => to_num(Lat),
+ <<"lon">> => to_num(Lon)
+ }};
+feature_to_hit(_) ->
+ false.
+
+to_num(N) when is_integer(N) -> float(N);
+to_num(N) when is_float(N) -> N;
+to_num(_) -> 0.0.
+
+-spec format_address(map()) -> binary().
+format_address(Props) when is_map(Props) ->
+ Name = bin(maps:get(<<"name">>, Props, <<>>)),
+ House = bin(maps:get(<<"housenumber">>, Props, <<>>)),
+ Street = bin(maps:get(<<"street">>, Props, <<>>)),
+ StreetLine = case {House, Street} of
+ {<<>>, S} -> S;
+ {H, <<>>} -> H;
+ {H, S} -> <>
+ end,
+ City = first_nonempty([
+ maps:get(<<"city">>, Props, <<>>),
+ maps:get(<<"town">>, Props, <<>>),
+ maps:get(<<"village">>, Props, <<>>),
+ maps:get(<<"locality">>, Props, <<>>)
+ ]),
+ Country = bin(maps:get(<<"country">>, Props, <<>>)),
+ Parts0 = [Name, StreetLine, City, Country],
+ Parts = [P || P <- Parts0, P =/= <<>>],
+ Unique = unique_keep(Parts),
+ case Unique of
+ [] -> <<"—">>;
+ _ -> join_comma(Unique)
+ end.
+
+bin(B) when is_binary(B) -> string:trim(B);
+bin(I) when is_integer(I) -> integer_to_binary(I);
+bin(_) -> <<>>.
+
+first_nonempty([]) -> <<>>;
+first_nonempty([H | T]) ->
+ B = bin(H),
+ case B of
+ <<>> -> first_nonempty(T);
+ _ -> B
+ end.
+
+unique_keep(List) ->
+ lists:reverse(lists:foldl(fun(X, Acc) ->
+ case lists:member(X, Acc) of
+ true -> Acc;
+ false -> [X | Acc]
+ end
+ end, [], List)).
+
+join_comma([H]) -> H;
+join_comma([H | T]) ->
+ Rest = join_comma(T),
+ <>.
diff --git a/src/logic/logic_search.erl b/src/logic/logic_search.erl
index 12cc7f1..acb5fd6 100755
--- a/src/logic/logic_search.erl
+++ b/src/logic/logic_search.erl
@@ -98,7 +98,7 @@ discovery_events(UserId, Params, Limit, Offset) ->
search_events(undefined, UserId, Params, Limit, Offset);
Items ->
Total = length(Items),
- {ok, Total, format_events(paginate(Items, Limit, Offset))}
+ {ok, Total, format_events(paginate(Items, Limit, Offset), Params)}
end.
discovery_calendars(UserId, Params, Limit, Offset) ->
@@ -110,7 +110,7 @@ discovery_calendars(UserId, Params, Limit, Offset) ->
search_calendars(undefined, UserId, Params, Limit, Offset);
Items ->
Total = length(Items),
- {ok, Total, format_calendars(paginate(Items, Limit, Offset))}
+ {ok, Total, format_calendars(paginate(Items, Limit, Offset), Params)}
end.
%% ============ Поиск событий ============
@@ -131,7 +131,7 @@ search_events(Query, UserId, Params, Limit, Offset) ->
%% Pagination is applied BEFORE formatting so only the page slice is
%% enriched with calendar data (see format_events).
Paginated = paginate(Sorted, Limit, Offset),
- {ok, length(Filtered), format_events(Paginated)}.
+ {ok, length(Filtered), format_events(Paginated, Params)}.
%% ============ Поиск календарей ============
@@ -145,8 +145,9 @@ search_calendars(Query, UserId, Params, Limit, Offset) ->
AllCalendars = get_all_calendars(),
AccessibleCalendars = filter_accessible_calendars(AllCalendars, UserId),
Filtered = apply_calendar_filters(AccessibleCalendars, Query, Params),
- Paginated = paginate(Filtered, Limit, Offset),
- {ok, length(Filtered), format_calendars(Paginated)}.
+ Sorted = sort_calendars(Filtered, Params),
+ Paginated = paginate(Sorted, Limit, Offset),
+ {ok, length(Filtered), format_calendars(Paginated, Params)}.
%% ============ Получение данных ============
@@ -223,7 +224,8 @@ apply_event_filters(Events, Query, Params) ->
-spec apply_calendar_filters([#calendar{}], binary() | undefined, map()) -> [#calendar{}].
apply_calendar_filters(Calendars, Query, Params) ->
Calendars1 = filter_by_text(Calendars, Query),
- filter_by_tags(Calendars1, Params).
+ Calendars2 = filter_by_tags(Calendars1, Params),
+ filter_calendars_by_location(Calendars2, Params).
%% --- Текстовый поиск ---
-spec filter_by_text([#event{} | #calendar{}], binary() | undefined) -> [#event{} | #calendar{}].
@@ -269,21 +271,63 @@ filter_by_date_range(Events, Params) ->
%% --- Гео-фильтр ---
-spec filter_by_location([#event{}], map()) -> [#event{}].
filter_by_location(Events, Params) ->
- case {maps:get(lat, Params, undefined), maps:get(lon, Params, undefined)} of
- {undefined, _} -> Events;
- {_, undefined} -> Events;
+ case geo_origin(Params) of
+ none -> Events;
{Lat, Lon} ->
- Radius = maps:get(radius, Params, 10),
+ Radius = maps:get(radius, Params, undefined),
lists:filter(fun(Event) ->
- case Event#event.location of
- #location{lat = EventLat, lon = EventLon}
- when is_number(EventLat), is_number(EventLon) ->
- distance(Lat, Lon, EventLat, EventLon) =< Radius;
- _ -> false
- end
- end, Events)
+ within_radius(event_coords(Event), Lat, Lon, Radius)
+ end, Events)
end.
+-spec filter_calendars_by_location([#calendar{}], map()) -> [#calendar{}].
+filter_calendars_by_location(Calendars, Params) ->
+ case geo_origin(Params) of
+ none -> Calendars;
+ {Lat, Lon} ->
+ Radius = maps:get(radius, Params, undefined),
+ lists:filter(fun(Cal) ->
+ within_radius(calendar_coords(Cal), Lat, Lon, Radius)
+ end, Calendars)
+ end.
+
+geo_origin(Params) ->
+ case {maps:get(lat, Params, undefined), maps:get(lon, Params, undefined)} of
+ {Lat, Lon} when is_number(Lat), is_number(Lon) -> {Lat, Lon};
+ _ -> none
+ end.
+
+within_radius(none, _, _, _) -> false;
+within_radius({ok, ELat, ELon}, Lat, Lon, Radius) ->
+ D = distance(Lat, Lon, ELat, ELon),
+ Radius =:= undefined orelse D =< Radius.
+
+event_coords(#event{location = Loc}) -> loc_coords(Loc);
+event_coords(_) -> none.
+
+calendar_coords(#calendar{settings = Settings}) when is_map(Settings) ->
+ Loc = maps:get(<<"default_location">>, Settings,
+ maps:get(default_location, Settings, undefined)),
+ loc_coords(Loc);
+calendar_coords(_) -> none.
+
+loc_coords(#location{lat = Lat, lon = Lon}) ->
+ case handler_utils:meaningful_coords(Lat, Lon) of
+ true -> {ok, to_float(Lat), to_float(Lon)};
+ false -> none
+ end;
+loc_coords(Loc) when is_map(Loc) ->
+ Lat = maps:get(<<"lat">>, Loc, maps:get(lat, Loc, undefined)),
+ Lon = maps:get(<<"lon">>, Loc, maps:get(lon, Loc, undefined)),
+ case handler_utils:meaningful_coords(Lat, Lon) of
+ true -> {ok, to_float(Lat), to_float(Lon)};
+ false -> none
+ end;
+loc_coords(_) -> none.
+
+to_float(N) when is_integer(N) -> float(N);
+to_float(N) when is_float(N) -> N.
+
%% ============ Вспомогательные функции ============
-spec get_title(#event{} | #calendar{}) -> binary().
@@ -322,19 +366,66 @@ deg_to_rad(Deg) -> Deg * math:pi() / 180.
-spec sort_events([#event{}], map()) -> [#event{}].
sort_events(Events, Params) ->
- SortBy = maps:get(sort, Params, <<"start_time">>),
+ Default = case geo_origin(Params) of
+ none -> <<"start_time">>;
+ _ -> <<"distance">>
+ end,
+ SortBy = maps:get(sort, Params, Default),
Order = maps:get(order, Params, <<"asc">>),
Sorted = case SortBy of
+ <<"distance">> -> sort_by_distance_events(Events, Params);
<<"start_time">> -> lists:sort(fun(A, B) -> A#event.start_time =< B#event.start_time end, Events);
<<"rating">> -> lists:sort(fun(A, B) -> A#event.rating_avg >= B#event.rating_avg end, Events);
<<"created_at">> -> lists:sort(fun(A, B) -> A#event.created_at =< B#event.created_at end, Events);
+ <<"title">> -> lists:sort(fun(A, B) -> A#event.title =< B#event.title end, Events);
_ -> Events
end,
+ case {SortBy, Order} of
+ {<<"distance">>, <<"desc">>} -> lists:reverse(Sorted);
+ {<<"rating">>, _} -> Sorted;
+ {_, <<"desc">>} -> lists:reverse(Sorted);
+ _ -> Sorted
+ end.
+
+sort_calendars(Calendars, Params) ->
+ Default = case geo_origin(Params) of
+ none -> <<"title">>;
+ _ -> <<"distance">>
+ end,
+ SortBy = maps:get(sort, Params, Default),
+ Order = maps:get(order, Params, <<"asc">>),
+ Sorted = case SortBy of
+ <<"distance">> -> sort_by_distance_calendars(Calendars, Params);
+ <<"created_at">> -> lists:sort(fun(A, B) -> A#calendar.created_at =< B#calendar.created_at end, Calendars);
+ <<"title">> -> lists:sort(fun(A, B) -> A#calendar.title =< B#calendar.title end, Calendars);
+ _ -> Calendars
+ end,
case Order of
<<"desc">> -> lists:reverse(Sorted);
_ -> Sorted
end.
+sort_by_distance_events(Events, Params) ->
+ case geo_origin(Params) of
+ none -> Events;
+ {Lat, Lon} ->
+ lists:sort(fun(A, B) ->
+ dist_or_inf(event_coords(A), Lat, Lon) =< dist_or_inf(event_coords(B), Lat, Lon)
+ end, Events)
+ end.
+
+sort_by_distance_calendars(Cals, Params) ->
+ case geo_origin(Params) of
+ none -> Cals;
+ {Lat, Lon} ->
+ lists:sort(fun(A, B) ->
+ dist_or_inf(calendar_coords(A), Lat, Lon) =< dist_or_inf(calendar_coords(B), Lat, Lon)
+ end, Cals)
+ end.
+
+dist_or_inf({ok, ELat, ELon}, Lat, Lon) -> distance(Lat, Lon, ELat, ELon);
+dist_or_inf(none, _, _) -> 1.0e12.
+
%% ============ Пагинация ============
-spec paginate([term()], non_neg_integer(), non_neg_integer()) -> [term()].
@@ -343,28 +434,24 @@ paginate(List, Limit, Offset) ->
%% ============ Форматирование ответа ============
--spec format_events([#event{}]) -> [map()].
-format_events(Events) ->
+-spec format_events([#event{}], map()) -> [map()].
+format_events(Events, Params) ->
%% Optimization: batch-fetch calendars for the (already paginated)
%% subset only, avoiding an N+1 query per event during formatting.
CalendarMap = build_calendar_map(
lists:usort([E#event.calendar_id || E <- Events])),
- lists:map(fun(E) -> format_event(E, CalendarMap) end, Events).
+ lists:map(fun(E) -> format_event(E, CalendarMap, Params) end, Events).
--spec format_event(#event{}, #{binary() => #calendar{}}) -> map().
-format_event(Event, CalendarMap) ->
- Location = case Event#event.location of
- undefined -> null;
- #location{address = Addr, lat = Lat, lon = Lon} ->
- #{address => Addr, lat => Lat, lon => Lon}
- end,
+-spec format_event(#event{}, #{binary() => #calendar{}}, map()) -> map().
+format_event(Event, CalendarMap, Params) ->
+ Location = handler_utils:location_to_json(Event#event.location),
{CalendarTitle, ImageUrl} = case maps:find(Event#event.calendar_id, CalendarMap) of
{ok, #calendar{title = T, image_url = <<>>}} -> {T, null};
{ok, #calendar{title = T, image_url = undefined}} -> {T, null};
{ok, #calendar{title = T, image_url = Url}} -> {T, Url};
error -> {null, null}
end,
- #{
+ Base = #{
id => Event#event.id,
calendar_id => Event#event.calendar_id,
calendar_title => CalendarTitle,
@@ -380,15 +467,25 @@ format_event(Event, CalendarMap) ->
rating_count => Event#event.rating_count,
status => Event#event.status,
image_url => ImageUrl
- }.
+ },
+ maybe_distance(Base, event_coords(Event), Params).
--spec format_calendars([#calendar{}]) -> [map()].
-format_calendars(Calendars) ->
- lists:map(fun format_calendar/1, Calendars).
+-spec format_calendars([#calendar{}], map()) -> [map()].
+format_calendars(Calendars, Params) ->
+ lists:map(fun(C) -> format_calendar(C, Params) end, Calendars).
--spec format_calendar(#calendar{}) -> map().
-format_calendar(Calendar) ->
- #{
+-spec format_calendar(#calendar{}, map()) -> map().
+format_calendar(Calendar, Params) ->
+ Settings = case Calendar#calendar.settings of
+ M when is_map(M) -> M;
+ _ -> #{}
+ end,
+ LocJson = case maps:get(<<"default_location">>, Settings,
+ maps:get(default_location, Settings, undefined)) of
+ undefined -> null;
+ Loc -> Loc
+ end,
+ Base = #{
id => Calendar#calendar.id,
owner_id => Calendar#calendar.owner_id,
title => Calendar#calendar.title,
@@ -404,7 +501,19 @@ format_calendar(Calendar) ->
rating_avg => Calendar#calendar.rating_avg,
rating_count => Calendar#calendar.rating_count,
status => Calendar#calendar.status
- }.
+ },
+ Base1 = case geo_origin(Params) of
+ none -> Base;
+ _ -> Base#{default_location => LocJson}
+ end,
+ maybe_distance(Base1, calendar_coords(Calendar), Params).
+
+maybe_distance(Map, Coords, Params) ->
+ case {geo_origin(Params), Coords} of
+ {{OLat, OLon}, {ok, Lat, Lon}} ->
+ Map#{distance_km => round(distance(OLat, OLon, Lat, Lon) * 10) / 10};
+ _ -> Map
+ end.
-spec datetime_to_iso8601(calendar:datetime()) -> binary().
datetime_to_iso8601({{Y, M, D}, {H, Min, S}}) ->
diff --git a/src/swagger/eventhub_trails.erl b/src/swagger/eventhub_trails.erl
index bbd0d1a..87b6c83 100755
--- a/src/swagger/eventhub_trails.erl
+++ b/src/swagger/eventhub_trails.erl
@@ -93,6 +93,7 @@ user() ->
handler_review_vote,
handler_reviews,
handler_search,
+ handler_geo,
handler_subscription,
handler_ticket_by_id,
handler_tickets,
diff --git a/test/unit/logic_geo_tests.erl b/test/unit/logic_geo_tests.erl
new file mode 100644
index 0000000..357163f
--- /dev/null
+++ b/test/unit/logic_geo_tests.erl
@@ -0,0 +1,78 @@
+-module(logic_geo_tests).
+-include_lib("eunit/include/eunit.hrl").
+
+setup() ->
+ catch ets:delete(eventhub_geo_cache),
+ catch ets:delete(eventhub_geo_rl),
+ logic_geo:ensure(),
+ application:unset_env(eventhub, geo_http_get),
+ os:putenv("PHOTON_URL", "http://photon.test"),
+ ok.
+
+cleanup(_) ->
+ application:unset_env(eventhub, geo_http_get),
+ os:unsetenv("PHOTON_URL"),
+ catch ets:delete(eventhub_geo_cache),
+ catch ets:delete(eventhub_geo_rl),
+ ok.
+
+logic_geo_test_() ->
+ {foreach,
+ fun setup/0,
+ fun cleanup/1,
+ [
+ {"Suggest too short", fun test_suggest_short/0},
+ {"Suggest parses photon geojson", fun test_suggest_ok/0},
+ {"Geocode empty features is not_found", fun test_geocode_not_found/0},
+ {"Unavailable without photon url", fun test_unavailable/0},
+ {"Reverse fallback address", fun test_reverse_empty/0},
+ {"Format address from properties", fun test_format_address/0}
+ ]}.
+
+sample_geojson() ->
+ <<"{
+ \"features\": [{
+ \"geometry\": {\"coordinates\": [37.62, 55.75]},
+ \"properties\": {
+ \"name\": \"Red Square\",
+ \"city\": \"Moscow\",
+ \"country\": \"Russia\"
+ }
+ }]
+ }">>.
+
+test_suggest_short() ->
+ ?assertEqual({error, invalid_query}, logic_geo:suggest(<<"ab">>, <<"ru">>, <<"ip">>)).
+
+test_suggest_ok() ->
+ application:set_env(eventhub, geo_http_get, fun(_) -> {ok, sample_geojson()} end),
+ {ok, Hits} = logic_geo:suggest(<<"красная площадь">>, <<"ru">>, <<"ip1">>),
+ ?assertMatch([#{<<"lat">> := 55.75, <<"lon">> := 37.62}], Hits),
+ [#{<<"address">> := Addr}] = Hits,
+ ?assertNotEqual(<<>>, Addr).
+
+test_geocode_not_found() ->
+ application:set_env(eventhub, geo_http_get, fun(_) -> {ok, <<"{\"features\":[]}">>} end),
+ ?assertEqual({error, not_found},
+ logic_geo:geocode(<<"nowherexyz">>, <<"ru">>, <<"u1">>)).
+
+test_unavailable() ->
+ os:unsetenv("PHOTON_URL"),
+ application:unset_env(eventhub, geo_http_get),
+ ?assertEqual({error, unavailable},
+ logic_geo:suggest(<<"москва центр">>, <<"ru">>, <<"ip2">>)).
+
+test_reverse_empty() ->
+ application:set_env(eventhub, geo_http_get, fun(_) -> {ok, <<"{\"features\":[]}">>} end),
+ {ok, Hit} = logic_geo:reverse(55.75, 37.62, <<"ru">>, <<"u2">>),
+ ?assertEqual(<<"photon">>, maps:get(<<"source">>, Hit)),
+ ?assertEqual(55.75, maps:get(<<"lat">>, Hit)).
+
+test_format_address() ->
+ Addr = logic_geo:format_address(#{
+ <<"housenumber">> => <<"1">>,
+ <<"street">> => <<"Tverskaya">>,
+ <<"city">> => <<"Moscow">>,
+ <<"country">> => <<"Russia">>
+ }),
+ ?assertEqual(<<"1 Tverskaya, Moscow, Russia">>, Addr).
diff --git a/test/unit/logic_search_tests.erl b/test/unit/logic_search_tests.erl
index cd35dca..46cc0f3 100755
--- a/test/unit/logic_search_tests.erl
+++ b/test/unit/logic_search_tests.erl
@@ -23,6 +23,9 @@ logic_search_test_() ->
{"Search events by tags", fun test_search_events_by_tags/0},
{"Search events by date range", fun test_search_events_by_date/0},
{"Search events by location", fun test_search_events_by_location/0},
+ {"Search calendars by location", fun test_search_calendars_by_location/0},
+ {"Geo without radius keeps distant hits", fun test_geo_no_radius/0},
+ {"Geo sort by distance", fun test_geo_sort_distance/0},
{"Combined search", fun test_combined_search/0},
{"Search calendars", fun test_search_calendars/0},
{"Search calendars include image_url", fun test_search_calendars_image_url/0},
@@ -152,6 +155,59 @@ test_search_events_by_location() ->
#{lat => 59.9343, lon => 30.3351, radius => 10})),
?assertEqual(1, Total2).
+test_search_calendars_by_location() ->
+ OwnerId = create_test_user(user),
+ NearId = create_test_calendar(OwnerId, commercial, []),
+ FarId = create_test_calendar(OwnerId, commercial, []),
+ set_default_location(NearId, <<"Moscow">>, 55.7558, 37.6173),
+ set_default_location(FarId, <<"SPb">>, 59.9343, 30.3351),
+ {Total, Hits} = calendars_from(logic_search:search(<<"calendar">>, undefined, OwnerId,
+ #{lat => 55.7558, lon => 37.6173, radius => 10})),
+ ?assertEqual(1, Total),
+ [Hit] = Hits,
+ ?assertEqual(NearId, maps:get(id, Hit)),
+ ?assert(maps:is_key(distance_km, Hit)).
+
+test_geo_no_radius() ->
+ OwnerId = create_test_user(user),
+ CalendarId = create_test_calendar(OwnerId, commercial, []),
+ StartTime = eh_test_support:future_start(),
+ MoscowLoc = #location{address = <<"Moscow">>, lat = 55.7558, lon = 37.6173},
+ SpbLoc = #location{address = <<"SPb">>, lat = 59.9343, lon = 30.3351},
+ create_test_event(CalendarId, <<"Moscow Event">>, <<"">>, StartTime, [], MoscowLoc),
+ create_test_event(CalendarId, <<"SPb Event">>, <<"">>, StartTime, [], SpbLoc),
+ {Total, _} = events_from(logic_search:search(<<"event">>, undefined, OwnerId,
+ #{lat => 55.7558, lon => 37.6173})),
+ ?assertEqual(2, Total).
+
+test_geo_sort_distance() ->
+ OwnerId = create_test_user(user),
+ CalendarId = create_test_calendar(OwnerId, commercial, []),
+ StartTime = eh_test_support:future_start(),
+ MoscowLoc = #location{address = <<"Moscow">>, lat = 55.7558, lon = 37.6173},
+ SpbLoc = #location{address = <<"SPb">>, lat = 59.9343, lon = 30.3351},
+ create_test_event(CalendarId, <<"Moscow Event">>, <<"">>, StartTime, [], MoscowLoc),
+ create_test_event(CalendarId, <<"SPb Event">>, <<"">>, StartTime, [], SpbLoc),
+ {_, [First | _]} = events_from(logic_search:search(<<"event">>, undefined, OwnerId,
+ #{lat => 55.7558, lon => 37.6173})),
+ ?assertMatch(#{title := <<"Moscow Event">>}, First),
+ ?assert(maps:is_key(distance_km, First)).
+
+set_default_location(CalendarId, Addr, Lat, Lon) ->
+ {ok, Cal} = core_calendar:get_by_id(CalendarId),
+ Settings0 = case Cal#calendar.settings of
+ M when is_map(M) -> M;
+ _ -> #{}
+ end,
+ Settings = Settings0#{
+ <<"default_location">> => #{
+ <<"address">> => Addr,
+ <<"lat">> => Lat,
+ <<"lon">> => Lon
+ }
+ },
+ core_calendar:update(CalendarId, [{settings, Settings}]).
+
test_combined_search() ->
OwnerId = create_test_user(user),
CalendarId = create_test_calendar(OwnerId, commercial, []),