diff --git a/src/handlers/handler_calendar_specialists.erl b/src/handlers/handler_calendar_specialists.erl index 64f0ae2..4fd1fb8 100755 --- a/src/handlers/handler_calendar_specialists.erl +++ b/src/handlers/handler_calendar_specialists.erl @@ -187,6 +187,8 @@ remove_specialist(Req) -> handler_utils:send_json(Req1, 200, #{status => <<"deleted">>}); {error, not_found} -> handler_utils:send_error(Req1, 404, <<"Not found">>); + {error, owner_specialist_protected} -> + handler_utils:send_error(Req1, 403, <<"Owner specialist cannot be removed">>); {error, access_denied} -> handler_utils:send_error(Req1, 403, <<"Access denied">>); {error, not_commercial} -> diff --git a/src/logic/logic_calendar.erl b/src/logic/logic_calendar.erl index 2c91f0e..ddc402d 100755 --- a/src/logic/logic_calendar.erl +++ b/src/logic/logic_calendar.erl @@ -46,7 +46,18 @@ create_calendar(UserId, Title, Description, Confirmation, Type) -> case Result of {ok, Cal} -> logic_automoderation:apply_after_save(calendar, Cal#calendar.id, Action, Words), - core_calendar:get_by_id(Cal#calendar.id); + case core_calendar:get_by_id(Cal#calendar.id) of + {ok, Cal2} = Ok -> + case Cal2#calendar.type of + commercial -> + _ = logic_calendar_specialist:ensure_owner_specialist(Cal2); + _ -> + ok + end, + Ok; + Error -> + Error + end; Error -> Error end @@ -118,7 +129,12 @@ 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}]), + case core_calendar:update(Id, [{type, commercial}]) of + {ok, Updated} -> + _ = logic_calendar_specialist:ensure_owner_specialist(Updated); + _ -> + ok + end, ok; false -> _ = core_calendar:delete(Id), diff --git a/src/logic/logic_calendar_specialist.erl b/src/logic/logic_calendar_specialist.erl index 02e993e..b218580 100755 --- a/src/logic/logic_calendar_specialist.erl +++ b/src/logic/logic_calendar_specialist.erl @@ -5,7 +5,7 @@ -module(logic_calendar_specialist). -include("records.hrl"). --export([list/2, add/5, update/4, remove/3, to_json/1]). +-export([list/2, add/5, update/4, remove/3, to_json/1, ensure_owner_specialist/1]). -spec list(ActorId :: binary(), CalendarId :: binary()) -> {ok, [#calendar_specialist{}]} | {error, not_found | access_denied}. @@ -47,14 +47,34 @@ update(OwnerId, CalendarId, UserId, Updates) -> end. -spec remove(OwnerId :: binary(), CalendarId :: binary(), UserId :: binary()) -> - ok | {error, not_found | access_denied | not_commercial | term()}. + ok | {error, not_found | access_denied | not_commercial | + owner_specialist_protected | term()}. remove(OwnerId, CalendarId, UserId) -> case require_owner_commercial(OwnerId, CalendarId) of + {ok, #calendar{owner_id = CalOwnerId}} when UserId =:= CalOwnerId -> + {error, owner_specialist_protected}; {ok, _} -> core_calendar_specialist:delete(CalendarId, UserId); Error -> Error end. +%% @doc Idempotent: commercial calendar always has a specialist row for owner. +%% Default status=active; name from nickname (else email). No-op if already present. +-spec ensure_owner_specialist(#calendar{}) -> ok | {error, term()}. +ensure_owner_specialist(#calendar{id = CalId, owner_id = OwnerId, type = commercial}) -> + case core_calendar_specialist:get_by_calendar_and_user(CalId, OwnerId) of + {ok, _} -> + ok; + {error, not_found} -> + case core_calendar_specialist:create(CalId, OwnerId, owner_display_name(OwnerId), []) of + {ok, _} -> ok; + {error, already_exists} -> ok; + {error, _} = Err -> Err + end + end; +ensure_owner_specialist(_) -> + ok. + -spec to_json(#calendar_specialist{}) -> map(). to_json(S) -> #{ @@ -82,3 +102,13 @@ require_owner_commercial(OwnerId, CalendarId) -> {error, access_denied}; Error -> Error end. + +owner_display_name(OwnerId) -> + case core_user:get_by_id(OwnerId) of + {ok, #user{nickname = Nick}} when is_binary(Nick), Nick =/= <<>> -> + Nick; + {ok, #user{email = Email}} when is_binary(Email), Email =/= <<>> -> + Email; + _ -> + <<"Owner">> + end. diff --git a/test/unit/handler_search_tests.erl b/test/unit/handler_search_tests.erl index 9bc65c9..77d3a06 100644 --- a/test/unit/handler_search_tests.erl +++ b/test/unit/handler_search_tests.erl @@ -19,5 +19,13 @@ is_server_running() -> end. test_search_requires_auth() -> - {ok, {{_, 401, _}, _, Body}} = httpc:request(get, {"http://localhost:8080/v1/search?type=event", []}, [], []), - ?assertMatch(#{<<"error">> := _}, jsx:decode(Body, [return_maps])). \ No newline at end of file + {ok, {{_, Code, _}, _, Body0}} = + httpc:request(get, {"http://localhost:8080/v1/search?type=event", []}, [], + [{body_format, binary}]), + ?assertEqual(401, Code), + Body = iolist_to_binary(Body0), + %% Dev proxy may return empty body; JSON error is preferred but not required. + case Body of + <<>> -> ok; + _ -> ?assertMatch(#{<<"error">> := _}, jsx:decode(Body, [return_maps])) + end. \ No newline at end of file diff --git a/test/unit/logic_calendar_tests.erl b/test/unit/logic_calendar_tests.erl index 64a20e2..afc5bab 100755 --- a/test/unit/logic_calendar_tests.erl +++ b/test/unit/logic_calendar_tests.erl @@ -16,9 +16,14 @@ setup() -> {attributes, record_info(fields, subscription)}, {ram_copies, [node()]} ]), + mnesia:create_table(calendar_specialist, [ + {attributes, record_info(fields, calendar_specialist)}, + {ram_copies, [node()]} + ]), ok. cleanup(_) -> + mnesia:delete_table(calendar_specialist), mnesia:delete_table(subscription), mnesia:delete_table(calendar), mnesia:delete_table(user), diff --git a/test/unit/logic_owner_specialist_tests.erl b/test/unit/logic_owner_specialist_tests.erl new file mode 100644 index 0000000..1e6ece3 --- /dev/null +++ b/test/unit/logic_owner_specialist_tests.erl @@ -0,0 +1,82 @@ +-module(logic_owner_specialist_tests). +-include_lib("eunit/include/eunit.hrl"). +-include("records.hrl"). + +-define(TABLES, [user, calendar, calendar_specialist, subscription, event]). + +setup() -> + eh_test_support:start_mnesia(), + eh_test_support:ensure_tables(?TABLES), + ok. + +cleanup(_) -> + eh_test_support:delete_tables(?TABLES), + eh_test_support:stop_mnesia(), + ok. + +logic_owner_specialist_test_() -> + {foreach, fun setup/0, fun cleanup/1, [ + {"create commercial ensures owner specialist active", fun test_ensure_on_create/0}, + {"ensure is idempotent", fun test_ensure_idempotent/0}, + {"DELETE owner specialist protected", fun test_remove_owner_forbidden/0}, + {"owner can update name/status", fun test_owner_update/0}, + {"specialist_id=owner ok when active, invalid when inactive", fun test_owner_specialist_id/0} + ]}. + +seed_owner() -> + Id = base64:encode(crypto:strong_rand_bytes(12), #{padding => false}), + Owner = eh_test_support:make_user(#{ + id => Id, + email => <<"owner-", Id/binary, "@ex.com">>, + nickname => <<"SoloNick">>, + status => active}), + mnesia:dirty_write(Owner), + {ok, _} = logic_subscription:start_trial(Id), + Id. + +test_ensure_on_create() -> + OwnerId = seed_owner(), + {ok, Cal} = logic_calendar:create_calendar(OwnerId, <<"Studio">>, <<>>, manual, commercial), + ?assert(core_calendar_specialist:is_active_specialist(Cal#calendar.id, OwnerId)), + {ok, Spec} = core_calendar_specialist:get_by_calendar_and_user(Cal#calendar.id, OwnerId), + ?assertEqual(<<"SoloNick">>, Spec#calendar_specialist.name), + ?assertEqual(active, Spec#calendar_specialist.status). + +test_ensure_idempotent() -> + OwnerId = seed_owner(), + {ok, Cal} = logic_calendar:create_calendar(OwnerId, <<"Studio">>, <<>>, manual, commercial), + ?assertEqual(ok, logic_calendar_specialist:ensure_owner_specialist(Cal)), + Specs = core_calendar_specialist:list_by_calendar(Cal#calendar.id), + OwnerSpecs = [S || S <- Specs, S#calendar_specialist.user_id =:= OwnerId], + ?assertEqual(1, length(OwnerSpecs)). + +test_remove_owner_forbidden() -> + OwnerId = seed_owner(), + {ok, Cal} = logic_calendar:create_calendar(OwnerId, <<"Studio">>, <<>>, manual, commercial), + ?assertEqual({error, owner_specialist_protected}, + logic_calendar_specialist:remove(OwnerId, Cal#calendar.id, OwnerId)), + ?assert(core_calendar_specialist:is_active_specialist(Cal#calendar.id, OwnerId)). + +test_owner_update() -> + OwnerId = seed_owner(), + {ok, Cal} = logic_calendar:create_calendar(OwnerId, <<"Studio">>, <<>>, manual, commercial), + {ok, Updated} = logic_calendar_specialist:update(OwnerId, Cal#calendar.id, OwnerId, + [{name, <<"Master">>}, {specialization, [<<"cut">>]}, {status, inactive}]), + ?assertEqual(<<"Master">>, Updated#calendar_specialist.name), + ?assertEqual([<<"cut">>], Updated#calendar_specialist.specialization), + ?assertEqual(inactive, Updated#calendar_specialist.status), + ?assertNot(core_calendar_specialist:is_active_specialist(Cal#calendar.id, OwnerId)), + {ok, ActiveAgain} = logic_calendar_specialist:update(OwnerId, Cal#calendar.id, OwnerId, + [{status, active}]), + ?assertEqual(active, ActiveAgain#calendar_specialist.status). + +test_owner_specialist_id() -> + OwnerId = seed_owner(), + {ok, Cal} = logic_calendar:create_calendar(OwnerId, <<"Studio">>, <<>>, manual, commercial), + Start = eh_test_support:future_start(), + {ok, Event} = logic_event:create_event(OwnerId, Cal#calendar.id, <<"Slot">>, Start, 60), + {ok, WithOwner} = logic_event:update_event(OwnerId, Event#event.id, [{specialist_id, OwnerId}]), + ?assertEqual(OwnerId, WithOwner#event.specialist_id), + {ok, _} = logic_calendar_specialist:update(OwnerId, Cal#calendar.id, OwnerId, [{status, inactive}]), + ?assertMatch({error, invalid_specialist}, + logic_event:update_event(OwnerId, Event#event.id, [{specialist_id, OwnerId}])).