%%%------------------------------------------------------------------- %%% @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 }.