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
This commit is contained in:
@@ -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} ->
|
||||
|
||||
@@ -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}} ->
|
||||
|
||||
@@ -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.
|
||||
@@ -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)).
|
||||
?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).
|
||||
Reference in New Issue
Block a user