%%%------------------------------------------------------------------- %%% @doc Модуль поиска событий и календарей. %%% %%% Предоставляет публичную функцию `search/4`, которая выполняет %%% фильтрацию, сортировку и пагинацию доступных пользователю сущностей. %%% Поддерживается полнотекстовый поиск, фильтры по тегам, датам и %%% географическому положению. %%% @end %%%------------------------------------------------------------------- -module(logic_search). -include("records.hrl"). -export([search/4]). %% ───────────────────────────────────────────────────────────────── %% Константы %% ───────────────────────────────────────────────────────────────── -define(DEFAULT_LIMIT, 20). -define(MAX_LIMIT, 100). -define(DISCOVERY_FETCH, 200). -define(EARTH_RADIUS_KM, 6371.0). %%%------------------------------------------------------------------- %%% @doc Выполняет поиск по событиям и календарям. %%% %%% `Type` может принимать значения `<<"event">>`, `<<"calendar">>` %%% или `undefined` (оба типа). %%% %%% Возвращает кортеж `{ok, TotalCount, ResultMap}`, где ResultMap %%% содержит ключи `<<"events">>` и/или `<<"calendars">>`. %%% @end %%%------------------------------------------------------------------- -spec search(Type :: binary() | undefined, Query :: binary() | undefined, UserId :: binary(), Params :: map()) -> {ok, non_neg_integer(), map()}. search(Type, Query, UserId, Params) -> Limit = min(maps:get(limit, Params, ?DEFAULT_LIMIT), ?MAX_LIMIT), Offset = maps:get(offset, Params, 0), case is_discovery_request(Query, Params) of true -> discovery_search(Type, UserId, Params, Limit, Offset); false -> filtered_search(Type, Query, UserId, Params, Limit, Offset) end. filtered_search(Type, Query, UserId, Params, Limit, Offset) -> case Type of <<"event">> -> {ok, Total, Events} = search_events(Query, UserId, Params, Limit, Offset), {ok, Total, #{<<"events">> => Events}}; <<"calendar">> -> {ok, Total, Calendars} = search_calendars(Query, UserId, Params, Limit, Offset), {ok, Total, #{<<"calendars">> => Calendars}}; _ -> {ok, EventsTotal, Events} = search_events(Query, UserId, Params, Limit, Offset), {ok, CalendarsTotal, Calendars} = search_calendars(Query, UserId, Params, Limit, Offset), {ok, EventsTotal + CalendarsTotal, #{ <<"events">> => Events, <<"calendars">> => Calendars }} end. %% Пустой search (страница «Главная»): tops из stats_tops; иначе — полный scan. is_discovery_request(Query, Params) -> QueryEmpty = Query =:= undefined orelse Query =:= <<>>, QueryEmpty andalso not maps:is_key(tags, Params) andalso not maps:is_key(from, Params) andalso not maps:is_key(to, Params) andalso not maps:is_key(lat, Params) andalso not maps:is_key(lon, Params) andalso not maps:is_key(sort, Params). discovery_search(Type, UserId, Params, Limit, Offset) -> case Type of <<"event">> -> {ok, Total, Events} = discovery_events(UserId, Params, Limit, Offset), {ok, Total, #{<<"events">> => Events}}; <<"calendar">> -> {ok, Total, Calendars} = discovery_calendars(UserId, Params, Limit, Offset), {ok, Total, #{<<"calendars">> => Calendars}}; _ -> {ok, EventsTotal, Events} = discovery_events(UserId, Params, Limit, Offset), {ok, CalendarsTotal, Calendars} = discovery_calendars(UserId, Params, Limit, Offset), {ok, EventsTotal + CalendarsTotal, #{ <<"events">> => Events, <<"calendars">> => Calendars }} end. discovery_events(UserId, Params, Limit, Offset) -> FetchN = max(Limit + Offset, ?DISCOVERY_FETCH), Tops = core_event:get_top_events_by_rating(FetchN), Accessible = filter_accessible_events(Tops, UserId), case Accessible of [] -> search_events(undefined, UserId, Params, Limit, Offset); Items -> Total = length(Items), {ok, Total, format_events(paginate(Items, Limit, Offset))} end. discovery_calendars(UserId, Params, Limit, Offset) -> FetchN = max(Limit + Offset, ?DISCOVERY_FETCH), Tops = core_calendar:get_top_calendars_by_rating(FetchN), Accessible = filter_accessible_calendars(Tops, UserId), case Accessible of [] -> search_calendars(undefined, UserId, Params, Limit, Offset); Items -> Total = length(Items), {ok, Total, format_calendars(paginate(Items, Limit, Offset))} end. %% ============ Поиск событий ============ -spec search_events(Query :: binary() | undefined, UserId :: binary(), Params :: map(), Limit :: non_neg_integer(), Offset :: non_neg_integer()) -> {ok, non_neg_integer(), [map()]}. search_events(Query, UserId, Params, Limit, Offset) -> AllEvents = get_all_events(), AccessibleEvents = filter_accessible_events(AllEvents, UserId), Filtered = apply_event_filters(AccessibleEvents, Query, Params), Sorted = sort_events(Filtered, Params), Paginated = paginate(Sorted, Limit, Offset), {ok, length(Filtered), format_events(Paginated)}. %% ============ Поиск календарей ============ -spec search_calendars(Query :: binary() | undefined, UserId :: binary(), Params :: map(), Limit :: non_neg_integer(), Offset :: non_neg_integer()) -> {ok, non_neg_integer(), [map()]}. 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)}. %% ============ Получение данных ============ -spec get_all_events() -> [#event{}]. get_all_events() -> Match = #event{status = active, is_instance = false, _ = '_'}, mnesia:dirty_match_object(Match). -spec get_all_calendars() -> [#calendar{}]. get_all_calendars() -> Match = #calendar{status = active, _ = '_'}, mnesia:dirty_match_object(Match). %% ============ Фильтрация по доступности ============ -spec filter_accessible_events([#event{}], binary()) -> [#event{}]. filter_accessible_events(Events, UserId) -> lists:filter(fun(Event) -> case core_calendar:get_by_id(Event#event.calendar_id) of {ok, Calendar} -> CanAccess = logic_calendar:can_access(UserId, Calendar), case CanAccess of false -> %% io:format("Access denied for user ~p to calendar ~p (type: ~p, owner: ~p, status: ~p)~n", %% [UserId, Calendar#calendar.id, Calendar#calendar.type, %% Calendar#calendar.owner_id, Calendar#calendar.status]); false; true -> ok end, CanAccess; _ -> false end end, Events). -spec filter_accessible_calendars([#calendar{}], binary()) -> [#calendar{}]. filter_accessible_calendars(Calendars, UserId) -> lists:filter(fun(Calendar) -> logic_calendar:can_access(UserId, Calendar) end, Calendars). %% ============ Применение фильтров ============ -spec apply_event_filters([#event{}], binary() | undefined, map()) -> [#event{}]. apply_event_filters(Events, Query, Params) -> Events1 = filter_by_text(Events, Query), Events2 = filter_by_tags(Events1, Params), Events3 = filter_by_date_range(Events2, Params), filter_by_location(Events3, 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). %% --- Текстовый поиск --- -spec filter_by_text([#event{} | #calendar{}], binary() | undefined) -> [#event{} | #calendar{}]. filter_by_text(Items, undefined) -> Items; filter_by_text(Items, <<>>) -> Items; filter_by_text(Items, Query) -> QueryLower = string:lowercase(binary_to_list(Query)), lists:filter(fun(Item) -> Title = binary_to_list(get_title(Item)), Description = binary_to_list(get_description(Item)), string:find(string:lowercase(Title), QueryLower) =/= nomatch orelse string:find(string:lowercase(Description), QueryLower) =/= nomatch end, Items). %% --- Фильтр по тегам --- -spec filter_by_tags([#event{} | #calendar{}], map()) -> [#event{} | #calendar{}]. filter_by_tags(Items, Params) -> case maps:get(tags, Params, undefined) of undefined -> Items; TagsStr -> Tags = [string:trim(T) || T <- string:split(TagsStr, ",", all)], lists:filter(fun(Item) -> ItemTags = get_tags(Item), has_any_tag(ItemTags, Tags) end, Items) end. %% --- Фильтр по дате --- -spec filter_by_date_range([#event{}], map()) -> [#event{}]. filter_by_date_range(Events, Params) -> From = maps:get(from, Params, undefined), To = maps:get(to, Params, undefined), case {From, To} of {undefined, undefined} -> Events; _ -> lists:filter(fun(Event) -> StartTime = Event#event.start_time, (From =:= undefined orelse StartTime >= From) andalso (To =:= undefined orelse StartTime =< To) end, Events) end. %% --- Гео-фильтр --- -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; {Lat, Lon} -> Radius = maps:get(radius, Params, 10), 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) end. %% ============ Вспомогательные функции ============ -spec get_title(#event{} | #calendar{}) -> binary(). get_title(#event{title = Title}) -> Title; get_title(#calendar{title = Title}) -> Title. -spec get_description(#event{} | #calendar{}) -> binary(). get_description(#event{description = Desc}) -> Desc; get_description(#calendar{description = Desc}) -> Desc. -spec get_tags(#event{} | #calendar{}) -> [binary()]. get_tags(#event{tags = Tags}) -> Tags; get_tags(#calendar{tags = Tags}) -> Tags. -spec has_any_tag([binary()], [string()]) -> boolean(). has_any_tag(ItemTags, SearchTags) -> lists:any(fun(Tag) -> lists:member(Tag, ItemTags) end, SearchTags). %% ============ Гео-вычисления ============ %% @doc Вычисляет расстояние между двумя географическими точками по формуле Хаверсина. -spec distance(number(), number(), number(), number()) -> float(). distance(Lat1, Lon1, Lat2, Lon2) -> DLat = deg_to_rad(Lat2 - Lat1), DLon = deg_to_rad(Lon2 - Lon1), A = math:sin(DLat / 2) * math:sin(DLat / 2) + math:cos(deg_to_rad(Lat1)) * math:cos(deg_to_rad(Lat2)) * math:sin(DLon / 2) * math:sin(DLon / 2), C = 2 * math:atan2(math:sqrt(A), math:sqrt(1 - A)), ?EARTH_RADIUS_KM * C. -spec deg_to_rad(number()) -> float(). 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">>), Order = maps:get(order, Params, <<"asc">>), Sorted = case SortBy of <<"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); _ -> Events end, case Order of <<"desc">> -> lists:reverse(Sorted); _ -> Sorted end. %% ============ Пагинация ============ -spec paginate([term()], non_neg_integer(), non_neg_integer()) -> [term()]. paginate(List, Limit, Offset) -> lists:sublist(List, Offset + 1, Limit). %% ============ Форматирование ответа ============ -spec format_events([#event{}]) -> [map()]. format_events(Events) -> lists:map(fun format_event/1, Events). -spec format_event(#event{}) -> map(). format_event(Event) -> Location = case Event#event.location of undefined -> null; #location{address = Addr, lat = Lat, lon = Lon} -> #{address => Addr, lat => Lat, lon => Lon} end, CalendarTitle = case core_calendar:get_by_id(Event#event.calendar_id) of {ok, Cal} -> Cal#calendar.title; _ -> null end, #{ id => Event#event.id, calendar_id => Event#event.calendar_id, calendar_title => CalendarTitle, title => Event#event.title, description => Event#event.description, event_type => Event#event.event_type, start_time => datetime_to_iso8601(Event#event.start_time), duration => Event#event.duration, location => Location, tags => Event#event.tags, capacity => Event#event.capacity, rating_avg => Event#event.rating_avg, rating_count => Event#event.rating_count, status => Event#event.status }. -spec format_calendars([#calendar{}]) -> [map()]. format_calendars(Calendars) -> lists:map(fun format_calendar/1, Calendars). -spec format_calendar(#calendar{}) -> map(). format_calendar(Calendar) -> #{ id => Calendar#calendar.id, owner_id => Calendar#calendar.owner_id, title => Calendar#calendar.title, description => Calendar#calendar.description, type => Calendar#calendar.type, tags => Calendar#calendar.tags, rating_avg => Calendar#calendar.rating_avg, rating_count => Calendar#calendar.rating_count, status => Calendar#calendar.status }. -spec datetime_to_iso8601(calendar:datetime()) -> binary(). datetime_to_iso8601({{Y, M, D}, {H, Min, S}}) -> iolist_to_binary(io_lib:format("~4..0B-~2..0B-~2..0BT~2..0B:~2..0B:~2..0BZ", [Y, M, D, H, Min, S])).