Files
EventHubBack/src/handlers/handler_events.erl
T
aleksey e9d1a13900
CI / test (push) Failing after 37m9s
CI / deploy-ift (push) Has been skipped
CI / e2e-ift (push) Has been skipped
CI / deploy-stage (push) Has been skipped
CI / e2e-stage (push) Has been skipped
Добавить автомодерацию: политики, порог жалоб, settings/hits API и тесты.
Refs EventHub/EventHubBack#37
Refs EventHub/EventHubBack#38
Refs EventHub/EventHubBack#39
Refs EventHub/EventHubBack#40
2026-07-17 18:09:31 +03:00

332 lines
14 KiB
Erlang

%%%-------------------------------------------------------------------
%%% @doc Обработчик маршрута `/v1/calendars/:calendar_id/events`.
%%%
%%% POST – создание нового события (одиночного или повторяющегося).
%%% GET – получение списка событий календаря с возможностью фильтрации
%%% по диапазону дат и разворачиванием повторяющихся событий.
%%% @end
%%%-------------------------------------------------------------------
-module(handler_events).
-behaviour(cowboy_handler).
-export([init/2]).
-export([trails/0]).
-include("records.hrl").
%%% cowboy_handler callback
-spec init(cowboy_req:req(), any()) -> {ok, cowboy_req:req(), any()}.
init(Req, Opts) ->
handle(Req, Opts).
%%% Swagger metadata
-spec trails() -> [map()].
trails() ->
[
#{ % POST create event
path => <<"/v1/calendars/:calendar_id/events">>,
method => <<"POST">>,
description => <<"Create a new event (single or recurring)">>,
tags => [<<"Events">>],
parameters => [
#{
name => <<"calendar_id">>,
in => <<"path">>,
description => <<"Calendar ID">>,
required => true,
schema => #{type => string}
}
],
requestBody => #{
required => true,
content => #{<<"application/json">> => #{schema => event_create_schema()}}
},
responses => #{
201 => #{description => <<"Event created">>},
400 => #{description => <<"Missing required fields or invalid JSON">>},
403 => #{description => <<"Access denied">>},
404 => #{description => <<"Calendar not found">>}
}
},
#{ % GET list events
path => <<"/v1/calendars/:calendar_id/events">>,
method => <<"GET">>,
description => <<"List events of a calendar with optional date range">>,
tags => [<<"Events">>],
parameters => [
#{
name => <<"calendar_id">>,
in => <<"path">>,
description => <<"Calendar ID">>,
required => true,
schema => #{type => string}
},
#{
name => <<"from">>,
in => <<"query">>,
description => <<"Start datetime (ISO8601)">>,
required => false,
schema => #{type => string, format => <<"date-time">>}
},
#{
name => <<"to">>,
in => <<"query">>,
description => <<"End datetime (ISO8601)">>,
required => false,
schema => #{type => string, format => <<"date-time">>}
}
],
responses => #{
200 => #{
description => <<"Array of events">>,
content => #{<<"application/json">> => #{schema => #{
type => array,
items => event_schema()
}}}
},
403 => #{description => <<"Access denied">>},
404 => #{description => <<"Calendar not found">>}
}
}
].
event_schema() ->
#{
type => object,
properties => #{
id => #{type => string},
calendar_id => #{type => string},
title => #{type => string},
description => #{type => string},
event_type => #{type => string, enum => [<<"single">>, <<"recurring">>]},
start_time => #{type => string, format => <<"date-time">>},
duration => #{type => integer},
recurrence => #{type => object, nullable => true},
master_id => #{type => string, nullable => true},
is_instance => #{type => boolean},
specialist_id => #{type => string, nullable => true},
location => #{type => object, nullable => true},
tags => #{type => array, items => #{type => string}},
capacity => #{type => integer, nullable => true},
online_link => #{type => string, nullable => true},
status => #{type => string, enum => [<<"active">>, <<"cancelled">>, <<"completed">>]},
reason => #{type => string, nullable => true},
rating_avg => #{type => number, format => float},
rating_count => #{type => integer},
attachments => #{type => array, items => #{type => string}, nullable => true},
edit_history => #{type => array, items => #{type => object}, nullable => true},
created_at => #{type => string, format => <<"date-time">>},
updated_at => #{type => string, format => <<"date-time">>}
}
}.
event_create_schema() ->
#{
type => object,
required => [<<"title">>, <<"start_time">>, <<"duration">>],
properties => #{
title => #{type => string},
start_time => #{type => string, format => <<"date-time">>},
duration => #{type => integer, description => <<"Duration in minutes">>},
description => #{type => string},
tags => #{type => array, items => #{type => string}},
capacity => #{type => integer},
online_link => #{type => string},
location => #{
type => object,
properties => #{
address => #{type => string},
lat => #{type => number, format => float},
lon => #{type => number, format => float}
}
},
recurrence => #{type => object, description => <<"Recurrence rule (RFC 5545)">>}
}
}.
%%%===================================================================
%%% HTTP-методы
%%%===================================================================
%% @private
-spec handle(cowboy_req:req(), any()) -> {ok, cowboy_req:req(), any()}.
handle(Req, _Opts) ->
case cowboy_req:method(Req) of
<<"POST">> -> create_event(Req);
<<"GET">> -> list_events(Req);
_ -> handler_utils:send_error(Req, 405, <<"Method not allowed">>)
end.
%% @doc POST /v1/calendars/:calendar_id/events — создание события.
-spec create_event(cowboy_req:req()) -> {ok, binary(), cowboy_req:req()}.
create_event(Req) ->
case handler_utils:auth_user(Req) of
{ok, UserId, Req1} ->
CalendarId = cowboy_req:binding(calendar_id, Req1),
{ok, Body, Req2} = cowboy_req:read_body(Req1),
try jsx:decode(Body, [return_maps]) of
Decoded when is_map(Decoded) ->
case Decoded of
#{<<"title">> := Title, <<"start_time">> := StartTimeStr, <<"duration">> := Duration} ->
case handler_utils:parse_datetime(StartTimeStr) of
{ok, StartTime} ->
Location = parse_location(maps:get(<<"location">>, Decoded, undefined)),
Description = maps:get(<<"description">>, Decoded, <<>>),
case maps:get(<<"recurrence">>, Decoded, undefined) of
undefined ->
case logic_event:create_event(UserId, CalendarId, Title, StartTime, Duration, Description) of
{ok, Event} ->
update_event_fields(UserId, Event#event.id, Location, Decoded),
{ok, UpdatedEvent} = core_event:get_by_id(Event#event.id),
Response = handler_utils:event_to_json(UpdatedEvent),
handler_utils:send_json(Req2, 201, Response);
{error, access_denied} ->
handler_utils:send_error(Req2, 403, <<"Access denied">>);
{error, not_found} ->
handler_utils:send_error(Req2, 404, <<"Calendar not found">>);
{error, event_in_past} ->
handler_utils:send_error(Req2, 400, <<"Event cannot be in the past">>);
{error, {content_banned, _}} ->
handler_utils:send_error(Req2, 400, <<"Content contains banned words">>);
{error, _} ->
handler_utils:send_error(Req2, 500, <<"Internal server error">>)
end;
RRule ->
case logic_event:create_recurring_event(UserId, CalendarId, Title, StartTime, Duration, RRule, Description) of
{ok, Event} ->
update_event_fields(UserId, Event#event.id, Location, Decoded),
{ok, UpdatedEvent} = core_event:get_by_id(Event#event.id),
Response = handler_utils:event_to_json(UpdatedEvent),
handler_utils:send_json(Req2, 201, Response);
{error, invalid_rrule} ->
handler_utils:send_error(Req2, 400, <<"Invalid recurrence rule">>);
{error, access_denied} ->
handler_utils:send_error(Req2, 403, <<"Access denied">>);
{error, not_found} ->
handler_utils:send_error(Req2, 404, <<"Calendar not found">>);
{error, event_in_past} ->
handler_utils:send_error(Req2, 400, <<"Event cannot be in the past">>);
{error, {content_banned, _}} ->
handler_utils:send_error(Req2, 400, <<"Content contains banned words">>);
{error, _} ->
handler_utils:send_error(Req2, 500, <<"Internal server error">>)
end
end;
{error, _} ->
handler_utils:send_error(Req2, 400, <<"Invalid start_time format. Use ISO 8601">>)
end;
_ ->
handler_utils:send_error(Req2, 400, <<"Missing required fields: title, start_time, duration">>)
end;
_ ->
handler_utils:send_error(Req2, 400, <<"Invalid JSON">>)
catch
_:_ -> handler_utils:send_error(Req2, 400, <<"Invalid JSON format">>)
end;
{error, Code, Message, Req1} ->
handler_utils:send_error(Req1, Code, Message)
end.
%% @doc GET /v1/calendars/:calendar_id/events — список событий.
-spec list_events(cowboy_req:req()) -> {ok, binary(), cowboy_req:req()}.
list_events(Req) ->
case handler_utils:auth_user(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} ->
[handler_utils:event_to_json(E) || E <- Events];
{FromStr, ToStr} ->
FromDt = parse_datetime_binary(FromStr),
ToDt = parse_datetime_binary(ToStr),
expand_recurring_events(UserId, Events, FromDt, ToDt)
end,
handler_utils:send_json(Req1, 200, Response);
{error, access_denied} ->
handler_utils:send_error(Req1, 403, <<"Access denied">>);
{error, not_found} ->
handler_utils:send_error(Req1, 404, <<"Calendar not found">>);
{error, _} ->
handler_utils:send_error(Req1, 500, <<"Internal server error">>)
end;
{error, Code, Message, Req1} ->
handler_utils:send_error(Req1, Code, Message)
end.
%%%===================================================================
%%% Вспомогательные функции
%%%===================================================================
update_event_fields(UserId, 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,
%% description already applied in create_event/6 when present
Updates4 = case maps:get(<<"online_link">>, Decoded, undefined) of undefined -> Updates3; Link -> [{online_link, Link} | Updates3] end,
case Updates4 of
[] -> ok;
_ ->
case logic_event:update_event(UserId, EventId, Updates4) of
{ok, _} -> ok;
_ -> ok
end
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
single ->
case is_in_range(Event#event.start_time, From, To) of
true -> [handler_utils:event_to_json(Event)];
false -> []
end;
recurring ->
case logic_event:get_occurrences(UserId, Event#event.id, To) of
{ok, Occurrences} ->
lists:filtermap(
fun({virtual, Occ}) ->
case is_in_range(Occ, From, To) of
true -> {true, occurrence_to_json(Event, Occ)};
false -> false
end;
({materialized, Instance}) ->
case is_in_range(Instance#event.start_time, From, To) of
true -> {true, handler_utils:event_to_json(Instance)};
false -> false
end
end, Occurrences);
_ -> []
end
end
end, Events).
is_in_range(Time, From, To) -> Time >= From andalso Time =< To.
parse_datetime_binary(Str) ->
{ok, Dt} = handler_utils:parse_datetime(Str),
Dt.
occurrence_to_json(Master, Occurrence) ->
#{
master_id => Master#event.id,
start_time => handler_utils:datetime_to_iso8601(Occurrence),
duration => Master#event.duration,
title => Master#event.title,
is_virtual => true
}.