feat(calendar): single Default personal with guards and backfill
CI / test (push) Failing after 7m22s
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

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
This commit is contained in:
2026-07-30 22:07:15 +03:00
parent bb37909ffb
commit 10464de3ec
5 changed files with 157 additions and 45 deletions
+4
View File
@@ -152,6 +152,8 @@ update_calendar(Req) ->
handler_utils:send_error(Req2, 403, <<"Access denied">>); handler_utils:send_error(Req2, 403, <<"Access denied">>);
{error, subscription_required} -> {error, subscription_required} ->
handler_utils:send_error(Req2, 402, <<"Subscription required for commercial calendar">>); 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} -> {error, not_found} ->
handler_utils:send_error(Req2, 404, <<"Calendar not found">>); handler_utils:send_error(Req2, 404, <<"Calendar not found">>);
{error, {invalid_settings, Key}} when is_binary(Key) -> {error, {invalid_settings, Key}} when is_binary(Key) ->
@@ -176,6 +178,8 @@ delete_calendar(Req) ->
case logic_calendar:delete_calendar(UserId, CalendarId) of case logic_calendar:delete_calendar(UserId, CalendarId) of
{ok, _} -> {ok, _} ->
handler_utils:send_json(Req1, 200, #{status => <<"deleted">>}); handler_utils:send_json(Req1, 200, #{status => <<"deleted">>});
{error, default_calendar} ->
handler_utils:send_error(Req1, 403, <<"default_calendar">>);
{error, access_denied} -> {error, access_denied} ->
handler_utils:send_error(Req1, 403, <<"Access denied">>); handler_utils:send_error(Req1, 403, <<"Access denied">>);
{error, not_found} -> {error, not_found} ->
+2
View File
@@ -140,6 +140,8 @@ create_calendar(Req) ->
handler_utils:send_json(Req2, 201, Response); handler_utils:send_json(Req2, 201, Response);
{error, subscription_required} -> {error, subscription_required} ->
handler_utils:send_error(Req2, 402, <<"Subscription required for commercial calendar">>); 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} -> {error, user_inactive} ->
handler_utils:send_error(Req2, 403, <<"User account is not active">>); handler_utils:send_error(Req2, 403, <<"User account is not active">>);
{error, {content_banned, _Words}} -> {error, {content_banned, _Words}} ->
+88 -32
View File
@@ -2,11 +2,13 @@
-include("records.hrl"). -include("records.hrl").
-export([create_calendar/3, create_calendar/4, create_calendar/5, get_calendar/2, list_calendars/1, -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([can_access/2, can_edit/2, booking_open/1]).
-export([normalize_settings/1]). -export([normalize_settings/1]).
-export([admin_list_all/0, admin_get_by_id/1, admin_update/2, admin_delete/1]). -export([admin_list_all/0, admin_get_by_id/1, admin_update/2, admin_delete/1]).
-define(DEFAULT_PERSONAL_TITLE, <<"Default">>).
%% Создание календаря с политикой по умолчанию (manual) %% Создание календаря с политикой по умолчанию (manual)
create_calendar(UserId, Title, Description) -> create_calendar(UserId, Title, Description) ->
create_calendar(UserId, Title, Description, manual). create_calendar(UserId, Title, Description, manual).
@@ -34,7 +36,12 @@ create_calendar(UserId, Title, Description, Confirmation, Type) ->
{error, subscription_required} {error, subscription_required}
end; end;
personal -> 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, end,
case Result of case Result of
{ok, Cal} -> {ok, Cal} ->
@@ -51,42 +58,91 @@ create_calendar(UserId, Title, Description, Confirmation, Type) ->
{error, user_not_found} {error, user_not_found}
end. end.
%% @doc Создаёт дефолтный personal-календарь после активации пользователя. %% @doc Единственный personal владельца: title Default; лишние → commercial|delete.
%% Идемпотентно: если у владельца уже есть active personal — ok. %% Вызывается после verify; идемпотентно.
-spec ensure_default_calendar(UserId :: binary()) -> ok | {error, term()}. -spec ensure_default_calendar(UserId :: binary()) -> ok | {error, term()}.
ensure_default_calendar(UserId) -> ensure_default_calendar(UserId) ->
case has_active_personal_calendar(UserId) of normalize_owner_personals(UserId).
true ->
ok; %% @doc Backfill всех владельцев: ≤1 personal с title Default.
false -> -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 case core_user:get_by_id(UserId) of
{ok, User} -> {ok, #user{status = active}} ->
Title = default_calendar_title(User), case create_calendar(UserId, ?DEFAULT_PERSONAL_TITLE, <<>>, manual, personal) of
case create_calendar(UserId, Title, <<>>, manual, personal) of
{ok, _} -> ok; {ok, _} -> ok;
Error -> Error Error -> Error
end; end;
{ok, _} ->
{error, user_inactive};
Error -> Error ->
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. end.
has_active_personal_calendar(UserId) -> has_active_personal_calendar(UserId) ->
case core_calendar:list_by_owner(UserId) of active_personals(UserId) =/= [].
{ok, Calendars} ->
lists:any(
fun(#calendar{type = personal}) -> true;
(_) -> false
end,
Calendars);
_ ->
false
end.
default_calendar_title(#user{nickname = Nick}) when is_binary(Nick), byte_size(Nick) > 0 -> convert_or_delete_extra_personal(#calendar{id = Id} = Cal) ->
Nick; case calendar_has_content(Id) of
default_calendar_title(_) -> 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) -> get_calendar(UserId, CalendarId) ->
@@ -155,13 +211,11 @@ apply_calendar_update(CalendarId, Calendar, ValidUpdates) ->
end end
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 case lists:keyfind(type, 1, Updates) of
{type, commercial} -> {type, commercial} ->
case logic_subscription:can_create_commercial_calendar(UserId) of {error, default_calendar};
true -> {ok, Updates};
false -> {error, subscription_required}
end;
_ -> _ ->
{ok, Updates} {ok, Updates}
end; end;
@@ -200,9 +254,11 @@ apply_text_results(Updates, [], []) -> Updates;
apply_text_results(Updates, [F | Fs], [T | Ts]) -> apply_text_results(Updates, [F | Fs], [T | Ts]) ->
apply_text_results(lists:keystore(F, 1, Updates, {F, T}), Fs, Ts). apply_text_results(lists:keystore(F, 1, Updates, {F, T}), Fs, Ts).
%% Удаление календаря %% Удаление календаря (единственный personal удалять нельзя)
delete_calendar(UserId, CalendarId) -> delete_calendar(UserId, CalendarId) ->
case core_calendar:get_by_id(CalendarId) of case core_calendar:get_by_id(CalendarId) of
{ok, #calendar{type = personal}} ->
{error, default_calendar};
{ok, Calendar} -> {ok, Calendar} ->
case can_edit(UserId, Calendar) of case can_edit(UserId, Calendar) of
true -> true ->
@@ -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.
+52 -13
View File
@@ -41,7 +41,10 @@ logic_calendar_test_() ->
{"Reject invalid default_location shape", fun test_update_settings_invalid_location/0}, {"Reject invalid default_location shape", fun test_update_settings_invalid_location/0},
{"Delete calendar test", fun test_delete_calendar/0}, {"Delete calendar test", fun test_delete_calendar/0},
{"Access control test", fun test_access_control/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() -> create_test_user() ->
@@ -77,8 +80,6 @@ test_create_commercial_gate() ->
logic_calendar:create_calendar(UserId, <<"Studio">>, <<>>, manual, commercial)), logic_calendar:create_calendar(UserId, <<"Studio">>, <<>>, manual, commercial)),
{ok, Personal} = logic_calendar:create_calendar(UserId, <<"Personal">>, <<>>, manual, personal), {ok, Personal} = logic_calendar:create_calendar(UserId, <<"Personal">>, <<>>, manual, personal),
?assertEqual(personal, Personal#calendar.type), ?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, _} = logic_subscription:start_trial(UserId),
{ok, Commercial} = logic_calendar:create_calendar(UserId, <<"Studio">>, <<>>, manual, commercial), {ok, Commercial} = logic_calendar:create_calendar(UserId, <<"Studio">>, <<>>, manual, commercial),
?assertEqual(commercial, Commercial#calendar.type). ?assertEqual(commercial, Commercial#calendar.type).
@@ -101,17 +102,19 @@ test_get_calendar() ->
test_list_calendars() -> test_list_calendars() ->
UserId = create_test_user(), UserId = create_test_user(),
{ok, _} = logic_calendar:create_calendar(UserId, <<"Calendar 1">>, <<"">>, manual), {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), {ok, Calendars} = logic_calendar:list_calendars(UserId),
?assertEqual(2, length(Calendars)). ?assertEqual(2, length(Calendars)).
test_update_calendar() -> test_update_calendar() ->
UserId = create_test_user(), UserId = create_test_user(),
{ok, Calendar} = logic_calendar:create_calendar(UserId, <<"Original">>, <<"">>, manual),
{ok, _} = logic_subscription:start_trial(UserId), {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), {ok, Updated} = logic_calendar:update_calendar(UserId, Calendar#calendar.id, Updates),
?assertEqual(<<"Updated">>, Updated#calendar.title), ?assertEqual(<<"Updated">>, Updated#calendar.title),
?assertEqual(commercial, Updated#calendar.type), ?assertEqual(commercial, Updated#calendar.type),
@@ -216,12 +219,17 @@ test_update_settings_invalid_location() ->
test_delete_calendar() -> test_delete_calendar() ->
UserId = create_test_user(), 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), ?assertEqual(deleted, Deleted#calendar.status),
?assertMatch({error, access_denied},
?assertMatch({error, access_denied}, logic_calendar:get_calendar(UserId, Calendar#calendar.id)). logic_calendar:get_calendar(UserId, Commercial#calendar.id)).
test_access_control() -> test_access_control() ->
OwnerId = create_test_user(), OwnerId = create_test_user(),
@@ -234,7 +242,8 @@ test_access_control() ->
?assertNot(logic_calendar:can_access(OtherId, PersonalCalendar)), ?assertNot(logic_calendar:can_access(OtherId, PersonalCalendar)),
{ok, _} = logic_subscription:start_trial(OwnerId), {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)), ?assert(logic_calendar:can_access(OtherId, CommercialCalendar)),
?assertNot(logic_calendar:can_edit(OtherId, CommercialCalendar)), ?assertNot(logic_calendar:can_edit(OtherId, CommercialCalendar)),
@@ -249,9 +258,39 @@ test_ensure_default_calendar() ->
?assertEqual(1, length(Calendars)), ?assertEqual(1, length(Calendars)),
Cal = hd(Calendars), Cal = hd(Calendars),
?assertEqual(personal, Cal#calendar.type), ?assertEqual(personal, Cal#calendar.type),
?assertEqual(<<"Default">>, Cal#calendar.title),
?assertEqual(<<>>, Cal#calendar.short_name), ?assertEqual(<<>>, Cal#calendar.short_name),
?assertEqual(active, Cal#calendar.status), ?assertEqual(active, Cal#calendar.status),
?assert(byte_size(Cal#calendar.title) > 0),
ok = logic_calendar:ensure_default_calendar(UserId), ok = logic_calendar:ensure_default_calendar(UserId),
{ok, Calendars2} = logic_calendar:list_calendars(UserId), {ok, Calendars2} = logic_calendar:list_calendars(UserId),
?assertEqual(1, length(Calendars2)). ?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).