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">>);
{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} ->
+2
View File
@@ -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}} ->
+88 -32
View File
@@ -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 ->
@@ -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.