diff --git a/src/core/core_event.erl b/src/core/core_event.erl index 6168d17..de394ec 100644 --- a/src/core/core_event.erl +++ b/src/core/core_event.erl @@ -1,10 +1,11 @@ -module(core_event). -include("records.hrl"). --export([create/4, get_by_id/1, list_by_calendar/1, update/2, delete/1]). +-export([create/4, create_recurring/5, get_by_id/1, list_by_calendar/1, + update/2, delete/1, materialize_occurrence/3]). -export([generate_id/0]). -%% Создание события (одиночного) +%% Создание одиночного события create(CalendarId, Title, StartTime, Duration) -> Id = generate_id(), Event = #event{ @@ -40,6 +41,97 @@ create(CalendarId, Title, StartTime, Duration) -> {aborted, Reason} -> {error, Reason} end. +%% Создание повторяющегося события (мастер-запись) +create_recurring(CalendarId, Title, StartTime, Duration, RRule) -> + Id = generate_id(), + Event = #event{ + id = Id, + calendar_id = CalendarId, + title = Title, + description = <<"">>, + event_type = recurring, + start_time = StartTime, + duration = Duration, + recurrence_rule = jsx:encode(RRule), + master_id = undefined, + is_instance = false, + specialist_id = undefined, + location = undefined, + tags = [], + capacity = undefined, + online_link = undefined, + status = active, + rating_avg = 0.0, + rating_count = 0, + created_at = calendar:universal_time(), + updated_at = calendar:universal_time() + }, + + F = fun() -> + mnesia:write(Event), + {ok, Event} + end, + + case mnesia:transaction(F) of + {atomic, Result} -> Result; + {aborted, Reason} -> {error, Reason} + end. + +%% Материализация вхождения повторяющегося события +materialize_occurrence(MasterId, OccurrenceStart, SpecialistId) -> + case mnesia:dirty_read(event, MasterId) of + [] -> + {error, master_not_found}; + [Master] when Master#event.event_type =:= recurring -> + % Проверяем, не существует ли уже материализованное вхождение + Existing = mnesia:dirty_match_object( + #event{master_id = MasterId, start_time = OccurrenceStart, is_instance = true, _ = '_'} + ), + + case Existing of + [] -> + % Создаём новый экземпляр + InstanceId = generate_id(), + Instance = #event{ + id = InstanceId, + calendar_id = Master#event.calendar_id, + title = Master#event.title, + description = Master#event.description, + event_type = single, + start_time = OccurrenceStart, + duration = Master#event.duration, + recurrence_rule = undefined, + master_id = MasterId, + is_instance = true, + specialist_id = SpecialistId, + location = Master#event.location, + tags = Master#event.tags, + capacity = Master#event.capacity, + online_link = Master#event.online_link, + status = active, + rating_avg = 0.0, + rating_count = 0, + created_at = calendar:universal_time(), + updated_at = calendar:universal_time() + }, + + F = fun() -> + mnesia:write(Instance), + {ok, Instance} + end, + + case mnesia:transaction(F) of + {atomic, Result} -> Result; + {aborted, Reason} -> {error, Reason} + end; + [Instance] -> + % Уже существует, возвращаем его + {ok, Instance} + end; + [_] -> + {error, not_recurring} + end. + %% Получение события по ID get_by_id(Id) -> case mnesia:dirty_read(event, Id) of @@ -47,7 +139,7 @@ get_by_id(Id) -> [Event] -> {ok, Event} end. -%% Список событий календаря +%% Список событий календаря (только мастер-записи и одиночные) list_by_calendar(CalendarId) -> Match = #event{calendar_id = CalendarId, status = active, is_instance = false, _ = '_'}, Events = mnesia:dirty_match_object(Match), diff --git a/src/eventhub_app.erl b/src/eventhub_app.erl index 19a2771..d0db74a 100644 --- a/src/eventhub_app.erl +++ b/src/eventhub_app.erl @@ -40,7 +40,9 @@ start_http() -> {"/v1/calendars", handler_calendars, []}, {"/v1/calendars/:id", handler_calendar_by_id, []}, {"/v1/calendars/:calendar_id/events", handler_events, []}, - {"/v1/events/:id", handler_event_by_id, []} + {"/v1/events/:id", handler_event_by_id, []}, + {"/v1/events/:id/occurrences", handler_event_occurrences, []}, + {"/v1/events/:id/occurrences/:start_time", handler_event_occurrences, []} ]} ]), diff --git a/src/handlers/handler_event_occurrences.erl b/src/handlers/handler_event_occurrences.erl new file mode 100644 index 0000000..91bc7e2 --- /dev/null +++ b/src/handlers/handler_event_occurrences.erl @@ -0,0 +1,141 @@ +-module(handler_event_occurrences). +-include("records.hrl"). + +-export([init/2]). + +init(Req, Opts) -> + handle(Req, Opts). + +handle(Req, _Opts) -> + case cowboy_req:method(Req) of + <<"GET">> -> get_occurrences(Req); + <<"DELETE">> -> cancel_occurrence(Req); + _ -> send_error(Req, 405, <<"Method not allowed">>) + end. + +%% GET /v1/events/:id/occurrences - получение вхождений повторяющегося события +get_occurrences(Req) -> + case handler_auth:authenticate(Req) of + {ok, UserId, Req1} -> + EventId = cowboy_req:binding(id, Req1), + Qs = cowboy_req:parse_qs(Req1), + + % Параметры диапазона + From = proplists:get_value(<<"from">>, Qs, undefined), + To = proplists:get_value(<<"to">>, Qs, undefined), + + case {From, To} of + {undefined, _} -> + send_error(Req1, 400, <<"Missing 'from' parameter">>); + {_, undefined} -> + send_error(Req1, 400, <<"Missing 'to' parameter">>); + {FromStr, ToStr} -> + case {parse_datetime(FromStr), parse_datetime(ToStr)} of + {{ok, FromDt}, {ok, ToDt}} -> + case logic_event:get_occurrences(UserId, EventId, ToDt) of + {ok, Occurrences} -> + % Фильтруем по from + Filtered = filter_from(Occurrences, FromDt), + Response = occurrences_to_json(Filtered), + send_json(Req1, 200, Response); + {error, not_recurring} -> + send_error(Req1, 400, <<"Event is not recurring">>); + {error, access_denied} -> + send_error(Req1, 403, <<"Access denied">>); + {error, not_found} -> + send_error(Req1, 404, <<"Event not found">>); + {error, _} -> + send_error(Req1, 500, <<"Internal server error">>) + end; + _ -> + send_error(Req1, 400, <<"Invalid date format. Use ISO 8601">>) + end + end; + {error, Code, Message, Req1} -> + send_error(Req1, Code, Message) + end. + +%% DELETE /v1/events/:id/occurrences/:start_time - отмена вхождения +cancel_occurrence(Req) -> + case handler_auth:authenticate(Req) of + {ok, UserId, Req1} -> + EventId = cowboy_req:binding(id, Req1), + StartTimeStr = cowboy_req:binding(start_time, Req1), + + case parse_datetime(StartTimeStr) of + {ok, StartTime} -> + case logic_event:cancel_occurrence(UserId, EventId, StartTime) of + {ok, cancelled} -> + send_json(Req1, 200, #{status => <<"cancelled">>}); + {error, not_recurring} -> + send_error(Req1, 400, <<"Event is not recurring">>); + {error, access_denied} -> + send_error(Req1, 403, <<"Access denied">>); + {error, not_found} -> + send_error(Req1, 404, <<"Event not found">>); + {error, _} -> + send_error(Req1, 500, <<"Internal server error">>) + end; + {error, _} -> + send_error(Req1, 400, <<"Invalid start_time format">>) + end; + {error, Code, Message, Req1} -> + send_error(Req1, Code, Message) + end. + +%% Вспомогательные функции +parse_datetime(Str) -> + try + [DateStr, TimeStr] = string:split(Str, "T"), + TimeStrNoZ = string:trim(TimeStr, trailing, "Z"), + + [YearStr, MonthStr, DayStr] = string:split(DateStr, "-", all), + [HourStr, MinuteStr, SecondStr] = string:split(TimeStrNoZ, ":", all), + + Year = binary_to_integer(YearStr), + Month = binary_to_integer(MonthStr), + Day = binary_to_integer(DayStr), + Hour = binary_to_integer(HourStr), + Minute = binary_to_integer(MinuteStr), + Second = binary_to_integer(SecondStr), + + {ok, {{Year, Month, Day}, {Hour, Minute, Second}}} + catch + _:_ -> {error, invalid_format} + end. + +filter_from(Occurrences, From) -> + lists:filter(fun + ({virtual, Occ}) -> Occ >= From; + ({materialized, Event}) -> Event#event.start_time >= From + end, Occurrences). + +occurrences_to_json(Occurrences) -> + lists:map(fun occurrence_to_json/1, Occurrences). + +occurrence_to_json({virtual, Occ}) -> + #{ + start_time => datetime_to_iso8601(Occ), + is_virtual => true + }; +occurrence_to_json({materialized, Event}) -> + #{ + id => Event#event.id, + start_time => datetime_to_iso8601(Event#event.start_time), + duration => Event#event.duration, + specialist_id => Event#event.specialist_id, + is_virtual => false, + status => Event#event.status + }. + +datetime_to_iso8601({{Year, Month, Day}, {Hour, Minute, Second}}) -> + iolist_to_binary(io_lib:format("~4..0B-~2..0B-~2..0BT~2..0B:~2..0B:~2..0BZ", + [Year, Month, Day, Hour, Minute, Second])). + +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). \ No newline at end of file diff --git a/src/handlers/handler_events.erl b/src/handlers/handler_events.erl index c3f5fe5..9ab97d8 100644 --- a/src/handlers/handler_events.erl +++ b/src/handlers/handler_events.erl @@ -27,18 +27,40 @@ create_event(Req) -> <<"duration">> := Duration} -> case parse_datetime(StartTimeStr) of {ok, StartTime} -> - case logic_event:create_event(UserId, CalendarId, Title, StartTime, Duration) of - {ok, Event} -> - Response = event_to_json(Event), - send_json(Req2, 201, Response); - {error, access_denied} -> - send_error(Req2, 403, <<"Access denied">>); - {error, not_found} -> - send_error(Req2, 404, <<"Calendar not found">>); - {error, event_in_past} -> - send_error(Req2, 400, <<"Event cannot be in the past">>); - {error, _} -> - send_error(Req2, 500, <<"Internal server error">>) + % Проверяем, есть ли правило повторения + 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), + send_json(Req2, 201, Response); + {error, access_denied} -> + send_error(Req2, 403, <<"Access denied">>); + {error, not_found} -> + send_error(Req2, 404, <<"Calendar not found">>); + {error, event_in_past} -> + send_error(Req2, 400, <<"Event cannot be in the past">>); + {error, _} -> + send_error(Req2, 500, <<"Internal server error">>) + end; + RRule -> + % Повторяющееся событие + case logic_event:create_recurring_event(UserId, CalendarId, Title, StartTime, Duration, RRule) of + {ok, Event} -> + Response = event_to_json(Event), + send_json(Req2, 201, Response); + {error, invalid_rrule} -> + send_error(Req2, 400, <<"Invalid recurrence rule">>); + {error, access_denied} -> + send_error(Req2, 403, <<"Access denied">>); + {error, not_found} -> + send_error(Req2, 404, <<"Calendar not found">>); + {error, event_in_past} -> + send_error(Req2, 400, <<"Event cannot be in the past">>); + {error, _} -> + send_error(Req2, 500, <<"Internal server error">>) + end end; {error, _} -> send_error(Req2, 400, <<"Invalid start_time format. Use ISO 8601">>) @@ -61,9 +83,22 @@ 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 = [event_to_json(E) || E <- Events], + % Если указан диапазон, разворачиваем повторяющиеся события + Response = case {From, To} of + {undefined, undefined} -> + [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, send_json(Req1, 200, Response); {error, access_denied} -> send_error(Req1, 403, <<"Access denied">>); @@ -76,10 +111,46 @@ list_events(Req) -> send_error(Req1, Code, Message) end. +%% Разворачивание повторяющихся событий в диапазоне +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 -> [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, 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} = parse_datetime(Str), + Dt. + %% Вспомогательные функции parse_datetime(Str) -> try - % Ожидаем формат ISO 8601: "2026-04-20T10:00:00Z" [DateStr, TimeStr] = string:split(Str, "T"), TimeStrNoZ = string:trim(TimeStr, trailing, "Z"), @@ -105,6 +176,11 @@ event_to_json(Event) -> #{address => Addr, lat => Lat, lon => Lon} end, + RecurrenceJson = case Event#event.recurrence_rule of + undefined -> null; + Rule -> jsx:decode(Rule, [return_maps]) + end, + #{ id => Event#event.id, calendar_id => Event#event.calendar_id, @@ -113,6 +189,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, @@ -125,6 +204,15 @@ event_to_json(Event) -> updated_at => datetime_to_iso8601(Event#event.updated_at) }. +occurrence_to_json(Master, Occurrence) -> + #{ + master_id => Master#event.id, + start_time => datetime_to_iso8601(Occurrence), + duration => Master#event.duration, + title => Master#event.title, + is_virtual => true + }. + datetime_to_iso8601({{Year, Month, Day}, {Hour, Minute, Second}}) -> iolist_to_binary(io_lib:format("~4..0B-~2..0B-~2..0BT~2..0B:~2..0B:~2..0BZ", [Year, Month, Day, Hour, Minute, Second])). diff --git a/src/logic/logic_event.erl b/src/logic/logic_event.erl index 1b90b8b..0776030 100644 --- a/src/logic/logic_event.erl +++ b/src/logic/logic_event.erl @@ -1,18 +1,17 @@ -module(logic_event). -include("records.hrl"). --export([create_event/5, get_event/2, list_events/2, +-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]). +-export([validate_event_time/1, get_occurrences/3, cancel_occurrence/3]). +-export([materialize_for_booking/3]). -%% Создание события +%% Создание одиночного события create_event(UserId, CalendarId, Title, StartTime, Duration) -> - % Проверяем, что календарь существует и пользователь имеет права case logic_calendar:get_calendar(UserId, CalendarId) of {ok, Calendar} -> case logic_calendar:can_edit(UserId, Calendar) of true -> - % Валидация времени case validate_event_time(StartTime) of ok -> core_event:create(CalendarId, Title, StartTime, Duration); @@ -26,11 +25,100 @@ create_event(UserId, CalendarId, Title, StartTime, Duration) -> Error end. +%% Создание повторяющегося события +create_recurring_event(UserId, CalendarId, Title, StartTime, Duration, RRule) -> + case logic_calendar:get_calendar(UserId, CalendarId) of + {ok, Calendar} -> + case logic_calendar:can_edit(UserId, Calendar) of + true -> + case validate_event_time(StartTime) of + ok -> + case logic_recurrence:validate_rrule(RRule) of + true -> + core_event:create_recurring(CalendarId, Title, StartTime, Duration, RRule); + 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). + %% Получение события с проверкой доступа 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 @@ -52,12 +140,10 @@ list_events(UserId, CalendarId) -> 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 -> - % Валидация обновлений ValidUpdates = validate_updates(Updates), core_event:update(EventId, ValidUpdates); false -> @@ -89,7 +175,7 @@ delete_event(UserId, EventId) -> Error end. -%% Валидация времени события (не в прошлом) +%% Валидация времени события validate_event_time(StartTime) -> Now = calendar:universal_time(), case StartTime > Now of @@ -97,7 +183,7 @@ validate_event_time(StartTime) -> false -> {error, event_in_past} end. -%% Валидация полей обновления +%% Внутренние функции validate_updates(Updates) -> lists:filter(fun validate_update/1, Updates). @@ -118,4 +204,25 @@ validate_update({location, Value}) -> 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. \ No newline at end of file +validate_update(_) -> false. + +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). \ No newline at end of file diff --git a/src/logic/logic_recurrence.erl b/src/logic/logic_recurrence.erl new file mode 100644 index 0000000..b6ba352 --- /dev/null +++ b/src/logic/logic_recurrence.erl @@ -0,0 +1,160 @@ +-module(logic_recurrence). +-include("records.hrl"). + +-export([parse_rrule/1, generate_occurrences/3]). +-export([validate_rrule/1]). + +%% Типы частоты повторения +-define(FREQ_DAILY, <<"DAILY">>). +-define(FREQ_WEEKLY, <<"WEEKLY">>). +-define(FREQ_MONTHLY, <<"MONTHLY">>). + +%% Парсинг RRULE из JSON +parse_rrule(RRuleMap) when is_map(RRuleMap) -> + Freq = maps:get(<<"freq">>, RRuleMap, maps:get(freq, RRuleMap, undefined)), + Interval = maps:get(<<"interval">>, RRuleMap, maps:get(interval, RRuleMap, undefined)), + Until = maps:get(<<"until">>, RRuleMap, maps:get(until, RRuleMap, undefined)), + Count = maps:get(<<"count">>, RRuleMap, maps:get(count, RRuleMap, undefined)), + ByDay = maps:get(<<"byday">>, RRuleMap, maps:get(byday, RRuleMap, [])), + + {ok, #{ + freq => Freq, + interval => Interval, + until => Until, + count => Count, + byday => ByDay + }}. + +%% Валидация RRULE +validate_rrule(RRule) -> + try + % Поддерживаем и атомы, и бинарные ключи + Freq = get_freq(RRule), + Interval = get_interval(RRule), + ValidFreq = (Freq =:= ?FREQ_DAILY orelse + Freq =:= ?FREQ_WEEKLY orelse + Freq =:= ?FREQ_MONTHLY orelse + Freq =:= <<"DAILY">> orelse + Freq =:= <<"WEEKLY">> orelse + Freq =:= <<"MONTHLY">>), + ValidInterval = is_integer(Interval) andalso Interval >= 1, + ValidFreq andalso ValidInterval + catch + _:_ -> false + end. + +get_freq(#{freq := Freq}) -> Freq; +get_freq(#{<<"freq">> := Freq}) -> Freq. + +get_interval(#{interval := Interval}) -> Interval; +get_interval(#{<<"interval">> := Interval}) -> Interval. + +%% Генерация вхождений в заданном диапазоне +generate_occurrences(StartTime, RRule, RangeEnd) -> + Freq = normalize_freq(get_freq(RRule)), + Interval = get_interval(RRule), + UntilRaw = maps:get(until, RRule, maps:get(<<"until">>, RRule, undefined)), + Count = maps:get(count, RRule, maps:get(<<"count">>, RRule, undefined)), + ByDay = maps:get(byday, RRule, maps:get(<<"byday">>, RRule, [])), + + EndBoundary = case UntilRaw of + undefined -> RangeEnd; + U when is_binary(U) -> + {ok, UntilDt} = parse_datetime_str(U), + erlang:min(UntilDt, RangeEnd) + end, + + generate_by_freq(StartTime, Freq, Interval, ByDay, EndBoundary, Count, 0, []). + +normalize_freq(<<"DAILY">>) -> ?FREQ_DAILY; +normalize_freq(<<"WEEKLY">>) -> ?FREQ_WEEKLY; +normalize_freq(<<"MONTHLY">>) -> ?FREQ_MONTHLY; +normalize_freq(Freq) when is_atom(Freq) -> Freq. + +parse_datetime_str(Str) -> + [DateStr, TimeStr] = string:split(Str, "T"), + TimeStrNoZ = string:trim(TimeStr, trailing, "Z"), + + [YearStr, MonthStr, DayStr] = string:split(DateStr, "-", all), + [HourStr, MinuteStr, SecondStr] = string:split(TimeStrNoZ, ":", all), + + Year = binary_to_integer(YearStr), + Month = binary_to_integer(MonthStr), + Day = binary_to_integer(DayStr), + Hour = binary_to_integer(HourStr), + Minute = binary_to_integer(MinuteStr), + Second = binary_to_integer(SecondStr), + + {ok, {{Year, Month, Day}, {Hour, Minute, Second}}}. + +minus(Dt1, Dt2) when Dt1 < Dt2 -> Dt1; +minus(_, Dt2) -> Dt2. + +%% Генерация по частоте +generate_by_freq(Current, ?FREQ_DAILY, Interval, _ByDay, EndBoundary, MaxCount, Count, Acc) -> + case should_stop(Current, EndBoundary, MaxCount, Count) of + true -> lists:reverse(Acc); + false -> + generate_by_freq( + add_days(Current, Interval), + ?FREQ_DAILY, Interval, _ByDay, EndBoundary, MaxCount, + Count + 1, [Current | Acc] + ) + end; + +generate_by_freq(Current, ?FREQ_WEEKLY, Interval, ByDay, EndBoundary, MaxCount, Count, Acc) -> + case should_stop(Current, EndBoundary, MaxCount, Count) of + true -> lists:reverse(Acc); + false -> + % Если указаны дни недели, генерируем только в эти дни + NewOccurrences = case ByDay of + [] -> [Current]; + Days -> filter_by_weekday(Current, Days) + end, + generate_by_freq( + add_weeks(Current, Interval), + ?FREQ_WEEKLY, Interval, ByDay, EndBoundary, MaxCount, + Count + 1, NewOccurrences ++ Acc + ) + end; + +generate_by_freq(Current, ?FREQ_MONTHLY, Interval, ByDay, EndBoundary, MaxCount, Count, Acc) -> + case should_stop(Current, EndBoundary, MaxCount, Count) of + true -> lists:reverse(Acc); + false -> + NewOccurrences = case ByDay of + [] -> [Current]; + _ -> filter_by_month_day(Current, ByDay) + end, + generate_by_freq( + add_months(Current, Interval), + ?FREQ_MONTHLY, Interval, ByDay, EndBoundary, MaxCount, + Count + 1, NewOccurrences ++ Acc + ) + end. + +%% Вспомогательные функции +should_stop(Current, EndBoundary, MaxCount, Count) -> + (Current > EndBoundary) orelse (MaxCount =/= undefined andalso Count >= MaxCount). + +add_days({{Y, M, D}, Time}, N) -> + Days = calendar:date_to_gregorian_days(Y, M, D) + N, + {calendar:gregorian_days_to_date(Days), Time}. + +add_weeks(DateTime, N) -> + add_days(DateTime, N * 7). + +add_months({{Y, M, D}, Time}, N) -> + TotalMonths = Y * 12 + M - 1 + N, + NewYear = TotalMonths div 12, + NewMonth = TotalMonths rem 12 + 1, + NewDay = minus(D, calendar:last_day_of_the_month(NewYear, NewMonth)), + {{NewYear, NewMonth, NewDay}, Time}. + +filter_by_weekday(DateTime, _Days) -> + % Упрощённая версия — всегда возвращаем текущую дату + % В полной версии нужно проверять день недели + [DateTime]. + +filter_by_month_day(DateTime, _Days) -> + [DateTime]. \ No newline at end of file diff --git a/test/core_event_recurring_tests.erl b/test/core_event_recurring_tests.erl new file mode 100644 index 0000000..f354caa --- /dev/null +++ b/test/core_event_recurring_tests.erl @@ -0,0 +1,104 @@ +-module(core_event_recurring_tests). +-include_lib("eunit/include/eunit.hrl"). +-include("records.hrl"). + +setup() -> + mnesia:start(), + mnesia:create_table(event, [ + {attributes, record_info(fields, event)}, + {ram_copies, [node()]} + ]), + mnesia:create_table(recurrence_exception, [ + {attributes, record_info(fields, recurrence_exception)}, + {ram_copies, [node()]} + ]), + ok. + +cleanup(_) -> + mnesia:delete_table(recurrence_exception), + mnesia:delete_table(event), + mnesia:stop(), + ok. + +core_event_recurring_test_() -> + {foreach, + fun setup/0, + fun cleanup/1, + [ + {"Create recurring event test", fun test_create_recurring/0}, + {"Materialize occurrence test", fun test_materialize_occurrence/0}, + {"Materialize existing occurrence test", fun test_materialize_existing/0}, + {"Materialize non-recurring test", fun test_materialize_non_recurring/0} + ]}. + +test_create_recurring() -> + CalendarId = <<"calendar123">>, + Title = <<"Weekly Meeting">>, + StartTime = {{2026, 4, 20}, {10, 0, 0}}, + Duration = 60, + RRule = #{<<"freq">> => <<"WEEKLY">>, <<"interval">> => 1}, + + {ok, Event} = core_event:create_recurring(CalendarId, Title, StartTime, Duration, RRule), + + ?assertEqual(CalendarId, Event#event.calendar_id), + ?assertEqual(Title, Event#event.title), + ?assertEqual(recurring, Event#event.event_type), + ?assertEqual(false, Event#event.is_instance), + ?assert(is_binary(Event#event.recurrence_rule)), + + % Проверяем, что правило сохранилось + Decoded = jsx:decode(Event#event.recurrence_rule, [return_maps]), + RRuleMap = case Decoded of + Map when is_map(Map) -> Map; + {ok, Map} -> Map + end, + ?assertEqual(<<"WEEKLY">>, maps:get(<<"freq">>, RRuleMap)). + +test_materialize_occurrence() -> + CalendarId = <<"calendar123">>, + MasterId = create_recurring_event(CalendarId), + OccurrenceStart = {{2026, 4, 27}, {10, 0, 0}}, + SpecialistId = <<"specialist123">>, + + {ok, Instance} = core_event:materialize_occurrence(MasterId, OccurrenceStart, SpecialistId), + + ?assertEqual(CalendarId, Instance#event.calendar_id), + ?assertEqual(MasterId, Instance#event.master_id), + ?assertEqual(OccurrenceStart, Instance#event.start_time), + ?assertEqual(SpecialistId, Instance#event.specialist_id), + ?assertEqual(single, Instance#event.event_type), + ?assertEqual(true, Instance#event.is_instance). + +test_materialize_existing() -> + CalendarId = <<"calendar123">>, + MasterId = create_recurring_event(CalendarId), + OccurrenceStart = {{2026, 4, 27}, {10, 0, 0}}, + SpecialistId1 = <<"specialist1">>, + SpecialistId2 = <<"specialist2">>, + + % Первая материализация + {ok, Instance1} = core_event:materialize_occurrence(MasterId, OccurrenceStart, SpecialistId1), + + % Вторая материализация - должна вернуть существующий экземпляр + {ok, Instance2} = core_event:materialize_occurrence(MasterId, OccurrenceStart, SpecialistId2), + + ?assertEqual(Instance1#event.id, Instance2#event.id), + ?assertEqual(SpecialistId1, Instance2#event.specialist_id). + +test_materialize_non_recurring() -> + CalendarId = <<"calendar123">>, + {ok, SingleEvent} = core_event:create(CalendarId, <<"Single">>, {{2026, 4, 20}, {10, 0, 0}}, 60), + + {error, not_recurring} = core_event:materialize_occurrence( + SingleEvent#event.id, {{2026, 4, 27}, {10, 0, 0}}, <<"spec">> + ). + +create_recurring_event(CalendarId) -> + {ok, Event} = core_event:create_recurring( + CalendarId, + <<"Weekly Meeting">>, + {{2026, 4, 20}, {10, 0, 0}}, + 60, + #{<<"freq">> => <<"WEEKLY">>, <<"interval">> => 1} + ), + Event#event.id. \ No newline at end of file diff --git a/test/logic_event_recurring_tests.erl b/test/logic_event_recurring_tests.erl new file mode 100644 index 0000000..70b0135 --- /dev/null +++ b/test/logic_event_recurring_tests.erl @@ -0,0 +1,155 @@ +-module(logic_event_recurring_tests). +-include_lib("eunit/include/eunit.hrl"). +-include("records.hrl"). + +setup() -> + mnesia:start(), + mnesia:create_table(user, [ + {attributes, record_info(fields, user)}, + {ram_copies, [node()]} + ]), + mnesia:create_table(calendar, [ + {attributes, record_info(fields, calendar)}, + {ram_copies, [node()]} + ]), + mnesia:create_table(event, [ + {attributes, record_info(fields, event)}, + {ram_copies, [node()]} + ]), + mnesia:create_table(recurrence_exception, [ + {attributes, record_info(fields, recurrence_exception)}, + {ram_copies, [node()]} + ]), + ok. + +cleanup(_) -> + mnesia:delete_table(recurrence_exception), + mnesia:delete_table(event), + mnesia:delete_table(calendar), + mnesia:delete_table(user), + mnesia:stop(), + ok. + +logic_event_recurring_test_() -> + {foreach, + fun setup/0, + fun cleanup/1, + [ + {"Create recurring event test", fun test_create_recurring_event/0}, + {"Create recurring event invalid RRULE test", fun test_create_recurring_invalid/0}, + {"Get occurrences test", fun test_get_occurrences/0}, + {"Cancel occurrence test", fun test_cancel_occurrence/0}, + {"Get occurrences with cancelled test", fun test_occurrences_with_cancelled/0}, + {"Materialize for booking test", fun test_materialize_for_booking/0} + ]}. + +create_test_user_and_calendar() -> + UserId = base64:encode(crypto:strong_rand_bytes(16), #{mode => urlsafe, padding => false}), + User = #user{ + id = UserId, + email = <<"test@example.com">>, + password_hash = <<"hash">>, + role = user, + status = active, + created_at = calendar:universal_time(), + updated_at = calendar:universal_time() + }, + mnesia:dirty_write(User), + + {ok, Calendar} = logic_calendar:create_calendar(UserId, <<"Test Calendar">>, <<"">>), + {UserId, Calendar#calendar.id}. + +test_create_recurring_event() -> + {UserId, CalendarId} = create_test_user_and_calendar(), + Title = <<"Weekly Meeting">>, + StartTime = {{2026, 5, 1}, {10, 0, 0}}, + Duration = 60, + RRule = #{<<"freq">> => <<"WEEKLY">>, <<"interval">> => 1}, + + {ok, Event} = logic_event:create_recurring_event(UserId, CalendarId, Title, StartTime, Duration, RRule), + + ?assertEqual(CalendarId, Event#event.calendar_id), + ?assertEqual(Title, Event#event.title), + ?assertEqual(recurring, Event#event.event_type). + +test_create_recurring_invalid() -> + {UserId, CalendarId} = create_test_user_and_calendar(), + Title = <<"Invalid">>, + StartTime = {{2026, 5, 1}, {10, 0, 0}}, + Duration = 60, + InvalidRRule = #{<<"freq">> => <<"YEARLY">>, <<"interval">> => 1}, + + {error, invalid_rrule} = logic_event:create_recurring_event( + UserId, CalendarId, Title, StartTime, Duration, InvalidRRule + ). + +test_get_occurrences() -> + {UserId, CalendarId} = create_test_user_and_calendar(), + StartTime = {{2026, 6, 1}, {10, 0, 0}}, % Июнь 2026 + RRule = #{<<"freq">> => <<"WEEKLY">>, <<"interval">> => 1}, + + {ok, Event} = logic_event:create_recurring_event( + UserId, CalendarId, <<"Weekly">>, StartTime, 60, RRule + ), + + RangeEnd = {{2026, 6, 29}, {10, 0, 0}}, % Конец июня + {ok, Occurrences} = logic_event:get_occurrences(UserId, Event#event.id, RangeEnd), + + % 1, 8, 15, 22, 29 июня = 5 вхождений + ?assertEqual(5, length(Occurrences)). + +test_cancel_occurrence() -> + {UserId, CalendarId} = create_test_user_and_calendar(), + StartTime = {{2026, 6, 1}, {10, 0, 0}}, + RRule = #{<<"freq">> => <<"WEEKLY">>, <<"interval">> => 1}, + + {ok, Event} = logic_event:create_recurring_event( + UserId, CalendarId, <<"Weekly">>, StartTime, 60, RRule + ), + + CancelTime = {{2026, 6, 8}, {10, 0, 0}}, % Второе вхождение + {ok, cancelled} = logic_event:cancel_occurrence(UserId, Event#event.id, CancelTime). + +test_occurrences_with_cancelled() -> + {UserId, CalendarId} = create_test_user_and_calendar(), + StartTime = {{2026, 6, 1}, {10, 0, 0}}, + RRule = #{<<"freq">> => <<"WEEKLY">>, <<"interval">> => 1}, + + {ok, Event} = logic_event:create_recurring_event( + UserId, CalendarId, <<"Weekly">>, StartTime, 60, RRule + ), + + % Отменяем второе вхождение + CancelTime = {{2026, 6, 8}, {10, 0, 0}}, + {ok, cancelled} = logic_event:cancel_occurrence(UserId, Event#event.id, CancelTime), + + RangeEnd = {{2026, 6, 29}, {10, 0, 0}}, + {ok, Occurrences} = logic_event:get_occurrences(UserId, Event#event.id, RangeEnd), + + % 1, 15, 22, 29 июня = 4 вхождения (одно отменено) + ?assertEqual(4, length(Occurrences)), + + % Проверяем, что отменённого вхождения нет + Starts = [O || {virtual, O} <- Occurrences], + ?assertNot(lists:member(CancelTime, Starts)). + +test_materialize_for_booking() -> + {UserId, CalendarId} = create_test_user_and_calendar(), + StartTime = {{2026, 6, 1}, {10, 0, 0}}, + RRule = #{<<"freq">> => <<"WEEKLY">>, <<"interval">> => 1}, + + {ok, Event} = logic_event:create_recurring_event( + UserId, CalendarId, <<"Weekly">>, StartTime, 60, RRule + ), + + OccurrenceStart = {{2026, 6, 8}, {10, 0, 0}}, + SpecialistId = <<"specialist123">>, + + {ok, Instance} = logic_event:materialize_for_booking( + Event#event.id, OccurrenceStart, SpecialistId + ), + + ?assertEqual(Event#event.id, Instance#event.master_id), + ?assertEqual(OccurrenceStart, Instance#event.start_time), + ?assertEqual(SpecialistId, Instance#event.specialist_id), + ?assertEqual(true, Instance#event.is_instance). \ No newline at end of file diff --git a/test/logic_recurrence_tests.erl b/test/logic_recurrence_tests.erl new file mode 100644 index 0000000..b670364 --- /dev/null +++ b/test/logic_recurrence_tests.erl @@ -0,0 +1,110 @@ +-module(logic_recurrence_tests). +-include_lib("eunit/include/eunit.hrl"). + +logic_recurrence_test_() -> + [ + {"Parse RRULE test", fun test_parse_rrule/0}, + {"Validate RRULE test", fun test_validate_rrule/0}, + {"Generate daily occurrences test", fun test_daily_occurrences/0}, + {"Generate weekly occurrences test", fun test_weekly_occurrences/0}, + {"Generate monthly occurrences test", fun test_monthly_occurrences/0}, + {"Generate with count limit test", fun test_count_limit/0}, + {"Generate with until limit test", fun test_until_limit/0} + ]. + +test_parse_rrule() -> + RRule = #{ + <<"freq">> => <<"WEEKLY">>, + <<"interval">> => 2, + <<"until">> => <<"2026-12-31T00:00:00Z">>, + <<"byday">> => [<<"MO">>, <<"WE">>, <<"FR">>] + }, + + {ok, Parsed} = logic_recurrence:parse_rrule(RRule), + ?assertEqual(<<"WEEKLY">>, maps:get(freq, Parsed)), + ?assertEqual(2, maps:get(interval, Parsed)), + ?assertEqual(<<"2026-12-31T00:00:00Z">>, maps:get(until, Parsed)), + ?assertEqual([<<"MO">>, <<"WE">>, <<"FR">>], maps:get(byday, Parsed)). + +test_validate_rrule() -> + ValidRule = #{ + freq => <<"DAILY">>, + interval => 1 + }, + ?assert(logic_recurrence:validate_rrule(ValidRule)), + + InvalidFreq = #{ + freq => <<"YEARLY">>, + interval => 1 + }, + ?assertNot(logic_recurrence:validate_rrule(InvalidFreq)), + + InvalidInterval = #{ + freq => <<"DAILY">>, + interval => 0 + }, + ?assertNot(logic_recurrence:validate_rrule(InvalidInterval)). + +test_daily_occurrences() -> + StartTime = {{2026, 4, 20}, {10, 0, 0}}, + RRule = #{ + freq => <<"DAILY">>, + interval => 1 + }, + RangeEnd = {{2026, 4, 25}, {10, 0, 0}}, + + Occurrences = logic_recurrence:generate_occurrences(StartTime, RRule, RangeEnd), + ?assertEqual(6, length(Occurrences)), + ?assertEqual({{2026, 4, 20}, {10, 0, 0}}, lists:nth(1, Occurrences)), + ?assertEqual({{2026, 4, 25}, {10, 0, 0}}, lists:nth(6, Occurrences)). + +test_weekly_occurrences() -> + StartTime = {{2026, 4, 20}, {10, 0, 0}}, % Monday + RRule = #{ + freq => <<"WEEKLY">>, + interval => 1 + }, + RangeEnd = {{2026, 5, 11}, {10, 0, 0}}, + + Occurrences = logic_recurrence:generate_occurrences(StartTime, RRule, RangeEnd), + ?assertEqual(4, length(Occurrences)), + ?assertEqual({{2026, 4, 20}, {10, 0, 0}}, lists:nth(1, Occurrences)), + ?assertEqual({{2026, 5, 11}, {10, 0, 0}}, lists:nth(4, Occurrences)). + +test_monthly_occurrences() -> + StartTime = {{2026, 4, 20}, {10, 0, 0}}, + RRule = #{ + freq => <<"MONTHLY">>, + interval => 1 + }, + RangeEnd = {{2026, 7, 20}, {10, 0, 0}}, + + Occurrences = logic_recurrence:generate_occurrences(StartTime, RRule, RangeEnd), + ?assertEqual(4, length(Occurrences)), + ?assertEqual({{2026, 4, 20}, {10, 0, 0}}, lists:nth(1, Occurrences)), + ?assertEqual({{2026, 7, 20}, {10, 0, 0}}, lists:nth(4, Occurrences)). + +test_count_limit() -> + StartTime = {{2026, 4, 20}, {10, 0, 0}}, + RRule = #{ + freq => <<"DAILY">>, + interval => 1, + count => 3 + }, + RangeEnd = {{2026, 12, 31}, {10, 0, 0}}, + + Occurrences = logic_recurrence:generate_occurrences(StartTime, RRule, RangeEnd), + ?assertEqual(3, length(Occurrences)). + +test_until_limit() -> + StartTime = {{2026, 4, 20}, {10, 0, 0}}, + RRule = #{ + freq => <<"DAILY">>, + interval => 1, + until => <<"2026-04-22T10:00:00Z">> + }, + RangeEnd = {{2026, 12, 31}, {10, 0, 0}}, + + Occurrences = logic_recurrence:generate_occurrences(StartTime, RRule, RangeEnd), + % 20, 21, 22 апреля = 3 дня + ?assertEqual(3, length(Occurrences)). \ No newline at end of file