From 6b70e1336dd0a09acf8963f1492cc69f6e029884 Mon Sep 17 00:00:00 2001 From: Aleksey Sabilin Date: Mon, 17 Aug 2026 23:23:10 +0300 Subject: [PATCH] feat(geo): Photon proxy, address-only location, nearby search. Refs EventHub/EventHubBack#77 --- docker/.env.example | 4 + docker/docker-compose.swarm.yml | 28 +- src/eventhub_app.erl | 3 + .../admin/admin_handler_event_by_id.erl | 9 +- src/handlers/handler_event_by_id.erl | 9 +- src/handlers/handler_events.erl | 9 +- src/handlers/handler_geo.erl | 162 +++++++++ src/handlers/handler_search.erl | 80 +++-- src/handlers/handler_utils.erl | 71 +++- src/logic/logic_geo.erl | 316 ++++++++++++++++++ src/logic/logic_search.erl | 183 ++++++++-- src/swagger/eventhub_trails.erl | 1 + test/unit/logic_geo_tests.erl | 78 +++++ test/unit/logic_search_tests.erl | 56 ++++ 14 files changed, 919 insertions(+), 90 deletions(-) create mode 100644 src/handlers/handler_geo.erl create mode 100644 src/logic/logic_geo.erl create mode 100644 test/unit/logic_geo_tests.erl 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, []),