497 lines
19 KiB
Erlang
Executable File
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). |