From 32d3e7378ee8c5e13817a109ca5f576d712018e3 Mon Sep 17 00:00:00 2001 From: Aleksey Sabilin Date: Thu, 30 Jul 2026 13:07:13 +0300 Subject: [PATCH] fix(event): store multiple recurrence cancel exceptions Use bag table for recurrence_exception so canceling several WEEKLY occurrences keeps all exceptions (set key was only master_id). Refs EventHub/EventHubFront#42 --- src/infra/infra_mnesia.erl | 4 +- src/infra/migration_engine.erl | 3 +- ...0260730120000_recurrence_exception_bag.erl | 45 +++++++++++++++++++ test/unit/eh_test_support.erl | 2 +- test/unit/logic_event_recurring_tests.erl | 23 ++++++++++ 5 files changed, 74 insertions(+), 3 deletions(-) create mode 100644 src/migrations/20260730120000_recurrence_exception_bag.erl diff --git a/src/infra/infra_mnesia.erl b/src/infra/infra_mnesia.erl index 1ac0f19..e85e9b7 100755 --- a/src/infra/infra_mnesia.erl +++ b/src/infra/infra_mnesia.erl @@ -321,7 +321,9 @@ table_opts(calendar_follow) -> [{disc_copies, [node()]}, {attributes, record_inf table_opts(calendar_specialist) -> [{disc_copies, [node()]}, {attributes, record_info(fields, calendar_specialist)}]; table_opts(specialist_invite) -> [{disc_copies, [node()]}, {attributes, record_info(fields, specialist_invite)}]; table_opts(event) -> [{disc_copies, [node()]}, {attributes, record_info(fields, event)}]; -table_opts(recurrence_exception) -> [{disc_copies, [node()]}, {attributes, record_info(fields, recurrence_exception)}]; +%% bag: несколько cancel/reschedule на один master_id (ключ set = только master_id). +table_opts(recurrence_exception) -> + [{disc_copies, [node()]}, {type, bag}, {attributes, record_info(fields, recurrence_exception)}]; table_opts(booking) -> [{disc_copies, [node()]}, {attributes, record_info(fields, booking)}]; table_opts(review) -> [{disc_copies, [node()]}, {attributes, record_info(fields, review)}]; table_opts(review_vote) -> [{disc_copies, [node()]}, {attributes, record_info(fields, review_vote)}]; diff --git a/src/infra/migration_engine.erl b/src/infra/migration_engine.erl index 81c870c..f442ddb 100755 --- a/src/infra/migration_engine.erl +++ b/src/infra/migration_engine.erl @@ -27,7 +27,8 @@ '20260719210000_review_vote', '20260720210000_calendar_follow', '20260722150000_calendar_specialist_id', - '20260722190000_specialist_invite' + '20260722190000_specialist_invite', + '20260730120000_recurrence_exception_bag' ]). %% ------------------------------ diff --git a/src/migrations/20260730120000_recurrence_exception_bag.erl b/src/migrations/20260730120000_recurrence_exception_bag.erl new file mode 100644 index 0000000..9b0292a --- /dev/null +++ b/src/migrations/20260730120000_recurrence_exception_bag.erl @@ -0,0 +1,45 @@ +%% @doc recurrence_exception: set → bag, чтобы хранить несколько cancel на один master. +-module('20260730120000_recurrence_exception_bag'). + +-export([up/0, down/0]). + +-include("records.hrl"). + +up() -> + case lists:member(recurrence_exception, mnesia:system_info(tables)) of + false -> + case mnesia:create_table(recurrence_exception, [ + {disc_copies, [node()]}, + {type, bag}, + {attributes, record_info(fields, recurrence_exception)} + ]) of + {atomic, ok} -> ok; + {aborted, {already_exists, recurrence_exception}} -> ok; + {aborted, Reason} -> error({create_table_failed, Reason}) + end; + true -> + case mnesia:table_info(recurrence_exception, type) of + bag -> + ok; + _ -> + Rows = mnesia:dirty_match_object(#recurrence_exception{_ = '_'}), + case mnesia:delete_table(recurrence_exception) of + {atomic, ok} -> ok; + {aborted, Reason1} -> error({delete_table_failed, Reason1}) + end, + case mnesia:create_table(recurrence_exception, [ + {disc_copies, [node()]}, + {type, bag}, + {attributes, record_info(fields, recurrence_exception)} + ]) of + {atomic, ok} -> ok; + {aborted, Reason2} -> error({create_table_failed, Reason2}) + end, + lists:foreach(fun(R) -> mnesia:dirty_write(R) end, Rows), + ok + end + end. + +down() -> + %% Откат в set потерял бы все кроме одного exception на master — не делаем. + ok. diff --git a/test/unit/eh_test_support.erl b/test/unit/eh_test_support.erl index a2075c7..e8835be 100755 --- a/test/unit/eh_test_support.erl +++ b/test/unit/eh_test_support.erl @@ -137,7 +137,7 @@ table_opts(specialist_invite) -> table_opts(event) -> [{ram_copies, [node()]}, {attributes, record_info(fields, event)}]; table_opts(recurrence_exception) -> - [{ram_copies, [node()]}, {attributes, record_info(fields, recurrence_exception)}]; + [{ram_copies, [node()]}, {type, bag}, {attributes, record_info(fields, recurrence_exception)}]; table_opts(booking) -> [{ram_copies, [node()]}, {attributes, record_info(fields, booking)}]; table_opts(review) -> diff --git a/test/unit/logic_event_recurring_tests.erl b/test/unit/logic_event_recurring_tests.erl index a9dbf26..103dc87 100644 --- a/test/unit/logic_event_recurring_tests.erl +++ b/test/unit/logic_event_recurring_tests.erl @@ -24,6 +24,7 @@ logic_event_recurring_test_() -> {"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}, + {"Cancel multiple occurrences keeps all exceptions", fun test_cancel_multiple_occurrences/0}, {"Materialize for booking test", fun test_materialize_for_booking/0} ]}. @@ -117,6 +118,28 @@ test_occurrences_with_cancelled() -> Starts = [O || {virtual, O} <- Occurrences], ?assertNot(lists:member(CancelTime, Starts)). +test_cancel_multiple_occurrences() -> + {UserId, CalendarId} = create_test_user_and_calendar(), + StartTime = eh_test_support:future_start(), + RRule = #{<<"freq">> => <<"WEEKLY">>, <<"interval">> => 1, <<"count">> => 4}, + + {ok, Event} = logic_event:create_recurring_event( + UserId, CalendarId, <<"WeeklyFill">>, StartTime, 60, RRule + ), + + C1 = StartTime, + C2 = add_days(StartTime, 7), + C3 = add_days(StartTime, 14), + {ok, cancelled} = logic_event:cancel_occurrence(UserId, Event#event.id, C1), + {ok, cancelled} = logic_event:cancel_occurrence(UserId, Event#event.id, C2), + {ok, cancelled} = logic_event:cancel_occurrence(UserId, Event#event.id, C3), + + RangeEnd = add_days(StartTime, 28), + {ok, Occurrences} = logic_event:get_occurrences(UserId, Event#event.id, RangeEnd), + Starts = [O || {virtual, O} <- Occurrences], + ?assertEqual(1, length(Starts)), + ?assertEqual([add_days(StartTime, 21)], Starts). + test_materialize_for_booking() -> {UserId, CalendarId} = create_test_user_and_calendar(), StartTime = eh_test_support:future_start(),