Files
EventHubBack/src/logic/logic_event.erl
T
aleksey 3d7ae0d372
CI / test (push) Successful in 7m28s
CI / deploy-ift (push) Successful in 2m46s
CI / e2e-ift (push) Successful in 1m26s
CI / deploy-stage (push) Successful in 2m4s
CI / e2e-stage (push) Successful in 2m4s
fix(api): resolve public calendar GET by unique short_name.
2026-08-14 15:00:41 +03:00

497 lines
19 KiB
Erlang
Executable File

-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).