-module(logic_event). -include("records.hrl"). -export([create_event/5, create_event/6, create_recurring_event/6, create_recurring_event/7, get_event/2, list_events/2, update_event/3, delete_event/2]). -export([validate_event_time/1, validate_event_time/2, get_occurrences/3, cancel_occurrence/3]). -export([materialize_for_booking/3, validate_occurrence/2]). -export([list_all_events/1, get_event_admin/1, update_event_admin/2, delete_event_admin/1]). -export([search_events/1]). -define(DEFAULT_SEARCH_DAYS, 30). %% Создание одиночного события create_event(UserId, CalendarId, Title, StartTime, Duration) -> create_event(UserId, CalendarId, Title, StartTime, Duration, <<>>). create_event(UserId, CalendarId, Title, StartTime, Duration, Description) -> case logic_calendar:get_calendar(UserId, CalendarId) of {ok, Calendar} -> case logic_calendar:can_edit(UserId, Calendar) of true -> case validate_event_time(StartTime, UserId) of ok -> case logic_automoderation:evaluate_texts([Title, Description]) of {reject, Words} -> {error, {content_banned, Words}}; {ok, Action, [Title2, Desc2], Words} -> case core_event:create(Calendar#calendar.id, Title2, StartTime, Duration) of {ok, Event} -> case Desc2 of <<>> -> ok; _ -> _ = core_event:update(Event#event.id, [{description, Desc2}]) end, logic_automoderation:apply_after_save(event, Event#event.id, Action, Words), core_event:get_by_id(Event#event.id); Error -> Error end end; {error, _} = Error -> Error end; false -> {error, access_denied} end; Error -> Error end. %% Создание повторяющегося события create_recurring_event(UserId, CalendarId, Title, StartTime, Duration, RRule) -> create_recurring_event(UserId, CalendarId, Title, StartTime, Duration, RRule, <<>>). create_recurring_event(UserId, CalendarId, Title, StartTime, Duration, RRule, Description) -> case logic_calendar:get_calendar(UserId, CalendarId) of {ok, Calendar} -> case logic_calendar:can_edit(UserId, Calendar) of true -> case validate_event_time(StartTime, UserId) of ok -> case logic_recurrence:validate_rrule(RRule) of true -> case logic_automoderation:evaluate_texts([Title, Description]) of {reject, Words} -> {error, {content_banned, Words}}; {ok, Action, [Title2, Desc2], Words} -> case core_event:create_recurring(Calendar#calendar.id, Title2, StartTime, Duration, RRule) of {ok, Event} -> case Desc2 of <<>> -> ok; _ -> _ = core_event:update(Event#event.id, [{description, Desc2}]) end, logic_automoderation:apply_after_save(event, Event#event.id, Action, Words), core_event:get_by_id(Event#event.id); Error -> Error end end; false -> {error, invalid_rrule} end; {error, _} = Error -> Error end; false -> {error, access_denied} end; Error -> Error end. %% Получение вхождений повторяющегося события в диапазоне get_occurrences(UserId, MasterId, RangeEnd) -> case get_event(UserId, MasterId) of {ok, Event} when Event#event.event_type =:= recurring -> Decoded = jsx:decode(Event#event.recurrence_rule, [return_maps]), RRuleMap = case Decoded of Map when is_map(Map) -> Map; {ok, Map} -> Map end, {ok, ParsedRule} = logic_recurrence:parse_rrule(RRuleMap), Occurrences = logic_recurrence:generate_occurrences( Event#event.start_time, ParsedRule, RangeEnd ), Exceptions = get_exceptions(MasterId), ValidOccurrences = filter_cancelled(Occurrences, Exceptions), FinalOccurrences = merge_materialized(MasterId, ValidOccurrences), {ok, FinalOccurrences}; {ok, _} -> {error, not_recurring}; Error -> Error end. %% Отмена отдельного вхождения cancel_occurrence(UserId, MasterId, OccurrenceStart) -> case get_event(UserId, MasterId) of {ok, Event} when Event#event.event_type =:= recurring -> case logic_calendar:get_calendar(UserId, Event#event.calendar_id) of {ok, Calendar} -> case logic_calendar:can_edit(UserId, Calendar) of true -> Exception = #recurrence_exception{ master_id = MasterId, original_start = OccurrenceStart, action = cancel, new_start = undefined }, mnesia:dirty_write(Exception), {ok, cancelled}; false -> {error, access_denied} end; Error -> Error end; {ok, _} -> {error, not_recurring}; Error -> Error end. %% Материализация вхождения при записи участника materialize_for_booking(MasterId, OccurrenceStart, SpecialistId) -> core_event:materialize_occurrence(MasterId, OccurrenceStart, SpecialistId). %% Проверка, что OccurrenceStart — неотменённое вхождение серии. validate_occurrence(#event{event_type = recurring} = Event, OccurrenceStart) -> try Decoded = jsx:decode(Event#event.recurrence_rule, [return_maps]), RRuleMap = case Decoded of Map when is_map(Map) -> Map; _ -> #{} end, {ok, ParsedRule} = logic_recurrence:parse_rrule(RRuleMap), Occurrences = logic_recurrence:generate_occurrences( Event#event.start_time, ParsedRule, OccurrenceStart), Valid = filter_cancelled(Occurrences, get_exceptions(Event#event.id)), case lists:member(OccurrenceStart, Valid) of true -> ok; false -> {error, invalid_occurrence} end catch _:_ -> {error, invalid_occurrence} end; validate_occurrence(_, _) -> {error, not_recurring}. %% Получение события с проверкой доступа get_event(UserId, EventId) -> case core_event:get_by_id(EventId) of {ok, Event} -> case logic_calendar:get_calendar(UserId, Event#event.calendar_id) of {ok, _} -> {ok, Event}; Error -> Error end; Error -> Error end. %% Список событий календаря list_events(UserId, CalendarId) -> case logic_calendar:get_calendar(UserId, CalendarId) of {ok, Calendar} -> core_event:list_by_calendar(Calendar#calendar.id); Error -> Error end. %% Обновление события update_event(UserId, EventId, Updates) -> case core_event:get_by_id(EventId) of {ok, Event} -> case logic_calendar:get_calendar(UserId, Event#event.calendar_id) of {ok, Calendar} -> case logic_calendar:can_edit(UserId, Calendar) of true -> case validate_specialist_update(Calendar, Updates) of {error, _} = E -> E; ok -> ValidUpdates = validate_updates(Updates, UserId), Title = proplists:get_value(title, ValidUpdates, Event#event.title), Desc = proplists:get_value(description, ValidUpdates, Event#event.description), case logic_automoderation:evaluate_texts([Title, Desc]) of {reject, Words} -> {error, {content_banned, Words}}; {ok, Action, [Title2, Desc2], Words} -> Final0 = case lists:keymember(title, 1, ValidUpdates) of true -> lists:keystore(title, 1, ValidUpdates, {title, Title2}); false -> ValidUpdates end, Final = case lists:keymember(description, 1, Final0) orelse Desc2 =/= Event#event.description of true when Action =:= censor -> lists:keystore(description, 1, Final0, {description, Desc2}); true -> case lists:keymember(description, 1, Final0) of true -> lists:keystore(description, 1, Final0, {description, Desc2}); false -> Final0 end; false -> Final0 end, case core_event:update(EventId, Final) of {ok, _} -> logic_automoderation:apply_after_save(event, EventId, Action, Words), core_event:get_by_id(EventId); Error -> Error end end end; false -> {error, access_denied} end; Error -> Error end; Error -> Error end. %% Удаление события delete_event(UserId, EventId) -> case core_event:get_by_id(EventId) of {ok, Event} -> case logic_calendar:get_calendar(UserId, Event#event.calendar_id) of {ok, Calendar} -> case logic_calendar:can_edit(UserId, Calendar) of true -> core_event:delete(EventId); false -> {error, access_denied} end; Error -> Error end; Error -> Error end. %% @doc Поиск событий с пагинацией, фильтрацией и сортировкой search_events(Params) -> #{ from := FromIn, to := ToIn, status := StatusFilter, calendar_id := CalId, title := TitleExact, q := Query, limit := Limit, offset := Offset, sort := SortField, order := Order } = Params, {From, To} = ensure_time_range(FromIn, ToIn), %% 1. Получаем исходный список событий AllEvents = case CalId of undefined -> % Без календаря — только активные мастер-события core_event:list_all(); _ -> % Для конкретного календаря загружаем все события (любой статус) mnesia:dirty_index_match_object( event, #event{calendar_id = CalId, _ = '_'}, calendar_id ) end, %% 2. Фильтрация по дате (игнорируем undefined и instance) TimeFiltered = [E || E <- AllEvents, E#event.is_instance =:= false, E#event.start_time =/= undefined, E#event.start_time >= From, E#event.start_time =< To], %% 3. Фильтр по точному названию TitleFiltered = if TitleExact /= undefined -> [E || E <- TimeFiltered, E#event.title =:= TitleExact]; true -> TimeFiltered end, %% 4. Пост‑фильтры по статусу и поисковой строке %% Определяем эффективный фильтр статуса EffectiveStatus = case {CalId, StatusFilter} of {undefined, undefined} -> undefined; % без календаря → только active {_, undefined} -> <<"all">>; % конкретный календарь → все статусы _ -> StatusFilter end, FinalFiltered = apply_post_filters(TitleFiltered, EffectiveStatus, Query), %% 5. Сортировка и пагинация SortedEvents = sort_events(FinalFiltered, SortField, Order), Total = length(SortedEvents), Page = lists:sublist(SortedEvents, Offset + 1, Limit), {ok, Total, Page}. %% Дополнительная фильтрация по статусу и подстроке apply_post_filters(Events, StatusFilter, Query) -> E1 = case StatusFilter of <<"all">> -> Events; undefined -> [E || E <- Events, E#event.status =:= active]; _ -> try binary_to_existing_atom(StatusFilter, utf8) of Atom -> [E || E <- Events, E#event.status =:= Atom] catch error:badarg -> Events end end, case Query of undefined -> E1; _ -> [E || E <- E1, string:str(binary_to_list(E#event.title), binary_to_list(Query)) > 0 orelse string:str(binary_to_list(E#event.description), binary_to_list(Query)) > 0] end. %%-------------------------------------------------------------------- %% Внутренние функции для search_events %%-------------------------------------------------------------------- ensure_time_range(undefined, undefined) -> NowSec = calendar:datetime_to_gregorian_seconds(calendar:universal_time()), FromSec = NowSec - ?DEFAULT_SEARCH_DAYS * 86400, ToSec = NowSec + ?DEFAULT_SEARCH_DAYS * 86400, {calendar:gregorian_seconds_to_datetime(FromSec), calendar:gregorian_seconds_to_datetime(ToSec)}; ensure_time_range(From, undefined) -> {From, calendar:gregorian_seconds_to_datetime( calendar:datetime_to_gregorian_seconds(From) + ?DEFAULT_SEARCH_DAYS * 2 * 86400)}; ensure_time_range(undefined, To) -> {calendar:gregorian_seconds_to_datetime( calendar:datetime_to_gregorian_seconds(To) - ?DEFAULT_SEARCH_DAYS * 2 * 86400), To}; ensure_time_range(From, To) -> {From, To}. sort_events(Events, SortField, Order) -> Field = binary_to_existing_atom(SortField, utf8), Sorted = lists:sort( fun(A, B) -> ValA = event_field(A, Field), ValB = event_field(B, Field), if Order == <<"asc">> -> ValA =< ValB; true -> ValA >= ValB end end, Events), Sorted. event_field(Event, created_at) -> Event#event.created_at; event_field(Event, start_time) -> Event#event.start_time; event_field(Event, title) -> Event#event.title; event_field(Event, status) -> Event#event.status; event_field(_, _) -> undefined. %% Валидация времени события (без учёта пользователя) validate_event_time(StartTime) -> validate_event_time(StartTime, undefined). %% Валидация времени события с учётом роли пользователя validate_event_time(StartTime, UserId) -> case is_admin(UserId) of true -> ok; false -> Now = calendar:universal_time(), case StartTime > Now of true -> ok; false -> {error, event_in_past} end end. %% Проверка, является ли пользователь администратором is_admin(undefined) -> false; is_admin(UserId) -> case core_user:get_by_id(UserId) of {ok, User} -> User#user.role =:= admin; _ -> false end. %% Внутренние функции validate_updates(Updates, UserId) -> lists:filter(fun(Update) -> validate_update(Update, UserId) end, Updates). validate_update({title, Value}, _) when is_binary(Value) -> true; validate_update({description, Value}, _) when is_binary(Value) -> true; validate_update({start_time, Value}, UserId) -> case validate_event_time(Value, UserId) of ok -> true; _ -> false end; validate_update({duration, Value}, _) when is_integer(Value), Value > 0 -> true; validate_update({specialist_id, Value}, _) when is_binary(Value) -> true; validate_update({specialist_id, null}, _) -> true; validate_update({specialist_id, undefined}, _) -> true; validate_update({location, Value}, _) -> case Value of #location{} -> true; _ -> false end; validate_update({tags, Value}, _) when is_list(Value) -> true; validate_update({capacity, Value}, _) when is_integer(Value), Value > 0 -> true; validate_update({online_link, Value}, _) when is_binary(Value) -> true; validate_update({status, Value}, _) when is_atom(Value) -> true; validate_update(_, _) -> false. validate_specialist_update(Calendar, Updates) -> case lists:keyfind(specialist_id, 1, Updates) of false -> ok; {specialist_id, null} -> ok; {specialist_id, undefined} -> ok; {specialist_id, <<>>} -> ok; {specialist_id, SpecId} when is_binary(SpecId) -> case Calendar#calendar.type of commercial -> case core_calendar_specialist:is_active_specialist(Calendar#calendar.id, SpecId) of true -> ok; false -> {error, invalid_specialist} end; _ -> {error, invalid_specialist} end; _ -> ok end. get_exceptions(MasterId) -> Match = #recurrence_exception{master_id = MasterId, _ = '_'}, mnesia:dirty_match_object(Match). filter_cancelled(Occurrences, Exceptions) -> CancelledStarts = [E#recurrence_exception.original_start || E <- Exceptions, E#recurrence_exception.action =:= cancel], lists:filter(fun(Occ) -> not lists:member(Occ, CancelledStarts) end, Occurrences). merge_materialized(MasterId, Occurrences) -> Materialized = mnesia:dirty_match_object( #event{master_id = MasterId, is_instance = true, status = active, _ = '_'} ), lists:map(fun(Occ) -> case lists:keyfind(Occ, #event.start_time, Materialized) of false -> {virtual, Occ}; Event -> {materialized, Event} end end, Occurrences). %% ─── Административные функции (без проверки прав) ───────────────── list_all_events(Filters) -> Events = core_event:list_all(), % возвращает список, а не {ok, List} Filtered = apply_filters(Events, Filters), {ok, Filtered}. get_event_admin(EventId) -> core_event:get_by_id(EventId). update_event_admin(EventId, Updates) -> case core_event:get_by_id(EventId) of {ok, _Event} -> ValidUpdates = validate_updates(Updates, undefined), core_event:update(EventId, ValidUpdates); Error -> Error end. delete_event_admin(EventId) -> core_event:delete(EventId). %% Применяет фильтры from/to к списку событий apply_filters(Events, []) -> Events; apply_filters(Events, [{from, From} | Rest]) -> apply_filters( [E || E <- Events, E#event.start_time >= From], Rest ); apply_filters(Events, [{to, To} | Rest]) -> apply_filters( [E || E <- Events, E#event.start_time =< To], Rest ); apply_filters(Events, [_ | Rest]) -> apply_filters(Events, Rest).