Stage 4
This commit is contained in:
@@ -1,24 +1,31 @@
|
||||
-module(core_user).
|
||||
|
||||
-include("records.hrl").
|
||||
|
||||
-export([create/2, get_by_id/1, get_by_email/1, update/2, delete/1]).
|
||||
-export([email_exists/1]).
|
||||
-export([generate_id/0]).
|
||||
|
||||
%% Создание пользователя
|
||||
create(Email, Password) ->
|
||||
% Сначала проверяем, существует ли email
|
||||
% Проверяем, существует ли email
|
||||
case email_exists(Email) of
|
||||
true ->
|
||||
{error, email_exists};
|
||||
false ->
|
||||
Id = generate_id(),
|
||||
{ok, PasswordHash} = logic_auth:hash_password(Password),
|
||||
|
||||
% Определяем роль: первый пользователь становится админом
|
||||
Role = case mnesia:dirty_match_object(#user{_ = '_'}) of
|
||||
[] -> admin;
|
||||
_ -> user
|
||||
end,
|
||||
|
||||
User = #user{
|
||||
id = Id,
|
||||
email = Email,
|
||||
password_hash = PasswordHash,
|
||||
role = user,
|
||||
role = Role,
|
||||
status = active,
|
||||
created_at = calendar:universal_time(),
|
||||
updated_at = calendar:universal_time()
|
||||
@@ -42,7 +49,7 @@ get_by_id(Id) ->
|
||||
[User] -> {ok, User}
|
||||
end.
|
||||
|
||||
%% Получение пользователя по email (через индекс позже)
|
||||
%% Получение пользователя по email
|
||||
get_by_email(Email) ->
|
||||
Match = #user{email = Email, _ = '_'},
|
||||
case mnesia:dirty_match_object(Match) of
|
||||
@@ -82,11 +89,15 @@ delete(Id) ->
|
||||
%% Внутренние функции
|
||||
generate_id() ->
|
||||
base64:encode(crypto:strong_rand_bytes(16), #{mode => urlsafe, padding => false}).
|
||||
apply_updates(User, Updates) ->
|
||||
lists:foldl(fun({Field, Value}, U) -> set_field(Field, Value, U) end, User, Updates).
|
||||
|
||||
set_field(email, Value, User) -> User#user{email = Value, updated_at = calendar:universal_time()};
|
||||
set_field(password_hash, Value, User) -> User#user{password_hash = Value, updated_at = calendar:universal_time()};
|
||||
set_field(role, Value, User) -> User#user{role = Value, updated_at = calendar:universal_time()};
|
||||
set_field(status, Value, User) -> User#user{status = Value, updated_at = calendar:universal_time()};
|
||||
set_field(_, _, User) -> User.
|
||||
apply_updates(User, Updates) ->
|
||||
Updated = lists:foldl(fun({Field, Value}, U) ->
|
||||
set_field(Field, Value, U)
|
||||
end, User, Updates),
|
||||
Updated#user{updated_at = calendar:universal_time()}.
|
||||
|
||||
set_field(email, Value, U) -> U#user{email = Value};
|
||||
set_field(password_hash, Value, U) -> U#user{password_hash = Value};
|
||||
set_field(role, Value, U) when Value =:= user; Value =:= admin -> U#user{role = Value};
|
||||
set_field(status, Value, U) when Value =:= active; Value =:= frozen; Value =:= deleted -> U#user{status = Value};
|
||||
set_field(_, _, U) -> U.
|
||||
@@ -4,19 +4,14 @@
|
||||
-export([start/2, stop/1]).
|
||||
|
||||
start(_StartType, _StartArgs) ->
|
||||
% Запускаем Mnesia
|
||||
application:ensure_all_started(mnesia),
|
||||
application:ensure_all_started(cowboy),
|
||||
|
||||
case infra_sup:start_link() of
|
||||
{ok, Pid} ->
|
||||
% Инициализируем таблицы и ждем готовности
|
||||
ok = infra_mnesia:init_tables(),
|
||||
ok = infra_mnesia:wait_for_tables(),
|
||||
|
||||
% Запускаем HTTP-сервер
|
||||
start_http(),
|
||||
|
||||
{ok, Pid};
|
||||
Error ->
|
||||
Error
|
||||
@@ -25,11 +20,9 @@ start(_StartType, _StartArgs) ->
|
||||
stop(_State) ->
|
||||
ok.
|
||||
|
||||
%% Internal functions
|
||||
start_http() ->
|
||||
Port = application:get_env(eventhub, http_port, 8080),
|
||||
|
||||
% Настройка маршрутов
|
||||
Dispatch = cowboy_router:compile([
|
||||
{'_', [
|
||||
{"/health", handler_health, []},
|
||||
@@ -38,6 +31,7 @@ start_http() ->
|
||||
{"/v1/refresh", handler_refresh, []},
|
||||
{"/v1/user/me", handler_user_me, []},
|
||||
{"/v1/user/bookings", handler_user_bookings, []},
|
||||
{"/v1/search", handler_search, []},
|
||||
{"/v1/calendars", handler_calendars, []},
|
||||
{"/v1/calendars/:id", handler_calendar_by_id, []},
|
||||
{"/v1/calendars/:calendar_id/events", handler_events, []},
|
||||
@@ -49,7 +43,6 @@ start_http() ->
|
||||
]}
|
||||
]),
|
||||
|
||||
% Настройка middleware
|
||||
Middlewares = [
|
||||
cowboy_router,
|
||||
cowboy_handler
|
||||
|
||||
@@ -24,9 +24,16 @@ create_calendar(Req) ->
|
||||
#{<<"title">> := Title} ->
|
||||
Description = maps:get(<<"description">>, Decoded, <<"">>),
|
||||
Confirmation = parse_confirmation(maps:get(<<"confirmation">>, Decoded, <<"manual">>)),
|
||||
Tags = maps:get(<<"tags">>, Decoded, []),
|
||||
Type = parse_type(maps:get(<<"type">>, Decoded, <<"personal">>)),
|
||||
|
||||
case logic_calendar:create_calendar(UserId, Title, Description, Confirmation) of
|
||||
{ok, Calendar} ->
|
||||
Response = calendar_to_json(Calendar),
|
||||
% Обновляем теги и тип
|
||||
Updates = [{tags, Tags}, {type, Type}],
|
||||
core_calendar:update(Calendar#calendar.id, Updates),
|
||||
{ok, Updated} = core_calendar:get_by_id(Calendar#calendar.id),
|
||||
Response = calendar_to_json(Updated),
|
||||
send_json(Req2, 201, Response);
|
||||
{error, user_inactive} ->
|
||||
send_error(Req2, 403, <<"User account is not active">>);
|
||||
@@ -51,6 +58,10 @@ parse_confirmation(<<"manual">>) -> manual;
|
||||
parse_confirmation(#{<<"timeout">> := N}) when is_integer(N), N > 0 -> {timeout, N};
|
||||
parse_confirmation(_) -> manual.
|
||||
|
||||
parse_type(<<"personal">>) -> personal;
|
||||
parse_type(<<"commercial">>) -> commercial;
|
||||
parse_type(_) -> personal.
|
||||
|
||||
%% GET /v1/calendars - список календарей
|
||||
list_calendars(Req) ->
|
||||
case handler_auth:authenticate(Req) of
|
||||
|
||||
@@ -43,9 +43,8 @@ update_event(Req) ->
|
||||
try jsx:decode(Body, [return_maps]) of
|
||||
UpdatesMap when is_map(UpdatesMap) ->
|
||||
Updates = maps:to_list(UpdatesMap),
|
||||
% Преобразуем строку времени в datetime если есть
|
||||
UpdatesWithTime = convert_time_field(Updates),
|
||||
case logic_event:update_event(UserId, EventId, UpdatesWithTime) of
|
||||
UpdatesWithTypes = convert_fields(Updates),
|
||||
case logic_event:update_event(UserId, EventId, UpdatesWithTypes) of
|
||||
{ok, Event} ->
|
||||
Response = event_to_json(Event),
|
||||
send_json(Req2, 200, Response);
|
||||
@@ -88,13 +87,22 @@ delete_event(Req) ->
|
||||
end.
|
||||
|
||||
%% Вспомогательные функции
|
||||
convert_time_field(Updates) ->
|
||||
convert_fields(Updates) ->
|
||||
lists:map(fun
|
||||
({start_time, Value}) when is_binary(Value) ->
|
||||
case parse_datetime(Value) of
|
||||
{ok, DateTime} -> {start_time, DateTime};
|
||||
_ -> {start_time, Value}
|
||||
end;
|
||||
({location, Value}) when is_map(Value) ->
|
||||
case Value of
|
||||
#{<<"lat">> := Lat, <<"lon">> := Lon} ->
|
||||
Address = maps:get(<<"address">>, Value, <<"">>),
|
||||
{location, #location{address = Address, lat = Lat, lon = Lon}};
|
||||
_ -> {location, undefined}
|
||||
end;
|
||||
({tags, Value}) when is_list(Value) ->
|
||||
{tags, Value};
|
||||
(Other) -> Other
|
||||
end, Updates).
|
||||
|
||||
@@ -125,6 +133,16 @@ event_to_json(Event) ->
|
||||
#{address => Addr, lat => Lat, lon => Lon}
|
||||
end,
|
||||
|
||||
RecurrenceJson = case Event#event.recurrence_rule of
|
||||
undefined -> null;
|
||||
Rule ->
|
||||
Decoded = jsx:decode(Rule, [return_maps]),
|
||||
case Decoded of
|
||||
Map when is_map(Map) -> Map;
|
||||
{ok, Map} -> Map
|
||||
end
|
||||
end,
|
||||
|
||||
#{
|
||||
id => Event#event.id,
|
||||
calendar_id => Event#event.calendar_id,
|
||||
@@ -133,6 +151,9 @@ event_to_json(Event) ->
|
||||
event_type => Event#event.event_type,
|
||||
start_time => datetime_to_iso8601(Event#event.start_time),
|
||||
duration => Event#event.duration,
|
||||
recurrence => RecurrenceJson,
|
||||
master_id => Event#event.master_id,
|
||||
is_instance => Event#event.is_instance,
|
||||
specialist_id => Event#event.specialist_id,
|
||||
location => LocationJson,
|
||||
tags => Event#event.tags,
|
||||
|
||||
@@ -27,13 +27,19 @@ create_event(Req) ->
|
||||
<<"duration">> := Duration} ->
|
||||
case parse_datetime(StartTimeStr) of
|
||||
{ok, StartTime} ->
|
||||
% Парсим location если есть
|
||||
Location = parse_location(maps:get(<<"location">>, Decoded, undefined)),
|
||||
|
||||
% Проверяем, есть ли правило повторения
|
||||
case maps:get(<<"recurrence">>, Decoded, undefined) of
|
||||
undefined ->
|
||||
% Одиночное событие
|
||||
case logic_event:create_event(UserId, CalendarId, Title, StartTime, Duration) of
|
||||
{ok, Event} ->
|
||||
Response = event_to_json(Event),
|
||||
% Обновляем location и capacity если нужно
|
||||
update_event_fields(Event#event.id, Location, Decoded),
|
||||
{ok, UpdatedEvent} = core_event:get_by_id(Event#event.id),
|
||||
Response = event_to_json(UpdatedEvent),
|
||||
send_json(Req2, 201, Response);
|
||||
{error, access_denied} ->
|
||||
send_error(Req2, 403, <<"Access denied">>);
|
||||
@@ -48,7 +54,9 @@ create_event(Req) ->
|
||||
% Повторяющееся событие
|
||||
case logic_event:create_recurring_event(UserId, CalendarId, Title, StartTime, Duration, RRule) of
|
||||
{ok, Event} ->
|
||||
Response = event_to_json(Event),
|
||||
update_event_fields(Event#event.id, Location, Decoded),
|
||||
{ok, UpdatedEvent} = core_event:get_by_id(Event#event.id),
|
||||
Response = event_to_json(UpdatedEvent),
|
||||
send_json(Req2, 201, Response);
|
||||
{error, invalid_rrule} ->
|
||||
send_error(Req2, 400, <<"Invalid recurrence rule">>);
|
||||
@@ -83,14 +91,12 @@ list_events(Req) ->
|
||||
case handler_auth:authenticate(Req) of
|
||||
{ok, UserId, Req1} ->
|
||||
CalendarId = cowboy_req:binding(calendar_id, Req1),
|
||||
% Проверяем параметры запроса для диапазона дат
|
||||
Qs = cowboy_req:parse_qs(Req1),
|
||||
From = proplists:get_value(<<"from">>, Qs, undefined),
|
||||
To = proplists:get_value(<<"to">>, Qs, undefined),
|
||||
|
||||
case logic_event:list_events(UserId, CalendarId) of
|
||||
{ok, Events} ->
|
||||
% Если указан диапазон, разворачиваем повторяющиеся события
|
||||
Response = case {From, To} of
|
||||
{undefined, undefined} ->
|
||||
[event_to_json(E) || E <- Events];
|
||||
@@ -111,7 +117,43 @@ list_events(Req) ->
|
||||
send_error(Req1, Code, Message)
|
||||
end.
|
||||
|
||||
%% Разворачивание повторяющихся событий в диапазоне
|
||||
%% Вспомогательные функции
|
||||
update_event_fields(EventId, Location, Decoded) ->
|
||||
Updates = [],
|
||||
Updates1 = case Location of
|
||||
undefined -> Updates;
|
||||
_ -> [{location, Location} | Updates]
|
||||
end,
|
||||
Updates2 = case maps:get(<<"capacity">>, Decoded, undefined) of
|
||||
undefined -> Updates1;
|
||||
Cap -> [{capacity, Cap} | Updates1]
|
||||
end,
|
||||
Updates3 = case maps:get(<<"tags">>, Decoded, undefined) of
|
||||
undefined -> Updates2;
|
||||
Tags -> [{tags, Tags} | Updates2]
|
||||
end,
|
||||
Updates4 = case maps:get(<<"description">>, Decoded, undefined) of
|
||||
undefined -> Updates3;
|
||||
Desc -> [{description, Desc} | Updates3]
|
||||
end,
|
||||
Updates5 = case maps:get(<<"online_link">>, Decoded, undefined) of
|
||||
undefined -> Updates4;
|
||||
Link -> [{online_link, Link} | Updates4]
|
||||
end,
|
||||
if Updates5 /= [] -> core_event:update(EventId, Updates5);
|
||||
true -> ok
|
||||
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.
|
||||
|
||||
expand_recurring_events(UserId, Events, From, To) ->
|
||||
lists:flatmap(fun(Event) ->
|
||||
case Event#event.event_type of
|
||||
@@ -148,7 +190,6 @@ parse_datetime_binary(Str) ->
|
||||
{ok, Dt} = parse_datetime(Str),
|
||||
Dt.
|
||||
|
||||
%% Вспомогательные функции
|
||||
parse_datetime(Str) ->
|
||||
try
|
||||
[DateStr, TimeStr] = string:split(Str, "T"),
|
||||
@@ -178,7 +219,12 @@ event_to_json(Event) ->
|
||||
|
||||
RecurrenceJson = case Event#event.recurrence_rule of
|
||||
undefined -> null;
|
||||
Rule -> jsx:decode(Rule, [return_maps])
|
||||
Rule ->
|
||||
Decoded = jsx:decode(Rule, [return_maps]),
|
||||
case Decoded of
|
||||
Map when is_map(Map) -> Map;
|
||||
{ok, Map} -> Map
|
||||
end
|
||||
end,
|
||||
|
||||
#{
|
||||
|
||||
104
src/handlers/handler_search.erl
Normal file
104
src/handlers/handler_search.erl
Normal file
@@ -0,0 +1,104 @@
|
||||
-module(handler_search).
|
||||
-include("records.hrl").
|
||||
|
||||
-export([init/2]).
|
||||
|
||||
init(Req, Opts) ->
|
||||
handle(Req, Opts).
|
||||
|
||||
handle(Req, _Opts) ->
|
||||
case cowboy_req:method(Req) of
|
||||
<<"GET">> -> search(Req);
|
||||
_ -> send_error(Req, 405, <<"Method not allowed">>)
|
||||
end.
|
||||
|
||||
search(Req) ->
|
||||
case handler_auth:authenticate(Req) of
|
||||
{ok, UserId, Req1} ->
|
||||
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
|
||||
},
|
||||
send_json(Req1, 200, Response);
|
||||
{error, _} ->
|
||||
send_error(Req1, 500, <<"Search failed">>)
|
||||
end;
|
||||
{error, Code, Message, Req1} ->
|
||||
send_error(Req1, Code, Message)
|
||||
end.
|
||||
|
||||
parse_params(Qs) ->
|
||||
Params = #{
|
||||
limit => parse_int_param(Qs, <<"limit">>, 20),
|
||||
offset => parse_int_param(Qs, <<"offset">>, 0),
|
||||
tags => proplists:get_value(<<"tags">>, Qs),
|
||||
sort => proplists:get_value(<<"sort">>, Qs, <<"start_time">>),
|
||||
order => proplists:get_value(<<"order">>, Qs, <<"asc">>)
|
||||
},
|
||||
|
||||
Params1 = case {parse_float_param(Qs, <<"lat">>), parse_float_param(Qs, <<"lon">>)} of
|
||||
{{ok, Lat}, {ok, Lon}} ->
|
||||
Radius = parse_int_param(Qs, <<"radius">>, 10),
|
||||
Params#{lat => Lat, lon => Lon, radius => Radius};
|
||||
_ -> Params
|
||||
end,
|
||||
|
||||
Params2 = case {parse_datetime_param(Qs, <<"from">>), parse_datetime_param(Qs, <<"to">>)} of
|
||||
{{ok, From}, {ok, To}} ->
|
||||
Params1#{from => From, to => To};
|
||||
{{ok, From}, error} ->
|
||||
Params1#{from => From};
|
||||
{error, {ok, To}} ->
|
||||
Params1#{to => To};
|
||||
_ -> Params1
|
||||
end,
|
||||
|
||||
Params2.
|
||||
|
||||
parse_int_param(Qs, Key, Default) ->
|
||||
case proplists:get_value(Key, Qs) of
|
||||
undefined -> Default;
|
||||
Val -> binary_to_integer(Val)
|
||||
end.
|
||||
|
||||
parse_float_param(Qs, Key) ->
|
||||
case proplists:get_value(Key, Qs) of
|
||||
undefined -> error;
|
||||
Val -> {ok, binary_to_float(Val)}
|
||||
end.
|
||||
|
||||
parse_datetime_param(Qs, Key) ->
|
||||
case proplists:get_value(Key, Qs) of
|
||||
undefined -> error;
|
||||
Val ->
|
||||
try
|
||||
[DateStr, TimeStr] = string:split(Val, "T"),
|
||||
TimeStrNoZ = string:trim(TimeStr, trailing, "Z"),
|
||||
|
||||
[Y, M, D] = [binary_to_integer(X) || X <- string:split(DateStr, "-", all)],
|
||||
[H, Min, S] = [binary_to_integer(X) || X <- string:split(TimeStrNoZ, ":", all)],
|
||||
|
||||
{ok, {{Y, M, D}, {H, Min, S}}}
|
||||
catch
|
||||
_:_ -> error
|
||||
end
|
||||
end.
|
||||
|
||||
send_json(Req, Status, Data) ->
|
||||
Body = jsx:encode(Data),
|
||||
cowboy_req:reply(Status, #{<<"content-type">> => <<"application/json">>}, Body, Req).
|
||||
|
||||
send_error(Req, Status, Message) ->
|
||||
Body = jsx:encode(#{error => Message}),
|
||||
cowboy_req:reply(Status, #{<<"content-type">> => <<"application/json">>}, Body, Req).
|
||||
@@ -1,11 +1,15 @@
|
||||
-module(logic_calendar).
|
||||
-include("records.hrl").
|
||||
|
||||
-export([create_calendar/4, get_calendar/2, list_calendars/1,
|
||||
-export([create_calendar/3, create_calendar/4, get_calendar/2, list_calendars/1,
|
||||
update_calendar/3, delete_calendar/2]).
|
||||
-export([can_access/2, can_edit/2]).
|
||||
|
||||
%% Создание календаря
|
||||
%% Создание календаря с политикой по умолчанию (manual)
|
||||
create_calendar(UserId, Title, Description) ->
|
||||
create_calendar(UserId, Title, Description, manual).
|
||||
|
||||
%% Создание календаря с указанной политикой подтверждения
|
||||
create_calendar(UserId, Title, Description, Confirmation) ->
|
||||
case core_user:get_by_id(UserId) of
|
||||
{ok, User} ->
|
||||
@@ -41,7 +45,6 @@ update_calendar(UserId, CalendarId, Updates) ->
|
||||
{ok, Calendar} ->
|
||||
case can_edit(UserId, Calendar) of
|
||||
true ->
|
||||
% Валидация обновлений
|
||||
ValidUpdates = validate_updates(Updates),
|
||||
core_calendar:update(CalendarId, ValidUpdates);
|
||||
false ->
|
||||
@@ -66,13 +69,20 @@ delete_calendar(UserId, CalendarId) ->
|
||||
end.
|
||||
|
||||
%% Проверка прав доступа (просмотр)
|
||||
can_access(UserId, #calendar{owner_id = UserId, status = active}) -> true;
|
||||
can_access(_UserId, #calendar{type = commercial, status = active}) -> true;
|
||||
can_access(_UserId, _) -> false.
|
||||
can_access(UserId, #calendar{owner_id = UserId, status = active}) ->
|
||||
true;
|
||||
can_access(_UserId, #calendar{type = commercial, status = active}) ->
|
||||
true;
|
||||
can_access(_UserId, _) ->
|
||||
false.
|
||||
|
||||
%% Проверка прав редактирования
|
||||
can_edit(UserId, #calendar{owner_id = UserId, status = active}) -> true;
|
||||
can_edit(_, _) -> false.
|
||||
can_edit(UserId, #calendar{owner_id = UserId, status = active}) ->
|
||||
true;
|
||||
can_edit(_UserId, #calendar{owner_id = _OwnerId}) ->
|
||||
false;
|
||||
can_edit(_, _) ->
|
||||
false.
|
||||
|
||||
%% Валидация полей обновления
|
||||
validate_updates(Updates) ->
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
|
||||
-export([create_event/5, create_recurring_event/6, get_event/2, list_events/2,
|
||||
update_event/3, delete_event/2]).
|
||||
-export([validate_event_time/1, get_occurrences/3, cancel_occurrence/3]).
|
||||
-export([validate_event_time/1, validate_event_time/2, get_occurrences/3, cancel_occurrence/3]).
|
||||
-export([materialize_for_booking/3]).
|
||||
|
||||
%% Создание одиночного события
|
||||
@@ -12,7 +12,7 @@ create_event(UserId, CalendarId, Title, StartTime, Duration) ->
|
||||
{ok, Calendar} ->
|
||||
case logic_calendar:can_edit(UserId, Calendar) of
|
||||
true ->
|
||||
case validate_event_time(StartTime) of
|
||||
case validate_event_time(StartTime, UserId) of
|
||||
ok ->
|
||||
core_event:create(CalendarId, Title, StartTime, Duration);
|
||||
{error, _} = Error ->
|
||||
@@ -31,7 +31,7 @@ create_recurring_event(UserId, CalendarId, Title, StartTime, Duration, RRule) ->
|
||||
{ok, Calendar} ->
|
||||
case logic_calendar:can_edit(UserId, Calendar) of
|
||||
true ->
|
||||
case validate_event_time(StartTime) of
|
||||
case validate_event_time(StartTime, UserId) of
|
||||
ok ->
|
||||
case logic_recurrence:validate_rrule(RRule) of
|
||||
true ->
|
||||
@@ -49,7 +49,6 @@ create_recurring_event(UserId, CalendarId, Title, StartTime, Duration, RRule) ->
|
||||
Error
|
||||
end.
|
||||
|
||||
%% Получение вхождений повторяющегося события в диапазоне
|
||||
%% Получение вхождений повторяющегося события в диапазоне
|
||||
get_occurrences(UserId, MasterId, RangeEnd) ->
|
||||
case get_event(UserId, MasterId) of
|
||||
@@ -61,18 +60,12 @@ get_occurrences(UserId, MasterId, RangeEnd) ->
|
||||
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};
|
||||
@@ -90,7 +83,6 @@ cancel_occurrence(UserId, MasterId, OccurrenceStart) ->
|
||||
{ok, Calendar} ->
|
||||
case logic_calendar:can_edit(UserId, Calendar) of
|
||||
true ->
|
||||
% Добавляем исключение
|
||||
Exception = #recurrence_exception{
|
||||
master_id = MasterId,
|
||||
original_start = OccurrenceStart,
|
||||
@@ -144,7 +136,7 @@ update_event(UserId, EventId, Updates) ->
|
||||
{ok, Calendar} ->
|
||||
case logic_calendar:can_edit(UserId, Calendar) of
|
||||
true ->
|
||||
ValidUpdates = validate_updates(Updates),
|
||||
ValidUpdates = validate_updates(Updates, UserId),
|
||||
core_event:update(EventId, ValidUpdates);
|
||||
false ->
|
||||
{error, access_denied}
|
||||
@@ -175,36 +167,53 @@ delete_event(UserId, EventId) ->
|
||||
Error
|
||||
end.
|
||||
|
||||
%% Валидация времени события
|
||||
%% Валидация времени события (без учёта пользователя)
|
||||
validate_event_time(StartTime) ->
|
||||
Now = calendar:universal_time(),
|
||||
case StartTime > Now of
|
||||
true -> ok;
|
||||
false -> {error, event_in_past}
|
||||
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) ->
|
||||
lists:filter(fun validate_update/1, Updates).
|
||||
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}) ->
|
||||
case validate_event_time(Value) of
|
||||
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({location, Value}) ->
|
||||
validate_update({duration, Value}, _) when is_integer(Value), Value > 0 -> true;
|
||||
validate_update({specialist_id, Value}, _) when is_binary(Value) -> 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(_) -> false.
|
||||
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(_, _) -> false.
|
||||
|
||||
get_exceptions(MasterId) ->
|
||||
Match = #recurrence_exception{master_id = MasterId, _ = '_'},
|
||||
|
||||
241
src/logic/logic_search.erl
Normal file
241
src/logic/logic_search.erl
Normal file
@@ -0,0 +1,241 @@
|
||||
-module(logic_search).
|
||||
-include("records.hrl").
|
||||
|
||||
-export([search/4]).
|
||||
|
||||
-define(DEFAULT_LIMIT, 20).
|
||||
-define(MAX_LIMIT, 100).
|
||||
-define(EARTH_RADIUS_KM, 6371.0).
|
||||
|
||||
%% Поиск событий и календарей
|
||||
search(Type, Query, UserId, Params) ->
|
||||
Limit = min(maps:get(limit, Params, ?DEFAULT_LIMIT), ?MAX_LIMIT),
|
||||
Offset = maps:get(offset, Params, 0),
|
||||
|
||||
case Type of
|
||||
<<"event">> -> search_events(Query, UserId, Params, Limit, Offset);
|
||||
<<"calendar">> -> search_calendars(Query, UserId, Params, Limit, Offset);
|
||||
_ -> search_all(Query, UserId, Params, Limit, Offset)
|
||||
end.
|
||||
|
||||
%% ============ Поиск событий ============
|
||||
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)}.
|
||||
|
||||
%% ============ Поиск календарей ============
|
||||
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)}.
|
||||
|
||||
%% ============ Поиск всего ============
|
||||
search_all(Query, UserId, Params, Limit, Offset) ->
|
||||
{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
|
||||
}}.
|
||||
|
||||
%% ============ Получение данных ============
|
||||
get_all_events() ->
|
||||
Match = #event{status = active, is_instance = false, _ = '_'},
|
||||
mnesia:dirty_match_object(Match).
|
||||
|
||||
get_all_calendars() ->
|
||||
Match = #calendar{status = active, _ = '_'},
|
||||
mnesia:dirty_match_object(Match).
|
||||
|
||||
%% ============ Фильтрация по доступности ============
|
||||
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]);
|
||||
true -> ok
|
||||
end,
|
||||
CanAccess;
|
||||
_ -> false
|
||||
end
|
||||
end, Events).
|
||||
|
||||
filter_accessible_calendars(Calendars, UserId) ->
|
||||
lists:filter(fun(Calendar) ->
|
||||
logic_calendar:can_access(UserId, Calendar)
|
||||
end, Calendars).
|
||||
|
||||
%% ============ Применение фильтров ============
|
||||
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).
|
||||
|
||||
apply_calendar_filters(Calendars, Query, Params) ->
|
||||
Calendars1 = filter_by_text(Calendars, Query),
|
||||
filter_by_tags(Calendars1, Params).
|
||||
|
||||
filter_by_text(Items, undefined) -> Items;
|
||||
filter_by_text(Items, <<>>) -> Items;
|
||||
filter_by_text(Items, Query) ->
|
||||
QueryLower = string:lowercase(Query),
|
||||
lists:filter(fun(Item) ->
|
||||
Title = get_title(Item),
|
||||
Description = get_description(Item),
|
||||
string:find(string:lowercase(Title), QueryLower) =/= nomatch orelse
|
||||
string:find(string:lowercase(Description), QueryLower) =/= nomatch
|
||||
end, Items).
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
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
|
||||
undefined -> false;
|
||||
#location{lat = EventLat, lon = EventLon} ->
|
||||
distance(Lat, Lon, EventLat, EventLon) =< Radius
|
||||
end
|
||||
end, Events)
|
||||
end.
|
||||
|
||||
%% ============ Вспомогательные функции ============
|
||||
get_title(#event{title = Title}) -> Title;
|
||||
get_title(#calendar{title = Title}) -> Title.
|
||||
|
||||
get_description(#event{description = Desc}) -> Desc;
|
||||
get_description(#calendar{description = Desc}) -> Desc.
|
||||
|
||||
get_tags(#event{tags = Tags}) -> Tags;
|
||||
get_tags(#calendar{tags = Tags}) -> Tags.
|
||||
|
||||
has_any_tag(ItemTags, SearchTags) ->
|
||||
lists:any(fun(Tag) -> lists:member(Tag, ItemTags) end, SearchTags).
|
||||
|
||||
%% ============ Гео-вычисления ============
|
||||
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.
|
||||
|
||||
deg_to_rad(Deg) -> Deg * math:pi() / 180.
|
||||
|
||||
%% ============ Сортировка ============
|
||||
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.
|
||||
|
||||
%% ============ Пагинация ============
|
||||
paginate(List, Limit, Offset) ->
|
||||
lists:sublist(List, Offset + 1, Limit).
|
||||
|
||||
%% ============ Форматирование ============
|
||||
format_events(Events) ->
|
||||
lists:map(fun format_event/1, Events).
|
||||
|
||||
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,
|
||||
|
||||
#{
|
||||
id => Event#event.id,
|
||||
calendar_id => Event#event.calendar_id,
|
||||
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
|
||||
}.
|
||||
|
||||
format_calendars(Calendars) ->
|
||||
lists:map(fun format_calendar/1, Calendars).
|
||||
|
||||
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
|
||||
}.
|
||||
|
||||
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])).
|
||||
Reference in New Issue
Block a user