From 10464de3ec1c6d2a5b1aef1848def6f4a960eaec Mon Sep 17 00:00:00 2001 From: Aleksey Sabilin Date: Thu, 30 Jul 2026 22:07:15 +0300 Subject: [PATCH] feat(calendar): single Default personal with guards and backfill Ensure title Default; reject second personal and delete/type-change of the sole personal; migrate extras to commercial or soft-delete. Refs EventHub/EventHubBack#64 --- src/handlers/handler_calendar_by_id.erl | 4 + src/handlers/handler_calendars.erl | 2 + src/logic/logic_calendar.erl | 120 +++++++++++++----- ...20260730200000_single_default_personal.erl | 11 ++ test/unit/logic_calendar_tests.erl | 65 ++++++++-- 5 files changed, 157 insertions(+), 45 deletions(-) create mode 100644 src/migrations/20260730200000_single_default_personal.erl diff --git a/src/handlers/handler_calendar_by_id.erl b/src/handlers/handler_calendar_by_id.erl index 7919f59..925facf 100755 --- a/src/handlers/handler_calendar_by_id.erl +++ b/src/handlers/handler_calendar_by_id.erl @@ -152,6 +152,8 @@ update_calendar(Req) -> handler_utils:send_error(Req2, 403, <<"Access denied">>); {error, subscription_required} -> handler_utils:send_error(Req2, 402, <<"Subscription required for commercial calendar">>); + {error, default_calendar} -> + handler_utils:send_error(Req2, 403, <<"default_calendar">>); {error, not_found} -> handler_utils:send_error(Req2, 404, <<"Calendar not found">>); {error, {invalid_settings, Key}} when is_binary(Key) -> @@ -176,6 +178,8 @@ delete_calendar(Req) -> case logic_calendar:delete_calendar(UserId, CalendarId) of {ok, _} -> handler_utils:send_json(Req1, 200, #{status => <<"deleted">>}); + {error, default_calendar} -> + handler_utils:send_error(Req1, 403, <<"default_calendar">>); {error, access_denied} -> handler_utils:send_error(Req1, 403, <<"Access denied">>); {error, not_found} -> diff --git a/src/handlers/handler_calendars.erl b/src/handlers/handler_calendars.erl index 96d457a..a06e3ec 100644 --- a/src/handlers/handler_calendars.erl +++ b/src/handlers/handler_calendars.erl @@ -140,6 +140,8 @@ create_calendar(Req) -> handler_utils:send_json(Req2, 201, Response); {error, subscription_required} -> handler_utils:send_error(Req2, 402, <<"Subscription required for commercial calendar">>); + {error, personal_exists} -> + handler_utils:send_error(Req2, 409, <<"personal_exists">>); {error, user_inactive} -> handler_utils:send_error(Req2, 403, <<"User account is not active">>); {error, {content_banned, _Words}} -> diff --git a/src/logic/logic_calendar.erl b/src/logic/logic_calendar.erl index 7c5706c..2c91f0e 100755 --- a/src/logic/logic_calendar.erl +++ b/src/logic/logic_calendar.erl @@ -2,11 +2,13 @@ -include("records.hrl"). -export([create_calendar/3, create_calendar/4, create_calendar/5, get_calendar/2, list_calendars/1, - update_calendar/3, delete_calendar/2, ensure_default_calendar/1]). + update_calendar/3, delete_calendar/2, ensure_default_calendar/1, backfill_single_personal/0]). -export([can_access/2, can_edit/2, booking_open/1]). -export([normalize_settings/1]). -export([admin_list_all/0, admin_get_by_id/1, admin_update/2, admin_delete/1]). +-define(DEFAULT_PERSONAL_TITLE, <<"Default">>). + %% Создание календаря с политикой по умолчанию (manual) create_calendar(UserId, Title, Description) -> create_calendar(UserId, Title, Description, manual). @@ -34,7 +36,12 @@ create_calendar(UserId, Title, Description, Confirmation, Type) -> {error, subscription_required} end; personal -> - core_calendar:create(UserId, Title2, Desc2, Confirmation, Type) + case has_active_personal_calendar(UserId) of + true -> + {error, personal_exists}; + false -> + core_calendar:create(UserId, Title2, Desc2, Confirmation, Type) + end end, case Result of {ok, Cal} -> @@ -51,42 +58,91 @@ create_calendar(UserId, Title, Description, Confirmation, Type) -> {error, user_not_found} end. -%% @doc Создаёт дефолтный personal-календарь после активации пользователя. -%% Идемпотентно: если у владельца уже есть active personal — ok. +%% @doc Единственный personal владельца: title Default; лишние → commercial|delete. +%% Вызывается после verify; идемпотентно. -spec ensure_default_calendar(UserId :: binary()) -> ok | {error, term()}. ensure_default_calendar(UserId) -> - case has_active_personal_calendar(UserId) of - true -> - ok; - false -> + normalize_owner_personals(UserId). + +%% @doc Backfill всех владельцев: ≤1 personal с title Default. +-spec backfill_single_personal() -> ok. +backfill_single_personal() -> + Owners = lists:usort([ + C#calendar.owner_id + || C <- core_calendar:list_all(), + C#calendar.status =:= active, + C#calendar.type =:= personal + ]), + lists:foreach(fun(OwnerId) -> _ = normalize_owner_personals(OwnerId) end, Owners), + ok. + +-spec normalize_owner_personals(UserId :: binary()) -> ok | {error, term()}. +normalize_owner_personals(UserId) -> + Personals = active_personals(UserId), + case Personals of + [] -> case core_user:get_by_id(UserId) of - {ok, User} -> - Title = default_calendar_title(User), - case create_calendar(UserId, Title, <<>>, manual, personal) of + {ok, #user{status = active}} -> + case create_calendar(UserId, ?DEFAULT_PERSONAL_TITLE, <<>>, manual, personal) of {ok, _} -> ok; Error -> Error end; + {ok, _} -> + {error, user_inactive}; Error -> Error - end + end; + [Keep | Rest] -> + _ = core_calendar:update(Keep#calendar.id, [{title, ?DEFAULT_PERSONAL_TITLE}]), + lists:foreach(fun convert_or_delete_extra_personal/1, Rest), + ok + end. + +active_personals(UserId) -> + case core_calendar:list_by_owner(UserId) of + {ok, Calendars} -> + lists:sort( + fun(#calendar{created_at = A, id = IdA}, #calendar{created_at = B, id = IdB}) -> + {A, IdA} =< {B, IdB} + end, + [C || C <- Calendars, C#calendar.type =:= personal] + ); + _ -> + [] end. has_active_personal_calendar(UserId) -> - case core_calendar:list_by_owner(UserId) of - {ok, Calendars} -> - lists:any( - fun(#calendar{type = personal}) -> true; - (_) -> false - end, - Calendars); - _ -> - false - end. + active_personals(UserId) =/= []. -default_calendar_title(#user{nickname = Nick}) when is_binary(Nick), byte_size(Nick) > 0 -> - Nick; -default_calendar_title(_) -> - <<"Мой календарь">>. +convert_or_delete_extra_personal(#calendar{id = Id} = Cal) -> + case calendar_has_content(Id) of + true -> + %% System backfill: bypass subscription gate. + _ = core_calendar:update(Id, [{type, commercial}]), + ok; + false -> + _ = core_calendar:delete(Id), + ok + end, + Cal. + +calendar_has_content(CalendarId) -> + Events = + try + case core_event:list_by_calendar(CalendarId) of + {ok, Es} -> Es; + _ -> [] + end + catch + _:_ -> [] + end, + Specs = + try + core_calendar_specialist:list_by_calendar(CalendarId) + catch + _:_ -> [] + end, + Events =/= [] orelse Specs =/= []. %% Получение календаря с проверкой доступа get_calendar(UserId, CalendarId) -> @@ -155,13 +211,11 @@ apply_calendar_update(CalendarId, Calendar, ValidUpdates) -> end end. -gate_type_change(UserId, #calendar{type = personal}, Updates) -> +%% Единственный personal нельзя превратить в commercial (дневник пользователя). +gate_type_change(_UserId, #calendar{type = personal}, Updates) -> case lists:keyfind(type, 1, Updates) of {type, commercial} -> - case logic_subscription:can_create_commercial_calendar(UserId) of - true -> {ok, Updates}; - false -> {error, subscription_required} - end; + {error, default_calendar}; _ -> {ok, Updates} end; @@ -200,9 +254,11 @@ apply_text_results(Updates, [], []) -> Updates; apply_text_results(Updates, [F | Fs], [T | Ts]) -> apply_text_results(lists:keystore(F, 1, Updates, {F, T}), Fs, Ts). -%% Удаление календаря +%% Удаление календаря (единственный personal удалять нельзя) delete_calendar(UserId, CalendarId) -> case core_calendar:get_by_id(CalendarId) of + {ok, #calendar{type = personal}} -> + {error, default_calendar}; {ok, Calendar} -> case can_edit(UserId, Calendar) of true -> diff --git a/src/migrations/20260730200000_single_default_personal.erl b/src/migrations/20260730200000_single_default_personal.erl new file mode 100644 index 0000000..592f587 --- /dev/null +++ b/src/migrations/20260730200000_single_default_personal.erl @@ -0,0 +1,11 @@ +%% @doc Backfill: ≤1 personal per owner, title Default; extras → commercial | delete. +-module('20260730200000_single_default_personal'). + +-export([up/0, down/0]). + +up() -> + logic_calendar:backfill_single_personal(), + ok. + +down() -> + ok. diff --git a/test/unit/logic_calendar_tests.erl b/test/unit/logic_calendar_tests.erl index f9a31ca..64a20e2 100755 --- a/test/unit/logic_calendar_tests.erl +++ b/test/unit/logic_calendar_tests.erl @@ -41,7 +41,10 @@ logic_calendar_test_() -> {"Reject invalid default_location shape", fun test_update_settings_invalid_location/0}, {"Delete calendar test", fun test_delete_calendar/0}, {"Access control test", fun test_access_control/0}, - {"Ensure default calendar test", fun test_ensure_default_calendar/0} + {"Ensure default calendar test", fun test_ensure_default_calendar/0}, + {"Second personal rejected", fun test_personal_exists/0}, + {"Personal type change forbidden", fun test_default_calendar_type_change/0}, + {"Backfill extras to commercial or delete", fun test_backfill_extras/0} ]}. create_test_user() -> @@ -77,8 +80,6 @@ test_create_commercial_gate() -> logic_calendar:create_calendar(UserId, <<"Studio">>, <<>>, manual, commercial)), {ok, Personal} = logic_calendar:create_calendar(UserId, <<"Personal">>, <<>>, manual, personal), ?assertEqual(personal, Personal#calendar.type), - ?assertMatch({error, subscription_required}, - logic_calendar:update_calendar(UserId, Personal#calendar.id, [{type, commercial}])), {ok, _} = logic_subscription:start_trial(UserId), {ok, Commercial} = logic_calendar:create_calendar(UserId, <<"Studio">>, <<>>, manual, commercial), ?assertEqual(commercial, Commercial#calendar.type). @@ -101,17 +102,19 @@ test_get_calendar() -> test_list_calendars() -> UserId = create_test_user(), {ok, _} = logic_calendar:create_calendar(UserId, <<"Calendar 1">>, <<"">>, manual), - {ok, _} = logic_calendar:create_calendar(UserId, <<"Calendar 2">>, <<"">>, auto), + {ok, _} = logic_subscription:start_trial(UserId), + {ok, _} = logic_calendar:create_calendar(UserId, <<"Calendar 2">>, <<"">>, auto, commercial), {ok, Calendars} = logic_calendar:list_calendars(UserId), ?assertEqual(2, length(Calendars)). test_update_calendar() -> UserId = create_test_user(), - {ok, Calendar} = logic_calendar:create_calendar(UserId, <<"Original">>, <<"">>, manual), {ok, _} = logic_subscription:start_trial(UserId), + {ok, Calendar} = logic_calendar:create_calendar( + UserId, <<"Original">>, <<"">>, manual, commercial), - Updates = [{title, <<"Updated">>}, {type, commercial}, {confirmation, auto}], + Updates = [{title, <<"Updated">>}, {confirmation, auto}], {ok, Updated} = logic_calendar:update_calendar(UserId, Calendar#calendar.id, Updates), ?assertEqual(<<"Updated">>, Updated#calendar.title), ?assertEqual(commercial, Updated#calendar.type), @@ -216,12 +219,17 @@ test_update_settings_invalid_location() -> test_delete_calendar() -> UserId = create_test_user(), - {ok, Calendar} = logic_calendar:create_calendar(UserId, <<"Test">>, <<"">>, manual), + {ok, Personal} = logic_calendar:create_calendar(UserId, <<"Test">>, <<"">>, manual), + ?assertMatch({error, default_calendar}, + logic_calendar:delete_calendar(UserId, Personal#calendar.id)), - {ok, Deleted} = logic_calendar:delete_calendar(UserId, Calendar#calendar.id), + {ok, _} = logic_subscription:start_trial(UserId), + {ok, Commercial} = logic_calendar:create_calendar( + UserId, <<"Studio">>, <<"">>, manual, commercial), + {ok, Deleted} = logic_calendar:delete_calendar(UserId, Commercial#calendar.id), ?assertEqual(deleted, Deleted#calendar.status), - - ?assertMatch({error, access_denied}, logic_calendar:get_calendar(UserId, Calendar#calendar.id)). + ?assertMatch({error, access_denied}, + logic_calendar:get_calendar(UserId, Commercial#calendar.id)). test_access_control() -> OwnerId = create_test_user(), @@ -234,7 +242,8 @@ test_access_control() -> ?assertNot(logic_calendar:can_access(OtherId, PersonalCalendar)), {ok, _} = logic_subscription:start_trial(OwnerId), - {ok, CommercialCalendar} = logic_calendar:update_calendar(OwnerId, PersonalCalendar#calendar.id, [{type, commercial}]), + {ok, CommercialCalendar} = logic_calendar:create_calendar( + OwnerId, <<"Studio">>, <<"">>, manual, commercial), ?assert(logic_calendar:can_access(OtherId, CommercialCalendar)), ?assertNot(logic_calendar:can_edit(OtherId, CommercialCalendar)), @@ -249,9 +258,39 @@ test_ensure_default_calendar() -> ?assertEqual(1, length(Calendars)), Cal = hd(Calendars), ?assertEqual(personal, Cal#calendar.type), + ?assertEqual(<<"Default">>, Cal#calendar.title), ?assertEqual(<<>>, Cal#calendar.short_name), ?assertEqual(active, Cal#calendar.status), - ?assert(byte_size(Cal#calendar.title) > 0), ok = logic_calendar:ensure_default_calendar(UserId), {ok, Calendars2} = logic_calendar:list_calendars(UserId), - ?assertEqual(1, length(Calendars2)). \ No newline at end of file + ?assertEqual(1, length(Calendars2)), + ?assertEqual(<<"Default">>, (hd(Calendars2))#calendar.title). + +test_personal_exists() -> + UserId = create_test_user(), + {ok, _} = logic_calendar:create_calendar(UserId, <<"One">>, <<"">>, manual, personal), + ?assertMatch({error, personal_exists}, + logic_calendar:create_calendar(UserId, <<"Two">>, <<"">>, manual, personal)). + +test_default_calendar_type_change() -> + UserId = create_test_user(), + {ok, Personal} = logic_calendar:create_calendar(UserId, <<"One">>, <<"">>, manual, personal), + {ok, _} = logic_subscription:start_trial(UserId), + ?assertMatch({error, default_calendar}, + logic_calendar:update_calendar(UserId, Personal#calendar.id, [{type, commercial}])). + +test_backfill_extras() -> + UserId = create_test_user(), + {ok, Keep0} = core_calendar:create(UserId, <<"Old">>, <<"">>, manual, personal), + {ok, Extra0} = core_calendar:create(UserId, <<"Extra">>, <<"">>, manual, personal), + %% datetime resolution is 1s — pin order explicitly + mnesia:dirty_write(Keep0#calendar{created_at = {{2020, 1, 1}, {0, 0, 0}}}), + mnesia:dirty_write(Extra0#calendar{created_at = {{2020, 1, 2}, {0, 0, 0}}}), + ok = logic_calendar:backfill_single_personal(), + {ok, List} = logic_calendar:list_calendars(UserId), + Personals = [C || C <- List, C#calendar.type =:= personal], + ?assertEqual(1, length(Personals)), + ?assertEqual(Keep0#calendar.id, (hd(Personals))#calendar.id), + ?assertEqual(<<"Default">>, (hd(Personals))#calendar.title), + {ok, ExtraAfter} = core_calendar:get_by_id(Extra0#calendar.id), + ?assertEqual(deleted, ExtraAfter#calendar.status). \ No newline at end of file