feat(specialists): owner is non-deletable specialist on commercial create
Bootstrap calendar_specialist for owner (active); DELETE owner → 403; toggle via status. Fixes solo assign-to-slot. Refs EventHub/EventHubBack#66
This commit is contained in:
@@ -187,6 +187,8 @@ remove_specialist(Req) ->
|
|||||||
handler_utils:send_json(Req1, 200, #{status => <<"deleted">>});
|
handler_utils:send_json(Req1, 200, #{status => <<"deleted">>});
|
||||||
{error, not_found} ->
|
{error, not_found} ->
|
||||||
handler_utils:send_error(Req1, 404, <<"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} ->
|
{error, access_denied} ->
|
||||||
handler_utils:send_error(Req1, 403, <<"Access denied">>);
|
handler_utils:send_error(Req1, 403, <<"Access denied">>);
|
||||||
{error, not_commercial} ->
|
{error, not_commercial} ->
|
||||||
|
|||||||
@@ -46,7 +46,18 @@ create_calendar(UserId, Title, Description, Confirmation, Type) ->
|
|||||||
case Result of
|
case Result of
|
||||||
{ok, Cal} ->
|
{ok, Cal} ->
|
||||||
logic_automoderation:apply_after_save(calendar, Cal#calendar.id, Action, Words),
|
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 ->
|
||||||
Error
|
Error
|
||||||
end
|
end
|
||||||
@@ -118,7 +129,12 @@ convert_or_delete_extra_personal(#calendar{id = Id} = Cal) ->
|
|||||||
case calendar_has_content(Id) of
|
case calendar_has_content(Id) of
|
||||||
true ->
|
true ->
|
||||||
%% System backfill: bypass subscription gate.
|
%% 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;
|
ok;
|
||||||
false ->
|
false ->
|
||||||
_ = core_calendar:delete(Id),
|
_ = core_calendar:delete(Id),
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
-module(logic_calendar_specialist).
|
-module(logic_calendar_specialist).
|
||||||
-include("records.hrl").
|
-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()) ->
|
-spec list(ActorId :: binary(), CalendarId :: binary()) ->
|
||||||
{ok, [#calendar_specialist{}]} | {error, not_found | access_denied}.
|
{ok, [#calendar_specialist{}]} | {error, not_found | access_denied}.
|
||||||
@@ -47,14 +47,34 @@ update(OwnerId, CalendarId, UserId, Updates) ->
|
|||||||
end.
|
end.
|
||||||
|
|
||||||
-spec remove(OwnerId :: binary(), CalendarId :: binary(), UserId :: binary()) ->
|
-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) ->
|
remove(OwnerId, CalendarId, UserId) ->
|
||||||
case require_owner_commercial(OwnerId, CalendarId) of
|
case require_owner_commercial(OwnerId, CalendarId) of
|
||||||
|
{ok, #calendar{owner_id = CalOwnerId}} when UserId =:= CalOwnerId ->
|
||||||
|
{error, owner_specialist_protected};
|
||||||
{ok, _} ->
|
{ok, _} ->
|
||||||
core_calendar_specialist:delete(CalendarId, UserId);
|
core_calendar_specialist:delete(CalendarId, UserId);
|
||||||
Error -> Error
|
Error -> Error
|
||||||
end.
|
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().
|
-spec to_json(#calendar_specialist{}) -> map().
|
||||||
to_json(S) ->
|
to_json(S) ->
|
||||||
#{
|
#{
|
||||||
@@ -82,3 +102,13 @@ require_owner_commercial(OwnerId, CalendarId) ->
|
|||||||
{error, access_denied};
|
{error, access_denied};
|
||||||
Error -> Error
|
Error -> Error
|
||||||
end.
|
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.
|
||||||
|
|||||||
@@ -19,5 +19,13 @@ is_server_running() ->
|
|||||||
end.
|
end.
|
||||||
|
|
||||||
test_search_requires_auth() ->
|
test_search_requires_auth() ->
|
||||||
{ok, {{_, 401, _}, _, Body}} = httpc:request(get, {"http://localhost:8080/v1/search?type=event", []}, [], []),
|
{ok, {{_, Code, _}, _, Body0}} =
|
||||||
?assertMatch(#{<<"error">> := _}, jsx:decode(Body, [return_maps])).
|
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.
|
||||||
@@ -16,9 +16,14 @@ setup() ->
|
|||||||
{attributes, record_info(fields, subscription)},
|
{attributes, record_info(fields, subscription)},
|
||||||
{ram_copies, [node()]}
|
{ram_copies, [node()]}
|
||||||
]),
|
]),
|
||||||
|
mnesia:create_table(calendar_specialist, [
|
||||||
|
{attributes, record_info(fields, calendar_specialist)},
|
||||||
|
{ram_copies, [node()]}
|
||||||
|
]),
|
||||||
ok.
|
ok.
|
||||||
|
|
||||||
cleanup(_) ->
|
cleanup(_) ->
|
||||||
|
mnesia:delete_table(calendar_specialist),
|
||||||
mnesia:delete_table(subscription),
|
mnesia:delete_table(subscription),
|
||||||
mnesia:delete_table(calendar),
|
mnesia:delete_table(calendar),
|
||||||
mnesia:delete_table(user),
|
mnesia:delete_table(user),
|
||||||
|
|||||||
@@ -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}])).
|
||||||
Reference in New Issue
Block a user