feat(share): calendar_share invite/ACL for personal and commercial.
Co-editor and deputy grants with mirror_to_default; personal out of search; list owned+shared. Refs EventHub/EventHubBack#73
This commit is contained in:
+20
-3
@@ -103,10 +103,27 @@
|
||||
updated_at :: calendar:datetime()
|
||||
}).
|
||||
|
||||
%% PK = id. Уникальность пары calendar_id+user_id — в logic.
|
||||
-record(calendar_share, {
|
||||
calendar_id :: binary(),
|
||||
user_id :: binary(),
|
||||
rights :: read | write | admin
|
||||
id :: binary(),
|
||||
calendar_id :: binary(),
|
||||
user_id :: binary(),
|
||||
rights :: read | write | admin,
|
||||
mirror_to_default :: boolean()
|
||||
}).
|
||||
|
||||
%% Приглашения к calendar_share (соредактор / заместитель).
|
||||
-record(calendar_share_invite, {
|
||||
id :: binary(),
|
||||
calendar_id :: binary(),
|
||||
inviter_id :: binary(),
|
||||
invitee_user_id :: binary(), % <<>> если ещё неизвестен
|
||||
invitee_email :: binary(), % <<>> если только user_id
|
||||
rights :: read | write | admin,
|
||||
status :: pending | accepted | declined | expired | cancelled,
|
||||
token :: binary(),
|
||||
created_at :: calendar:datetime(),
|
||||
expires_at :: calendar:datetime()
|
||||
}).
|
||||
|
||||
%% Follow чужого календаря (не путать с платной subscription / calendar_share)
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
%%%-------------------------------------------------------------------
|
||||
%%% @doc Хранение grants calendar_share (соредактор / заместитель).
|
||||
%%% @end
|
||||
%%%-------------------------------------------------------------------
|
||||
-module(core_calendar_share).
|
||||
-include("records.hrl").
|
||||
|
||||
-export([upsert/4, get/2, list_by_calendar/1, list_by_user/1,
|
||||
delete/2, update_rights/3, update_mirror/3]).
|
||||
|
||||
-spec upsert(CalendarId :: binary(), UserId :: binary(),
|
||||
Rights :: read | write | admin, Mirror :: boolean()) ->
|
||||
{ok, #calendar_share{}} | {error, term()}.
|
||||
upsert(CalendarId, UserId, Rights, Mirror) ->
|
||||
F = fun() ->
|
||||
case find(CalendarId, UserId) of
|
||||
[#calendar_share{} = Existing] ->
|
||||
Updated = Existing#calendar_share{
|
||||
rights = Rights,
|
||||
mirror_to_default = Mirror
|
||||
},
|
||||
mnesia:write(Updated),
|
||||
{ok, Updated};
|
||||
[] ->
|
||||
Rec = #calendar_share{
|
||||
id = infra_utils:generate_id(16),
|
||||
calendar_id = CalendarId,
|
||||
user_id = UserId,
|
||||
rights = Rights,
|
||||
mirror_to_default = Mirror
|
||||
},
|
||||
mnesia:write(Rec),
|
||||
{ok, Rec}
|
||||
end
|
||||
end,
|
||||
case mnesia:transaction(F) of
|
||||
{atomic, Result} -> Result;
|
||||
{aborted, Reason} -> {error, Reason}
|
||||
end.
|
||||
|
||||
-spec get(CalendarId :: binary(), UserId :: binary()) ->
|
||||
{ok, #calendar_share{}} | {error, not_found}.
|
||||
get(CalendarId, UserId) ->
|
||||
case find_dirty(CalendarId, UserId) of
|
||||
[Rec] -> {ok, Rec};
|
||||
[] -> {error, not_found}
|
||||
end.
|
||||
|
||||
-spec list_by_calendar(CalendarId :: binary()) -> [#calendar_share{}].
|
||||
list_by_calendar(CalendarId) ->
|
||||
mnesia:dirty_match_object(#calendar_share{calendar_id = CalendarId, _ = '_'}).
|
||||
|
||||
-spec list_by_user(UserId :: binary()) -> [#calendar_share{}].
|
||||
list_by_user(UserId) ->
|
||||
mnesia:dirty_match_object(#calendar_share{user_id = UserId, _ = '_'}).
|
||||
|
||||
-spec delete(CalendarId :: binary(), UserId :: binary()) -> ok | {error, not_found | term()}.
|
||||
delete(CalendarId, UserId) ->
|
||||
F = fun() ->
|
||||
case find(CalendarId, UserId) of
|
||||
[] -> {error, not_found};
|
||||
[#calendar_share{id = Id}] ->
|
||||
mnesia:delete({calendar_share, Id}),
|
||||
ok
|
||||
end
|
||||
end,
|
||||
case mnesia:transaction(F) of
|
||||
{atomic, Result} -> Result;
|
||||
{aborted, Reason} -> {error, Reason}
|
||||
end.
|
||||
|
||||
-spec update_rights(CalendarId :: binary(), UserId :: binary(),
|
||||
Rights :: read | write | admin) ->
|
||||
{ok, #calendar_share{}} | {error, not_found | term()}.
|
||||
update_rights(CalendarId, UserId, Rights) ->
|
||||
F = fun() ->
|
||||
case find(CalendarId, UserId) of
|
||||
[] -> {error, not_found};
|
||||
[#calendar_share{} = Rec] ->
|
||||
Updated = Rec#calendar_share{rights = Rights},
|
||||
mnesia:write(Updated),
|
||||
{ok, Updated}
|
||||
end
|
||||
end,
|
||||
case mnesia:transaction(F) of
|
||||
{atomic, Result} -> Result;
|
||||
{aborted, Reason} -> {error, Reason}
|
||||
end.
|
||||
|
||||
-spec update_mirror(CalendarId :: binary(), UserId :: binary(), Mirror :: boolean()) ->
|
||||
{ok, #calendar_share{}} | {error, not_found | term()}.
|
||||
update_mirror(CalendarId, UserId, Mirror) ->
|
||||
F = fun() ->
|
||||
case find(CalendarId, UserId) of
|
||||
[] -> {error, not_found};
|
||||
[#calendar_share{} = Rec] ->
|
||||
Updated = Rec#calendar_share{mirror_to_default = Mirror},
|
||||
mnesia:write(Updated),
|
||||
{ok, Updated}
|
||||
end
|
||||
end,
|
||||
case mnesia:transaction(F) of
|
||||
{atomic, Result} -> Result;
|
||||
{aborted, Reason} -> {error, Reason}
|
||||
end.
|
||||
|
||||
find(CalendarId, UserId) ->
|
||||
mnesia:match_object(#calendar_share{calendar_id = CalendarId, user_id = UserId, _ = '_'}).
|
||||
|
||||
find_dirty(CalendarId, UserId) ->
|
||||
mnesia:dirty_match_object(
|
||||
#calendar_share{calendar_id = CalendarId, user_id = UserId, _ = '_'}).
|
||||
@@ -0,0 +1,90 @@
|
||||
%%%-------------------------------------------------------------------
|
||||
%%% @doc Хранение приглашений calendar_share.
|
||||
%%% @end
|
||||
%%%-------------------------------------------------------------------
|
||||
-module(core_calendar_share_invite).
|
||||
-include("records.hrl").
|
||||
|
||||
-export([create/1, get_by_id/1, get_by_token/1, list_by_calendar/1,
|
||||
list_by_invitee/1, list_by_email/1, update_status/2, find_pending/3,
|
||||
write/1]).
|
||||
|
||||
-spec create(#calendar_share_invite{}) ->
|
||||
{ok, #calendar_share_invite{}} | {error, term()}.
|
||||
create(Rec) ->
|
||||
F = fun() ->
|
||||
mnesia:write(Rec),
|
||||
{ok, Rec}
|
||||
end,
|
||||
case mnesia:transaction(F) of
|
||||
{atomic, Result} -> Result;
|
||||
{aborted, Reason} -> {error, Reason}
|
||||
end.
|
||||
|
||||
-spec write(#calendar_share_invite{}) -> ok.
|
||||
write(Rec) ->
|
||||
mnesia:dirty_write(Rec).
|
||||
|
||||
-spec get_by_id(Id :: binary()) ->
|
||||
{ok, #calendar_share_invite{}} | {error, not_found}.
|
||||
get_by_id(Id) ->
|
||||
case mnesia:dirty_read(calendar_share_invite, Id) of
|
||||
[Rec] -> {ok, Rec};
|
||||
[] -> {error, not_found}
|
||||
end.
|
||||
|
||||
-spec get_by_token(Token :: binary()) ->
|
||||
{ok, #calendar_share_invite{}} | {error, not_found}.
|
||||
get_by_token(Token) ->
|
||||
case mnesia:dirty_index_read(calendar_share_invite, Token,
|
||||
#calendar_share_invite.token) of
|
||||
[Rec] -> {ok, Rec};
|
||||
[] -> {error, not_found};
|
||||
[Rec | _] -> {ok, Rec}
|
||||
end.
|
||||
|
||||
-spec list_by_calendar(CalendarId :: binary()) -> [#calendar_share_invite{}].
|
||||
list_by_calendar(CalendarId) ->
|
||||
mnesia:dirty_match_object(
|
||||
#calendar_share_invite{calendar_id = CalendarId, _ = '_'}).
|
||||
|
||||
-spec list_by_invitee(UserId :: binary()) -> [#calendar_share_invite{}].
|
||||
list_by_invitee(UserId) ->
|
||||
mnesia:dirty_match_object(
|
||||
#calendar_share_invite{invitee_user_id = UserId, _ = '_'}).
|
||||
|
||||
-spec list_by_email(Email :: binary()) -> [#calendar_share_invite{}].
|
||||
list_by_email(Email) ->
|
||||
mnesia:dirty_match_object(
|
||||
#calendar_share_invite{invitee_email = Email, _ = '_'}).
|
||||
|
||||
-spec update_status(Id :: binary(), Status :: atom()) ->
|
||||
{ok, #calendar_share_invite{}} | {error, not_found | term()}.
|
||||
update_status(Id, Status) ->
|
||||
F = fun() ->
|
||||
case mnesia:read(calendar_share_invite, Id) of
|
||||
[] ->
|
||||
{error, not_found};
|
||||
[Rec] ->
|
||||
Updated = Rec#calendar_share_invite{status = Status},
|
||||
mnesia:write(Updated),
|
||||
{ok, Updated}
|
||||
end
|
||||
end,
|
||||
case mnesia:transaction(F) of
|
||||
{atomic, Result} -> Result;
|
||||
{aborted, Reason} -> {error, Reason}
|
||||
end.
|
||||
|
||||
-spec find_pending(CalendarId :: binary(), UserId :: binary(), Email :: binary()) ->
|
||||
[#calendar_share_invite{}].
|
||||
find_pending(CalendarId, UserId, Email) ->
|
||||
All = list_by_calendar(CalendarId),
|
||||
[I || I <- All, I#calendar_share_invite.status =:= pending,
|
||||
matches_pending(I, UserId, Email)].
|
||||
|
||||
matches_pending(#calendar_share_invite{invitee_user_id = U}, UserId, _)
|
||||
when UserId =/= <<>>, U =:= UserId -> true;
|
||||
matches_pending(#calendar_share_invite{invitee_email = E}, _, Email)
|
||||
when Email =/= <<>>, E =/= <<>>, E =:= Email -> true;
|
||||
matches_pending(_, _, _) -> false.
|
||||
@@ -102,6 +102,8 @@ start_http() ->
|
||||
{"/v1/user/reviews", handler_user_reviews, []},
|
||||
{"/v1/user/following", handler_user_following, []},
|
||||
{"/v1/user/specialist-invites", handler_specialist_invites, []},
|
||||
{"/v1/user/share-invites", handler_share_invites, []},
|
||||
{"/v1/user/shares/:calendar_id", handler_calendar_shares, []},
|
||||
{"/v1/users/lookup", handler_users_lookup, []},
|
||||
{"/v1/search", handler_search, []},
|
||||
{"/v1/calendars", handler_calendars, []},
|
||||
@@ -112,9 +114,16 @@ start_http() ->
|
||||
{"/v1/calendars/:id/specialists/:user_id", handler_calendar_specialists, []},
|
||||
{"/v1/calendars/:id/specialist-invites", handler_calendar_specialist_invites, []},
|
||||
{"/v1/calendars/:id/specialist-invites/:invite_id", handler_calendar_specialist_invites, []},
|
||||
{"/v1/calendars/:id/share-invites", handler_calendar_share_invites, []},
|
||||
{"/v1/calendars/:id/share-invites/:invite_id", handler_calendar_share_invites, []},
|
||||
{"/v1/calendars/:id/shares", handler_calendar_shares, []},
|
||||
{"/v1/calendars/:id/shares/:user_id", handler_calendar_shares, []},
|
||||
{"/v1/specialist-invites/accept", handler_specialist_invites, []},
|
||||
{"/v1/specialist-invites/:id/accept", handler_specialist_invites, []},
|
||||
{"/v1/specialist-invites/:id/decline", handler_specialist_invites, []},
|
||||
{"/v1/share-invites/accept", handler_share_invites, []},
|
||||
{"/v1/share-invites/:id/accept", handler_share_invites, []},
|
||||
{"/v1/share-invites/:id/decline", handler_share_invites, []},
|
||||
{"/v1/calendars/:calendar_id/view", handler_calendar_view, []},
|
||||
{"/v1/calendars/:calendar_id/events", handler_events, []},
|
||||
{"/v1/events/:id", handler_event_by_id, []},
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
%%%-------------------------------------------------------------------
|
||||
%%% @doc Owner/admin: исходящие share invites.
|
||||
%%%
|
||||
%%% GET/POST /v1/calendars/:id/share-invites
|
||||
%%% DELETE /v1/calendars/:id/share-invites/:invite_id
|
||||
%%% @end
|
||||
%%%-------------------------------------------------------------------
|
||||
-module(handler_calendar_share_invites).
|
||||
-behaviour(cowboy_handler).
|
||||
|
||||
-export([init/2, trails/0]).
|
||||
|
||||
-include("records.hrl").
|
||||
|
||||
init(Req, Opts) ->
|
||||
handle(Req, Opts).
|
||||
|
||||
trails() ->
|
||||
IdParam = #{name => <<"id">>, in => <<"path">>, required => true,
|
||||
schema => #{type => string}},
|
||||
InviteParam = #{name => <<"invite_id">>, in => <<"path">>, required => true,
|
||||
schema => #{type => string}},
|
||||
[
|
||||
#{path => <<"/v1/calendars/:id/share-invites">>, method => <<"GET">>,
|
||||
description => <<"List outgoing share invites">>, tags => [<<"Calendars">>],
|
||||
parameters => [IdParam], responses => #{200 => #{description => <<"OK">>}}},
|
||||
#{path => <<"/v1/calendars/:id/share-invites">>, method => <<"POST">>,
|
||||
description => <<"Create share invite">>, tags => [<<"Calendars">>],
|
||||
parameters => [IdParam], responses => #{201 => #{description => <<"Created">>}}},
|
||||
#{path => <<"/v1/calendars/:id/share-invites/:invite_id">>, method => <<"DELETE">>,
|
||||
description => <<"Cancel pending share invite">>, tags => [<<"Calendars">>],
|
||||
parameters => [IdParam, InviteParam], responses => #{200 => #{description => <<"OK">>}}}
|
||||
].
|
||||
|
||||
handle(Req, _Opts) ->
|
||||
Method = cowboy_req:method(Req),
|
||||
InviteId = cowboy_req:binding(invite_id, Req),
|
||||
case {Method, InviteId} of
|
||||
{<<"GET">>, undefined} -> list_invites(Req);
|
||||
{<<"POST">>, undefined} -> create_invite(Req);
|
||||
{<<"DELETE">>, Id} when is_binary(Id) -> cancel_invite(Req);
|
||||
_ -> handler_utils:send_error(Req, 405, <<"Method not allowed">>)
|
||||
end.
|
||||
|
||||
list_invites(Req) ->
|
||||
case handler_utils:auth_user(Req) of
|
||||
{ok, ActorId, Req1} ->
|
||||
CalendarId = cowboy_req:binding(id, Req1),
|
||||
case logic_calendar_share_invite:list_outgoing(ActorId, CalendarId) of
|
||||
{ok, List} ->
|
||||
handler_utils:send_json(Req1, 200,
|
||||
[logic_calendar_share_invite:to_json(I) || I <- List]);
|
||||
{error, not_found} ->
|
||||
handler_utils:send_error(Req1, 404, <<"Calendar not found">>);
|
||||
{error, access_denied} ->
|
||||
handler_utils:send_error(Req1, 403, <<"Access denied">>);
|
||||
{error, _} ->
|
||||
handler_utils:send_error(Req1, 500, <<"Internal server error">>)
|
||||
end;
|
||||
{error, Code, Message, Req1} ->
|
||||
handler_utils:send_error(Req1, Code, Message)
|
||||
end.
|
||||
|
||||
create_invite(Req) ->
|
||||
case handler_utils:auth_user(Req) of
|
||||
{ok, ActorId, Req1} ->
|
||||
CalendarId = cowboy_req:binding(id, Req1),
|
||||
{ok, Body, Req2} = cowboy_req:read_body(Req1),
|
||||
try jsx:decode(Body, [return_maps]) of
|
||||
Map when is_map(Map) ->
|
||||
case parse_target(Map) of
|
||||
{error, bad_request} ->
|
||||
handler_utils:send_error(Req2, 400, <<"user_id or email required">>);
|
||||
Target ->
|
||||
Opts = #{rights => maps:get(<<"rights">>, Map, <<"write">>)},
|
||||
case logic_calendar_share_invite:create(ActorId, CalendarId, Target, Opts) of
|
||||
{ok, Inv} ->
|
||||
handler_utils:send_json(Req2, 201,
|
||||
logic_calendar_share_invite:to_json(Inv));
|
||||
{error, Reason} ->
|
||||
map_create_error(Req2, Reason)
|
||||
end
|
||||
end;
|
||||
_ ->
|
||||
handler_utils:send_error(Req2, 400, <<"Invalid JSON">>)
|
||||
catch
|
||||
_:_ -> handler_utils:send_error(Req2, 400, <<"Invalid JSON">>)
|
||||
end;
|
||||
{error, Code, Message, Req1} ->
|
||||
handler_utils:send_error(Req1, Code, Message)
|
||||
end.
|
||||
|
||||
cancel_invite(Req) ->
|
||||
case handler_utils:auth_user(Req) of
|
||||
{ok, ActorId, Req1} ->
|
||||
CalendarId = cowboy_req:binding(id, Req1),
|
||||
InviteId = cowboy_req:binding(invite_id, Req1),
|
||||
case logic_calendar_share_invite:cancel(ActorId, CalendarId, InviteId) of
|
||||
{ok, Inv} ->
|
||||
handler_utils:send_json(Req1, 200, logic_calendar_share_invite:to_json(Inv));
|
||||
{error, not_found} ->
|
||||
handler_utils:send_error(Req1, 404, <<"Not found">>);
|
||||
{error, access_denied} ->
|
||||
handler_utils:send_error(Req1, 403, <<"Access denied">>);
|
||||
{error, not_pending} ->
|
||||
handler_utils:send_error(Req1, 409, <<"Invite is not pending">>);
|
||||
{error, _} ->
|
||||
handler_utils:send_error(Req1, 500, <<"Internal server error">>)
|
||||
end;
|
||||
{error, Code, Message, Req1} ->
|
||||
handler_utils:send_error(Req1, Code, Message)
|
||||
end.
|
||||
|
||||
parse_target(#{<<"user_id">> := UserId}) when is_binary(UserId), UserId =/= <<>> ->
|
||||
#{user_id => UserId};
|
||||
parse_target(#{<<"email">> := Email}) when is_binary(Email), Email =/= <<>> ->
|
||||
#{email => Email};
|
||||
parse_target(_) ->
|
||||
{error, bad_request}.
|
||||
|
||||
map_create_error(Req, not_found) ->
|
||||
handler_utils:send_error(Req, 404, <<"Calendar not found">>);
|
||||
map_create_error(Req, access_denied) ->
|
||||
handler_utils:send_error(Req, 403, <<"Access denied">>);
|
||||
map_create_error(Req, user_not_found) ->
|
||||
handler_utils:send_error(Req, 404, <<"User not found">>);
|
||||
map_create_error(Req, already_shared) ->
|
||||
handler_utils:send_error(Req, 409, <<"Already shared">>);
|
||||
map_create_error(Req, already_pending) ->
|
||||
handler_utils:send_error(Req, 409, <<"Invite already pending">>);
|
||||
map_create_error(Req, bad_request) ->
|
||||
handler_utils:send_error(Req, 400, <<"Bad request">>);
|
||||
map_create_error(Req, _) ->
|
||||
handler_utils:send_error(Req, 500, <<"Internal server error">>).
|
||||
@@ -0,0 +1,161 @@
|
||||
%%%-------------------------------------------------------------------
|
||||
%%% @doc Accepted shares on a calendar + revoke / rights / self-mirror.
|
||||
%%%
|
||||
%%% GET/DELETE/PUT /v1/calendars/:id/shares[/:user_id]
|
||||
%%% PUT /v1/user/shares/:calendar_id body: {mirror_to_default}
|
||||
%%% @end
|
||||
%%%-------------------------------------------------------------------
|
||||
-module(handler_calendar_shares).
|
||||
-behaviour(cowboy_handler).
|
||||
|
||||
-export([init/2, trails/0]).
|
||||
|
||||
-include("records.hrl").
|
||||
|
||||
init(Req, Opts) ->
|
||||
handle(Req, Opts).
|
||||
|
||||
trails() ->
|
||||
IdParam = #{name => <<"id">>, in => <<"path">>, required => true,
|
||||
schema => #{type => string}},
|
||||
UserParam = #{name => <<"user_id">>, in => <<"path">>, required => true,
|
||||
schema => #{type => string}},
|
||||
CalParam = #{name => <<"calendar_id">>, in => <<"path">>, required => true,
|
||||
schema => #{type => string}},
|
||||
[
|
||||
#{path => <<"/v1/calendars/:id/shares">>, method => <<"GET">>,
|
||||
description => <<"List calendar shares">>, tags => [<<"Calendars">>],
|
||||
parameters => [IdParam], responses => #{200 => #{description => <<"OK">>}}},
|
||||
#{path => <<"/v1/calendars/:id/shares/:user_id">>, method => <<"DELETE">>,
|
||||
description => <<"Revoke share grant">>, tags => [<<"Calendars">>],
|
||||
parameters => [IdParam, UserParam], responses => #{200 => #{description => <<"OK">>}}},
|
||||
#{path => <<"/v1/calendars/:id/shares/:user_id">>, method => <<"PUT">>,
|
||||
description => <<"Update share rights">>, tags => [<<"Calendars">>],
|
||||
parameters => [IdParam, UserParam], responses => #{200 => #{description => <<"OK">>}}},
|
||||
#{path => <<"/v1/user/shares/:calendar_id">>, method => <<"PUT">>,
|
||||
description => <<"Update own mirror_to_default">>, tags => [<<"Users">>],
|
||||
parameters => [CalParam], responses => #{200 => #{description => <<"OK">>}}}
|
||||
].
|
||||
|
||||
handle(Req, _Opts) ->
|
||||
Method = cowboy_req:method(Req),
|
||||
Path = cowboy_req:path(Req),
|
||||
case Path of
|
||||
<<"/v1/user/shares/", _/binary>> ->
|
||||
case Method of
|
||||
<<"PUT">> -> update_my_mirror(Req);
|
||||
_ -> handler_utils:send_error(Req, 405, <<"Method not allowed">>)
|
||||
end;
|
||||
_ ->
|
||||
UserId = cowboy_req:binding(user_id, Req),
|
||||
case {Method, UserId} of
|
||||
{<<"GET">>, undefined} -> list_shares(Req);
|
||||
{<<"DELETE">>, Id} when is_binary(Id) -> revoke_share(Req);
|
||||
{<<"PUT">>, Id} when is_binary(Id) -> update_rights(Req);
|
||||
_ -> handler_utils:send_error(Req, 405, <<"Method not allowed">>)
|
||||
end
|
||||
end.
|
||||
|
||||
list_shares(Req) ->
|
||||
case handler_utils:auth_user(Req) of
|
||||
{ok, ActorId, Req1} ->
|
||||
CalendarId = cowboy_req:binding(id, Req1),
|
||||
case logic_calendar_share:list(ActorId, CalendarId) of
|
||||
{ok, List} ->
|
||||
handler_utils:send_json(Req1, 200,
|
||||
[logic_calendar_share:to_json(S) || S <- List]);
|
||||
{error, not_found} ->
|
||||
handler_utils:send_error(Req1, 404, <<"Calendar not found">>);
|
||||
{error, access_denied} ->
|
||||
handler_utils:send_error(Req1, 403, <<"Access denied">>);
|
||||
{error, _} ->
|
||||
handler_utils:send_error(Req1, 500, <<"Internal server error">>)
|
||||
end;
|
||||
{error, Code, Message, Req1} ->
|
||||
handler_utils:send_error(Req1, Code, Message)
|
||||
end.
|
||||
|
||||
revoke_share(Req) ->
|
||||
case handler_utils:auth_user(Req) of
|
||||
{ok, ActorId, Req1} ->
|
||||
CalendarId = cowboy_req:binding(id, Req1),
|
||||
TargetUserId = cowboy_req:binding(user_id, Req1),
|
||||
case logic_calendar_share:revoke(ActorId, CalendarId, TargetUserId) of
|
||||
ok ->
|
||||
handler_utils:send_json(Req1, 200, #{ok => true});
|
||||
{error, not_found} ->
|
||||
handler_utils:send_error(Req1, 404, <<"Not found">>);
|
||||
{error, access_denied} ->
|
||||
handler_utils:send_error(Req1, 403, <<"Access denied">>);
|
||||
{error, _} ->
|
||||
handler_utils:send_error(Req1, 500, <<"Internal server error">>)
|
||||
end;
|
||||
{error, Code, Message, Req1} ->
|
||||
handler_utils:send_error(Req1, Code, Message)
|
||||
end.
|
||||
|
||||
update_rights(Req) ->
|
||||
case handler_utils:auth_user(Req) of
|
||||
{ok, ActorId, Req1} ->
|
||||
CalendarId = cowboy_req:binding(id, Req1),
|
||||
TargetUserId = cowboy_req:binding(user_id, Req1),
|
||||
{ok, Body, Req2} = cowboy_req:read_body(Req1),
|
||||
try jsx:decode(Body, [return_maps]) of
|
||||
#{<<"rights">> := RightsBin} when is_binary(RightsBin) ->
|
||||
case binary_to_existing_atom_safe(RightsBin) of
|
||||
invalid ->
|
||||
handler_utils:send_error(Req2, 400, <<"Bad request">>);
|
||||
Rights ->
|
||||
case logic_calendar_share:update_rights(ActorId, CalendarId, TargetUserId, Rights) of
|
||||
{ok, Share} ->
|
||||
handler_utils:send_json(Req2, 200, logic_calendar_share:to_json(Share));
|
||||
{error, Reason} ->
|
||||
map_share_error(Req2, Reason)
|
||||
end
|
||||
end;
|
||||
_ ->
|
||||
handler_utils:send_error(Req2, 400, <<"rights required">>)
|
||||
catch
|
||||
_:_ -> handler_utils:send_error(Req2, 400, <<"Invalid JSON">>)
|
||||
end;
|
||||
{error, Code, Message, Req1} ->
|
||||
handler_utils:send_error(Req1, Code, Message)
|
||||
end.
|
||||
|
||||
update_my_mirror(Req) ->
|
||||
case handler_utils:auth_user(Req) of
|
||||
{ok, UserId, Req1} ->
|
||||
CalendarId = cowboy_req:binding(calendar_id, Req1),
|
||||
{ok, Body, Req2} = cowboy_req:read_body(Req1),
|
||||
try jsx:decode(Body, [return_maps]) of
|
||||
#{<<"mirror_to_default">> := Mirror} when is_boolean(Mirror) ->
|
||||
case logic_calendar_share:update_my_mirror(UserId, CalendarId, Mirror) of
|
||||
{ok, Share} ->
|
||||
handler_utils:send_json(Req2, 200, logic_calendar_share:to_json(Share));
|
||||
{error, Reason} ->
|
||||
map_share_error(Req2, Reason)
|
||||
end;
|
||||
_ ->
|
||||
handler_utils:send_error(Req2, 400, <<"mirror_to_default required">>)
|
||||
catch
|
||||
_:_ -> handler_utils:send_error(Req2, 400, <<"Invalid JSON">>)
|
||||
end;
|
||||
{error, Code, Message, Req1} ->
|
||||
handler_utils:send_error(Req1, Code, Message)
|
||||
end.
|
||||
|
||||
binary_to_existing_atom_safe(<<"read">>) -> read;
|
||||
binary_to_existing_atom_safe(<<"write">>) -> write;
|
||||
binary_to_existing_atom_safe(<<"admin">>) -> admin;
|
||||
binary_to_existing_atom_safe(_) -> invalid.
|
||||
|
||||
map_share_error(Req, not_found) ->
|
||||
handler_utils:send_error(Req, 404, <<"Not found">>);
|
||||
map_share_error(Req, access_denied) ->
|
||||
handler_utils:send_error(Req, 403, <<"Access denied">>);
|
||||
map_share_error(Req, bad_request) ->
|
||||
handler_utils:send_error(Req, 400, <<"Bad request">>);
|
||||
map_share_error(Req, invalid) ->
|
||||
handler_utils:send_error(Req, 400, <<"Bad request">>);
|
||||
map_share_error(Req, _) ->
|
||||
handler_utils:send_error(Req, 500, <<"Internal server error">>).
|
||||
@@ -168,7 +168,7 @@ list_calendars(Req) ->
|
||||
{ok, UserId, Req1} ->
|
||||
case logic_calendar:list_calendars(UserId) of
|
||||
{ok, Calendars} ->
|
||||
Response = [calendar_to_json(C) || C <- Calendars],
|
||||
Response = [calendar_list_json(UserId, C) || C <- Calendars],
|
||||
handler_utils:send_json(Req1, 200, Response);
|
||||
{error, _} ->
|
||||
handler_utils:send_error(Req1, 500, <<"Internal server error">>)
|
||||
@@ -181,6 +181,21 @@ list_calendars(Req) ->
|
||||
%%% Внутренние функции
|
||||
%%%===================================================================
|
||||
|
||||
-spec calendar_list_json(binary(), #calendar{}) -> map().
|
||||
calendar_list_json(UserId, #calendar{owner_id = UserId} = Calendar) ->
|
||||
(calendar_to_json(Calendar))#{role => <<"owner">>};
|
||||
calendar_list_json(UserId, Calendar) ->
|
||||
Base = (calendar_to_json(Calendar))#{role => <<"shared">>},
|
||||
case core_calendar_share:get(Calendar#calendar.id, UserId) of
|
||||
{ok, Share} ->
|
||||
Base#{
|
||||
share_rights => Share#calendar_share.rights,
|
||||
mirror_to_default => Share#calendar_share.mirror_to_default
|
||||
};
|
||||
{error, not_found} ->
|
||||
Base
|
||||
end.
|
||||
|
||||
-spec calendar_to_json(#calendar{}) -> map().
|
||||
calendar_to_json(Calendar) ->
|
||||
Base = handler_utils:calendar_to_json(Calendar),
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
%%%-------------------------------------------------------------------
|
||||
%%% @doc Invitee: входящие share invites + accept/decline.
|
||||
%%%
|
||||
%%% GET /v1/user/share-invites
|
||||
%%% POST /v1/share-invites/:id/accept | decline
|
||||
%%% POST /v1/share-invites/accept body: {token, mirror_to_default?}
|
||||
%%% @end
|
||||
%%%-------------------------------------------------------------------
|
||||
-module(handler_share_invites).
|
||||
-behaviour(cowboy_handler).
|
||||
|
||||
-export([init/2, trails/0]).
|
||||
|
||||
-include("records.hrl").
|
||||
|
||||
init(Req, Opts) ->
|
||||
handle(Req, Opts).
|
||||
|
||||
trails() ->
|
||||
IdParam = #{name => <<"id">>, in => <<"path">>, required => true,
|
||||
schema => #{type => string}},
|
||||
[
|
||||
#{path => <<"/v1/user/share-invites">>, method => <<"GET">>,
|
||||
description => <<"Incoming share invites">>, tags => [<<"Users">>],
|
||||
responses => #{200 => #{description => <<"OK">>}}},
|
||||
#{path => <<"/v1/share-invites/accept">>, method => <<"POST">>,
|
||||
description => <<"Accept share invite by token">>, tags => [<<"Users">>],
|
||||
responses => #{200 => #{description => <<"OK">>}}},
|
||||
#{path => <<"/v1/share-invites/:id/accept">>, method => <<"POST">>,
|
||||
description => <<"Accept share invite">>, tags => [<<"Users">>],
|
||||
parameters => [IdParam], responses => #{200 => #{description => <<"OK">>}}},
|
||||
#{path => <<"/v1/share-invites/:id/decline">>, method => <<"POST">>,
|
||||
description => <<"Decline share invite">>, tags => [<<"Users">>],
|
||||
parameters => [IdParam], responses => #{200 => #{description => <<"OK">>}}}
|
||||
].
|
||||
|
||||
handle(Req, _Opts) ->
|
||||
Method = cowboy_req:method(Req),
|
||||
Path = cowboy_req:path(Req),
|
||||
case Method of
|
||||
<<"GET">> ->
|
||||
list_incoming(Req);
|
||||
<<"POST">> ->
|
||||
case Path of
|
||||
<<"/v1/share-invites/accept">> ->
|
||||
accept_token(Req);
|
||||
_ ->
|
||||
case {cowboy_req:binding(id, Req), path_action(Path)} of
|
||||
{Id, accept} when is_binary(Id) -> accept_id(Req, Id);
|
||||
{Id, decline} when is_binary(Id) -> decline_id(Req, Id);
|
||||
_ -> handler_utils:send_error(Req, 404, <<"Not found">>)
|
||||
end
|
||||
end;
|
||||
_ ->
|
||||
handler_utils:send_error(Req, 405, <<"Method not allowed">>)
|
||||
end.
|
||||
|
||||
path_action(Path) ->
|
||||
case binary:match(Path, <<"/accept">>) of
|
||||
nomatch ->
|
||||
case binary:match(Path, <<"/decline">>) of
|
||||
nomatch -> unknown;
|
||||
_ -> decline
|
||||
end;
|
||||
_ -> accept
|
||||
end.
|
||||
|
||||
list_incoming(Req) ->
|
||||
case handler_utils:auth_user(Req) of
|
||||
{ok, UserId, Req1} ->
|
||||
{ok, List} = logic_calendar_share_invite:list_incoming(UserId),
|
||||
handler_utils:send_json(Req1, 200,
|
||||
[logic_calendar_share_invite:to_json(I) || I <- List]);
|
||||
{error, Code, Message, Req1} ->
|
||||
handler_utils:send_error(Req1, Code, Message)
|
||||
end.
|
||||
|
||||
accept_id(Req, InviteId) ->
|
||||
case handler_utils:auth_user(Req) of
|
||||
{ok, UserId, Req1} ->
|
||||
{ok, Body, Req2} = cowboy_req:read_body(Req1),
|
||||
Opts = parse_accept_opts(Body),
|
||||
reply_accept(Req2, logic_calendar_share_invite:accept(UserId, InviteId, Opts));
|
||||
{error, Code, Message, Req1} ->
|
||||
handler_utils:send_error(Req1, Code, Message)
|
||||
end.
|
||||
|
||||
decline_id(Req, InviteId) ->
|
||||
case handler_utils:auth_user(Req) of
|
||||
{ok, UserId, Req1} ->
|
||||
case logic_calendar_share_invite:decline(UserId, InviteId) of
|
||||
{ok, Inv} ->
|
||||
handler_utils:send_json(Req1, 200, logic_calendar_share_invite:to_json(Inv));
|
||||
{error, Reason} ->
|
||||
map_decide_error(Req1, Reason)
|
||||
end;
|
||||
{error, Code, Message, Req1} ->
|
||||
handler_utils:send_error(Req1, Code, Message)
|
||||
end.
|
||||
|
||||
accept_token(Req) ->
|
||||
case handler_utils:auth_user(Req) of
|
||||
{ok, UserId, Req1} ->
|
||||
{ok, Body, Req2} = cowboy_req:read_body(Req1),
|
||||
try jsx:decode(Body, [return_maps]) of
|
||||
#{<<"token">> := Token} = Map when is_binary(Token), Token =/= <<>> ->
|
||||
Opts = accept_opts_from_map(Map),
|
||||
reply_accept(Req2,
|
||||
logic_calendar_share_invite:accept_by_token(UserId, Token, Opts));
|
||||
_ ->
|
||||
handler_utils:send_error(Req2, 400, <<"token required">>)
|
||||
catch
|
||||
_:_ -> handler_utils:send_error(Req2, 400, <<"Invalid JSON">>)
|
||||
end;
|
||||
{error, Code, Message, Req1} ->
|
||||
handler_utils:send_error(Req1, Code, Message)
|
||||
end.
|
||||
|
||||
parse_accept_opts(<<>>) -> #{};
|
||||
parse_accept_opts(Body) ->
|
||||
try jsx:decode(Body, [return_maps]) of
|
||||
Map when is_map(Map) -> accept_opts_from_map(Map);
|
||||
_ -> #{}
|
||||
catch
|
||||
_:_ -> #{}
|
||||
end.
|
||||
|
||||
accept_opts_from_map(Map) ->
|
||||
case maps:get(<<"mirror_to_default">>, Map, undefined) of
|
||||
true -> #{mirror_to_default => true};
|
||||
false -> #{mirror_to_default => false};
|
||||
_ -> #{}
|
||||
end.
|
||||
|
||||
reply_accept(Req, {ok, Inv, Share}) ->
|
||||
handler_utils:send_json(Req, 200, #{
|
||||
invite => logic_calendar_share_invite:to_json(Inv),
|
||||
share => logic_calendar_share:to_json(Share)
|
||||
});
|
||||
reply_accept(Req, {error, Reason}) ->
|
||||
map_decide_error(Req, Reason).
|
||||
|
||||
map_decide_error(Req, not_found) ->
|
||||
handler_utils:send_error(Req, 404, <<"Not found">>);
|
||||
map_decide_error(Req, access_denied) ->
|
||||
handler_utils:send_error(Req, 403, <<"Access denied">>);
|
||||
map_decide_error(Req, not_pending) ->
|
||||
handler_utils:send_error(Req, 409, <<"Invite is not pending">>);
|
||||
map_decide_error(Req, expired) ->
|
||||
handler_utils:send_error(Req, 410, <<"Invite expired">>);
|
||||
map_decide_error(Req, _) ->
|
||||
handler_utils:send_error(Req, 500, <<"Internal server error">>).
|
||||
@@ -14,7 +14,8 @@
|
||||
|
||||
-define(TABLES, [
|
||||
user, session, verification, password_reset, admin, admin_session, auth_session,
|
||||
calendar, calendar_share, calendar_follow, calendar_specialist, specialist_invite,
|
||||
calendar, calendar_share, calendar_share_invite, calendar_follow, calendar_specialist,
|
||||
specialist_invite,
|
||||
event, recurrence_exception,
|
||||
booking, waitlist_entry,
|
||||
review, review_vote, report, banned_word, automod_settings, automod_hit,
|
||||
@@ -317,7 +318,12 @@ create_table(Table) ->
|
||||
table_opts(user) -> [{disc_copies, [node()]}, {attributes, record_info(fields, user)}];
|
||||
table_opts(admin) -> [{disc_copies, [node()]}, {attributes, record_info(fields, admin)}];
|
||||
table_opts(calendar) -> [{disc_copies, [node()]}, {attributes, record_info(fields, calendar)}];
|
||||
table_opts(calendar_share) -> [{disc_copies, [node()]}, {attributes, record_info(fields, calendar_share)}];
|
||||
table_opts(calendar_share) ->
|
||||
[{disc_copies, [node()]}, {attributes, record_info(fields, calendar_share)},
|
||||
{index, [calendar_id, user_id]}];
|
||||
table_opts(calendar_share_invite) ->
|
||||
[{disc_copies, [node()]}, {attributes, record_info(fields, calendar_share_invite)},
|
||||
{index, [calendar_id, invitee_user_id, invitee_email, token, status]}];
|
||||
table_opts(calendar_follow) -> [{disc_copies, [node()]}, {attributes, record_info(fields, calendar_follow)}];
|
||||
table_opts(calendar_specialist) -> [{disc_copies, [node()]}, {attributes, record_info(fields, calendar_specialist)}];
|
||||
table_opts(specialist_invite) -> [{disc_copies, [node()]}, {attributes, record_info(fields, specialist_invite)}];
|
||||
|
||||
@@ -172,9 +172,21 @@ get_calendar(UserId, CalendarId) ->
|
||||
Error
|
||||
end.
|
||||
|
||||
%% Список календарей пользователя
|
||||
%% Список календарей пользователя: owned ∪ shared grants
|
||||
list_calendars(UserId) ->
|
||||
core_calendar:list_by_owner(UserId).
|
||||
{ok, Owned} = core_calendar:list_by_owner(UserId),
|
||||
Shared = shared_calendars(UserId),
|
||||
OwnedIds = [C#calendar.id || C <- Owned],
|
||||
SharedOnly = [C || C <- Shared, not lists:member(C#calendar.id, OwnedIds)],
|
||||
{ok, Owned ++ SharedOnly}.
|
||||
|
||||
shared_calendars(UserId) ->
|
||||
lists:filtermap(fun(#calendar_share{calendar_id = CalId}) ->
|
||||
case core_calendar:get_by_id(CalId) of
|
||||
{ok, #calendar{status = active} = Cal} -> {true, Cal};
|
||||
_ -> false
|
||||
end
|
||||
end, core_calendar_share:list_by_user(UserId)).
|
||||
|
||||
%% Обновление календаря
|
||||
update_calendar(UserId, CalendarId, Updates) ->
|
||||
@@ -227,16 +239,16 @@ apply_calendar_update(CalendarId, Calendar, ValidUpdates) ->
|
||||
end
|
||||
end.
|
||||
|
||||
%% Единственный personal нельзя превратить в commercial (дневник пользователя).
|
||||
gate_type_change(_UserId, #calendar{type = personal}, Updates) ->
|
||||
%% Смена type — только owner. Единственный personal → commercial запрещён.
|
||||
gate_type_change(UserId, #calendar{owner_id = OwnerId} = Calendar, Updates) ->
|
||||
case lists:keyfind(type, 1, Updates) of
|
||||
{type, commercial} ->
|
||||
{type, _} when UserId =/= OwnerId ->
|
||||
{error, access_denied};
|
||||
{type, commercial} when Calendar#calendar.type =:= personal ->
|
||||
{error, default_calendar};
|
||||
_ ->
|
||||
{ok, Updates}
|
||||
end;
|
||||
gate_type_change(_UserId, _Calendar, Updates) ->
|
||||
{ok, Updates}.
|
||||
end.
|
||||
|
||||
maybe_cancel_pending_on_personal(#calendar{type = commercial},
|
||||
#calendar{type = personal, id = Id}) ->
|
||||
@@ -270,18 +282,15 @@ 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 удалять нельзя)
|
||||
%% Удаление календаря: только owner (share write/admin не удаляет)
|
||||
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 ->
|
||||
core_calendar:delete(CalendarId);
|
||||
false ->
|
||||
{error, access_denied}
|
||||
end;
|
||||
{ok, #calendar{owner_id = UserId, status = active}} ->
|
||||
core_calendar:delete(CalendarId);
|
||||
{ok, #calendar{}} ->
|
||||
{error, access_denied};
|
||||
Error ->
|
||||
Error
|
||||
end.
|
||||
@@ -289,16 +298,22 @@ delete_calendar(UserId, CalendarId) ->
|
||||
%% Проверка прав доступа (просмотр)
|
||||
can_access(UserId, #calendar{owner_id = UserId, status = active}) ->
|
||||
true;
|
||||
can_access(UserId, #calendar{id = CalId, type = personal, status = active}) ->
|
||||
logic_calendar_share:get_rights(UserId, CalId) =/= none;
|
||||
can_access(_UserId, #calendar{type = commercial, status = active}) ->
|
||||
true;
|
||||
can_access(_UserId, _) ->
|
||||
false.
|
||||
|
||||
%% Проверка прав редактирования
|
||||
%% Проверка прав редактирования (owner или share write|admin)
|
||||
can_edit(UserId, #calendar{owner_id = UserId, status = active}) ->
|
||||
true;
|
||||
can_edit(_UserId, #calendar{owner_id = _OwnerId}) ->
|
||||
false;
|
||||
can_edit(UserId, #calendar{id = CalId, status = active}) ->
|
||||
case logic_calendar_share:get_rights(UserId, CalId) of
|
||||
write -> true;
|
||||
admin -> true;
|
||||
_ -> false
|
||||
end;
|
||||
can_edit(_, _) ->
|
||||
false.
|
||||
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
%%%-------------------------------------------------------------------
|
||||
%%% @doc Grants calendar_share: list / revoke / rights / mirror.
|
||||
%%% @end
|
||||
%%%-------------------------------------------------------------------
|
||||
-module(logic_calendar_share).
|
||||
-include("records.hrl").
|
||||
|
||||
-export([
|
||||
list/2,
|
||||
revoke/3,
|
||||
update_rights/4,
|
||||
update_my_mirror/3,
|
||||
get_rights/2,
|
||||
can_manage_shares/2,
|
||||
to_json/1
|
||||
]).
|
||||
|
||||
-spec list(ActorId :: binary(), CalendarId :: binary()) ->
|
||||
{ok, [#calendar_share{}]} | {error, not_found | access_denied}.
|
||||
list(ActorId, CalendarId) ->
|
||||
case require_can_view_shares(ActorId, CalendarId) of
|
||||
{ok, _} -> {ok, core_calendar_share:list_by_calendar(CalendarId)};
|
||||
Error -> Error
|
||||
end.
|
||||
|
||||
-spec revoke(ActorId :: binary(), CalendarId :: binary(), TargetUserId :: binary()) ->
|
||||
ok | {error, not_found | access_denied | term()}.
|
||||
revoke(ActorId, CalendarId, TargetUserId) ->
|
||||
case require_can_manage(ActorId, CalendarId) of
|
||||
{ok, _} ->
|
||||
case core_calendar_share:delete(CalendarId, TargetUserId) of
|
||||
ok -> ok;
|
||||
{error, _} = E -> E
|
||||
end;
|
||||
Error -> Error
|
||||
end.
|
||||
|
||||
-spec update_rights(ActorId :: binary(), CalendarId :: binary(),
|
||||
TargetUserId :: binary(), Rights :: read | write | admin) ->
|
||||
{ok, #calendar_share{}} | {error, not_found | access_denied | bad_request | term()}.
|
||||
update_rights(ActorId, CalendarId, TargetUserId, Rights) ->
|
||||
case valid_rights(Rights) of
|
||||
false -> {error, bad_request};
|
||||
true ->
|
||||
case require_can_manage(ActorId, CalendarId) of
|
||||
{ok, _} -> core_calendar_share:update_rights(CalendarId, TargetUserId, Rights);
|
||||
Error -> Error
|
||||
end
|
||||
end.
|
||||
|
||||
%% Invitee updates own mirror preference (personal shares only meaningful).
|
||||
-spec update_my_mirror(UserId :: binary(), CalendarId :: binary(), Mirror :: boolean()) ->
|
||||
{ok, #calendar_share{}} | {error, not_found | access_denied | term()}.
|
||||
update_my_mirror(UserId, CalendarId, Mirror) when is_boolean(Mirror) ->
|
||||
case core_calendar_share:get(CalendarId, UserId) of
|
||||
{ok, _} -> core_calendar_share:update_mirror(CalendarId, UserId, Mirror);
|
||||
{error, not_found} -> {error, not_found}
|
||||
end;
|
||||
update_my_mirror(_, _, _) ->
|
||||
{error, bad_request}.
|
||||
|
||||
-spec get_rights(UserId :: binary(), CalendarId :: binary()) ->
|
||||
none | read | write | admin.
|
||||
get_rights(UserId, CalendarId) ->
|
||||
case core_calendar_share:get(CalendarId, UserId) of
|
||||
{ok, #calendar_share{rights = R}} -> R;
|
||||
{error, not_found} -> none
|
||||
end.
|
||||
|
||||
-spec can_manage_shares(UserId :: binary(), #calendar{}) -> boolean().
|
||||
can_manage_shares(UserId, #calendar{owner_id = UserId, status = active}) ->
|
||||
true;
|
||||
can_manage_shares(UserId, #calendar{id = CalId, status = active}) ->
|
||||
get_rights(UserId, CalId) =:= admin;
|
||||
can_manage_shares(_, _) ->
|
||||
false.
|
||||
|
||||
-spec to_json(#calendar_share{}) -> map().
|
||||
to_json(S) ->
|
||||
#{
|
||||
id => S#calendar_share.id,
|
||||
calendar_id => S#calendar_share.calendar_id,
|
||||
user_id => S#calendar_share.user_id,
|
||||
rights => S#calendar_share.rights,
|
||||
mirror_to_default => S#calendar_share.mirror_to_default
|
||||
}.
|
||||
|
||||
%%%===================================================================
|
||||
%%% Internal
|
||||
%%%===================================================================
|
||||
|
||||
require_can_view_shares(ActorId, CalendarId) ->
|
||||
case core_calendar:get_by_id(CalendarId) of
|
||||
{ok, #calendar{status = active} = Cal} ->
|
||||
case can_manage_shares(ActorId, Cal)
|
||||
orelse Cal#calendar.owner_id =:= ActorId
|
||||
orelse get_rights(ActorId, CalendarId) =/= none of
|
||||
true -> {ok, Cal};
|
||||
false -> {error, access_denied}
|
||||
end;
|
||||
{ok, _} -> {error, not_found};
|
||||
Error -> Error
|
||||
end.
|
||||
|
||||
require_can_manage(ActorId, CalendarId) ->
|
||||
case core_calendar:get_by_id(CalendarId) of
|
||||
{ok, #calendar{status = active} = Cal} ->
|
||||
case can_manage_shares(ActorId, Cal) of
|
||||
true -> {ok, Cal};
|
||||
false -> {error, access_denied}
|
||||
end;
|
||||
{ok, _} -> {error, not_found};
|
||||
Error -> Error
|
||||
end.
|
||||
|
||||
valid_rights(read) -> true;
|
||||
valid_rights(write) -> true;
|
||||
valid_rights(admin) -> true;
|
||||
valid_rights(_) -> false.
|
||||
@@ -0,0 +1,313 @@
|
||||
%%%-------------------------------------------------------------------
|
||||
%%% @doc Приглашения calendar_share (соредактор personal / заместитель commercial).
|
||||
%%% @end
|
||||
%%%-------------------------------------------------------------------
|
||||
-module(logic_calendar_share_invite).
|
||||
-include("records.hrl").
|
||||
|
||||
-export([
|
||||
create/4,
|
||||
list_outgoing/2,
|
||||
cancel/3,
|
||||
list_incoming/1,
|
||||
accept/3,
|
||||
decline/2,
|
||||
accept_by_token/3,
|
||||
to_json/1
|
||||
]).
|
||||
|
||||
-define(INVITE_TTL_DAYS, 7).
|
||||
|
||||
-spec create(ActorId :: binary(), CalendarId :: binary(), Target :: map(),
|
||||
Opts :: map()) ->
|
||||
{ok, #calendar_share_invite{}} |
|
||||
{error, not_found | access_denied | user_not_found | already_shared |
|
||||
already_pending | bad_request | term()}.
|
||||
create(ActorId, CalendarId, Target, Opts) ->
|
||||
case require_can_invite(ActorId, CalendarId) of
|
||||
{ok, _Cal} ->
|
||||
case resolve_target(Target) of
|
||||
{error, _} = E -> E;
|
||||
{ok, UserId, Email} ->
|
||||
case already_shared(CalendarId, UserId) of
|
||||
true ->
|
||||
{error, already_shared};
|
||||
false ->
|
||||
case core_calendar_share_invite:find_pending(CalendarId, UserId, Email) of
|
||||
[_ | _] ->
|
||||
{error, already_pending};
|
||||
[] ->
|
||||
do_create(ActorId, CalendarId, UserId, Email, Opts)
|
||||
end
|
||||
end
|
||||
end;
|
||||
Error -> Error
|
||||
end.
|
||||
|
||||
-spec list_outgoing(ActorId :: binary(), CalendarId :: binary()) ->
|
||||
{ok, [#calendar_share_invite{}]} | {error, not_found | access_denied}.
|
||||
list_outgoing(ActorId, CalendarId) ->
|
||||
case require_can_invite(ActorId, CalendarId) of
|
||||
{ok, _} ->
|
||||
List = [maybe_expire(I) || I <- core_calendar_share_invite:list_by_calendar(CalendarId)],
|
||||
{ok, List};
|
||||
Error -> Error
|
||||
end.
|
||||
|
||||
-spec cancel(ActorId :: binary(), CalendarId :: binary(), InviteId :: binary()) ->
|
||||
{ok, #calendar_share_invite{}} |
|
||||
{error, not_found | access_denied | not_pending | term()}.
|
||||
cancel(ActorId, CalendarId, InviteId) ->
|
||||
case require_can_invite(ActorId, CalendarId) of
|
||||
{ok, _} ->
|
||||
case core_calendar_share_invite:get_by_id(InviteId) of
|
||||
{ok, #calendar_share_invite{calendar_id = CalendarId, status = pending} = Inv} ->
|
||||
Inv2 = maybe_expire(Inv),
|
||||
case Inv2#calendar_share_invite.status of
|
||||
pending -> core_calendar_share_invite:update_status(InviteId, cancelled);
|
||||
_ -> {error, not_pending}
|
||||
end;
|
||||
{ok, #calendar_share_invite{calendar_id = CalendarId}} ->
|
||||
{error, not_pending};
|
||||
{ok, _} ->
|
||||
{error, not_found};
|
||||
{error, _} = E -> E
|
||||
end;
|
||||
Error -> Error
|
||||
end.
|
||||
|
||||
-spec list_incoming(UserId :: binary()) -> {ok, [#calendar_share_invite{}]}.
|
||||
list_incoming(UserId) ->
|
||||
case core_user:get_by_id(UserId) of
|
||||
{ok, #user{email = Email}} ->
|
||||
ByUser = core_calendar_share_invite:list_by_invitee(UserId),
|
||||
ByEmail = case Email of
|
||||
<<>> -> [];
|
||||
_ -> core_calendar_share_invite:list_by_email(Email)
|
||||
end,
|
||||
Merged = lists:ukeysort(1, [{I#calendar_share_invite.id, maybe_expire(I)}
|
||||
|| I <- ByUser ++ ByEmail]),
|
||||
{ok, [I || {_, I} <- Merged]};
|
||||
{error, _} ->
|
||||
{ok, [maybe_expire(I) || I <- core_calendar_share_invite:list_by_invitee(UserId)]}
|
||||
end.
|
||||
|
||||
-spec accept(UserId :: binary(), InviteId :: binary(), Opts :: map()) ->
|
||||
{ok, #calendar_share_invite{}, #calendar_share{}} |
|
||||
{error, not_found | access_denied | not_pending | expired | term()}.
|
||||
accept(UserId, InviteId, Opts) ->
|
||||
case core_calendar_share_invite:get_by_id(InviteId) of
|
||||
{ok, Inv0} ->
|
||||
Inv = maybe_expire(Inv0),
|
||||
case Inv#calendar_share_invite.status of
|
||||
pending ->
|
||||
case can_accept(UserId, Inv) of
|
||||
true -> finalize_accept(UserId, Inv, Opts);
|
||||
false -> {error, access_denied}
|
||||
end;
|
||||
expired -> {error, expired};
|
||||
_ -> {error, not_pending}
|
||||
end;
|
||||
{error, _} = E -> E
|
||||
end.
|
||||
|
||||
-spec decline(UserId :: binary(), InviteId :: binary()) ->
|
||||
{ok, #calendar_share_invite{}} |
|
||||
{error, not_found | access_denied | not_pending | expired | term()}.
|
||||
decline(UserId, InviteId) ->
|
||||
case core_calendar_share_invite:get_by_id(InviteId) of
|
||||
{ok, Inv0} ->
|
||||
Inv = maybe_expire(Inv0),
|
||||
case Inv#calendar_share_invite.status of
|
||||
pending ->
|
||||
case can_accept(UserId, Inv) of
|
||||
true -> core_calendar_share_invite:update_status(InviteId, declined);
|
||||
false -> {error, access_denied}
|
||||
end;
|
||||
expired -> {error, expired};
|
||||
_ -> {error, not_pending}
|
||||
end;
|
||||
{error, _} = E -> E
|
||||
end.
|
||||
|
||||
-spec accept_by_token(UserId :: binary(), Token :: binary(), Opts :: map()) ->
|
||||
{ok, #calendar_share_invite{}, #calendar_share{}} |
|
||||
{error, not_found | access_denied | not_pending | expired | term()}.
|
||||
accept_by_token(UserId, Token, Opts) ->
|
||||
case core_calendar_share_invite:get_by_token(Token) of
|
||||
{ok, #calendar_share_invite{id = Id}} ->
|
||||
accept(UserId, Id, Opts);
|
||||
{error, _} = E -> E
|
||||
end.
|
||||
|
||||
-spec to_json(#calendar_share_invite{}) -> map().
|
||||
to_json(I) ->
|
||||
#{
|
||||
id => I#calendar_share_invite.id,
|
||||
calendar_id => I#calendar_share_invite.calendar_id,
|
||||
inviter_id => I#calendar_share_invite.inviter_id,
|
||||
invitee_user_id => null_if_empty(I#calendar_share_invite.invitee_user_id),
|
||||
invitee_email => null_if_empty(I#calendar_share_invite.invitee_email),
|
||||
rights => I#calendar_share_invite.rights,
|
||||
status => I#calendar_share_invite.status,
|
||||
created_at => handler_utils:datetime_to_iso8601(I#calendar_share_invite.created_at),
|
||||
expires_at => handler_utils:datetime_to_iso8601(I#calendar_share_invite.expires_at)
|
||||
}.
|
||||
|
||||
%%%===================================================================
|
||||
%%% Internal
|
||||
%%%===================================================================
|
||||
|
||||
do_create(ActorId, CalendarId, UserId, Email, Opts) ->
|
||||
Now = calendar:universal_time(),
|
||||
Expires = add_days(Now, ?INVITE_TTL_DAYS),
|
||||
Rights = normalize_rights(maps:get(rights, Opts, write)),
|
||||
case Rights of
|
||||
bad -> {error, bad_request};
|
||||
R ->
|
||||
Rec = #calendar_share_invite{
|
||||
id = infra_utils:generate_id(16),
|
||||
calendar_id = CalendarId,
|
||||
inviter_id = ActorId,
|
||||
invitee_user_id = UserId,
|
||||
invitee_email = Email,
|
||||
rights = R,
|
||||
status = pending,
|
||||
token = infra_utils:generate_id(32),
|
||||
created_at = Now,
|
||||
expires_at = Expires
|
||||
},
|
||||
case core_calendar_share_invite:create(Rec) of
|
||||
{ok, Created} ->
|
||||
notify_invite(Created),
|
||||
{ok, Created};
|
||||
Error -> Error
|
||||
end
|
||||
end.
|
||||
|
||||
notify_invite(#calendar_share_invite{invitee_user_id = UserId, calendar_id = CalId} = Inv)
|
||||
when UserId =/= <<>> ->
|
||||
Title = <<"Приглашение к календарю"/utf8>>,
|
||||
Body = <<"Вас пригласили в календарь "/utf8, CalId/binary>>,
|
||||
_ = core_notification:create(UserId, calendar_share_invite, Title, Body),
|
||||
logic_notification:notify_calendar_share_invite(UserId, Inv),
|
||||
ok;
|
||||
notify_invite(_) ->
|
||||
ok.
|
||||
|
||||
finalize_accept(UserId, #calendar_share_invite{
|
||||
id = InviteId, calendar_id = CalendarId, rights = Rights,
|
||||
invitee_user_id = PrevUser}, Opts) ->
|
||||
Mirror = resolve_mirror(CalendarId, Opts),
|
||||
case core_calendar_share:upsert(CalendarId, UserId, Rights, Mirror) of
|
||||
{ok, Share} ->
|
||||
case core_calendar_share_invite:update_status(InviteId, accepted) of
|
||||
{ok, Inv2} ->
|
||||
Inv3 = case PrevUser of
|
||||
<<>> ->
|
||||
Filled = Inv2#calendar_share_invite{invitee_user_id = UserId},
|
||||
ok = core_calendar_share_invite:write(Filled),
|
||||
Filled;
|
||||
_ -> Inv2
|
||||
end,
|
||||
{ok, Inv3, Share};
|
||||
Error -> Error
|
||||
end;
|
||||
Error -> Error
|
||||
end.
|
||||
|
||||
resolve_mirror(CalendarId, Opts) ->
|
||||
Default = case core_calendar:get_by_id(CalendarId) of
|
||||
{ok, #calendar{type = personal}} -> true;
|
||||
_ -> false
|
||||
end,
|
||||
case maps:get(mirror_to_default, Opts, Default) of
|
||||
true -> true;
|
||||
false -> false;
|
||||
_ -> Default
|
||||
end.
|
||||
|
||||
can_accept(UserId, #calendar_share_invite{invitee_user_id = UserId})
|
||||
when UserId =/= <<>> -> true;
|
||||
can_accept(UserId, #calendar_share_invite{invitee_user_id = <<>>, invitee_email = Email})
|
||||
when Email =/= <<>> ->
|
||||
case core_user:get_by_id(UserId) of
|
||||
{ok, #user{email = Email}} -> true;
|
||||
_ -> false
|
||||
end;
|
||||
can_accept(_, _) -> false.
|
||||
|
||||
already_shared(_CalendarId, <<>>) -> false;
|
||||
already_shared(CalendarId, UserId) ->
|
||||
case core_calendar_share:get(CalendarId, UserId) of
|
||||
{ok, _} -> true;
|
||||
{error, not_found} -> false
|
||||
end.
|
||||
|
||||
resolve_target(#{user_id := UserId}) when is_binary(UserId), UserId =/= <<>> ->
|
||||
case core_user:get_by_id(UserId) of
|
||||
{ok, #user{status = active, email = Email}} ->
|
||||
{ok, UserId, Email};
|
||||
{ok, #user{status = _}} ->
|
||||
{error, user_not_found};
|
||||
{error, _} ->
|
||||
{error, user_not_found}
|
||||
end;
|
||||
resolve_target(#{email := Email0}) when is_binary(Email0), Email0 =/= <<>> ->
|
||||
Email = string:trim(Email0),
|
||||
case find_user_by_email(Email) of
|
||||
{ok, #user{id = UserId, status = active}} ->
|
||||
{ok, UserId, Email};
|
||||
{ok, #user{status = _}} ->
|
||||
{ok, <<>>, Email};
|
||||
{error, not_found} ->
|
||||
{ok, <<>>, Email}
|
||||
end;
|
||||
resolve_target(_) ->
|
||||
{error, bad_request}.
|
||||
|
||||
find_user_by_email(Email) ->
|
||||
case core_user:get_by_email(Email) of
|
||||
{ok, _} = Ok -> Ok;
|
||||
{error, not_found} ->
|
||||
Lower = string:lowercase(Email),
|
||||
case Lower =:= Email of
|
||||
true -> {error, not_found};
|
||||
false -> core_user:get_by_email(Lower)
|
||||
end
|
||||
end.
|
||||
|
||||
require_can_invite(ActorId, CalendarId) ->
|
||||
case core_calendar:get_by_id(CalendarId) of
|
||||
{ok, #calendar{status = active} = Cal} ->
|
||||
case logic_calendar_share:can_manage_shares(ActorId, Cal) of
|
||||
true -> {ok, Cal};
|
||||
false -> {error, access_denied}
|
||||
end;
|
||||
{ok, _} -> {error, not_found};
|
||||
Error -> Error
|
||||
end.
|
||||
|
||||
normalize_rights(read) -> read;
|
||||
normalize_rights(write) -> write;
|
||||
normalize_rights(admin) -> admin;
|
||||
normalize_rights(<<"read">>) -> read;
|
||||
normalize_rights(<<"write">>) -> write;
|
||||
normalize_rights(<<"admin">>) -> admin;
|
||||
normalize_rights(_) -> bad.
|
||||
|
||||
maybe_expire(#calendar_share_invite{status = pending, expires_at = Exp, id = Id} = Inv) ->
|
||||
case Exp < calendar:universal_time() of
|
||||
true ->
|
||||
_ = core_calendar_share_invite:update_status(Id, expired),
|
||||
Inv#calendar_share_invite{status = expired};
|
||||
false -> Inv
|
||||
end;
|
||||
maybe_expire(Inv) -> Inv.
|
||||
|
||||
add_days(DateTime, Days) ->
|
||||
Sec = calendar:datetime_to_gregorian_seconds(DateTime) + Days * 86400,
|
||||
calendar:gregorian_seconds_to_datetime(Sec).
|
||||
|
||||
null_if_empty(<<>>) -> null;
|
||||
null_if_empty(V) -> V.
|
||||
@@ -5,7 +5,7 @@
|
||||
-export([notify_calendar_update/1]).
|
||||
-export([notify_event_update/1]).
|
||||
-export([notify_admin/2]).
|
||||
-export([notify_specialist_invite/2]).
|
||||
-export([notify_specialist_invite/2, notify_calendar_share_invite/2]).
|
||||
-export([notify_event_reminder/2]).
|
||||
-export([notify_waitlist_promoted/2]).
|
||||
|
||||
@@ -47,6 +47,16 @@ notify_specialist_invite(UserId, Invite) ->
|
||||
},
|
||||
broadcast_to_user(UserId, specialist_invite, Data).
|
||||
|
||||
%% In-app / WS: приглашение calendar_share
|
||||
notify_calendar_share_invite(UserId, Invite) ->
|
||||
Data = #{
|
||||
invite_id => Invite#calendar_share_invite.id,
|
||||
calendar_id => Invite#calendar_share_invite.calendar_id,
|
||||
rights => Invite#calendar_share_invite.rights,
|
||||
status => Invite#calendar_share_invite.status
|
||||
},
|
||||
broadcast_to_user(UserId, calendar_share_invite, Data).
|
||||
|
||||
%% In-app / WS: напоминание о записи (Back#70)
|
||||
notify_event_reminder(UserId, #{title := Title, when_text := WhenText} = Data) ->
|
||||
NTitle = <<"Напоминание о записи"/utf8>>,
|
||||
|
||||
@@ -191,10 +191,13 @@ filter_accessible_calendars(Calendars, UserId) ->
|
||||
end, Calendars).
|
||||
|
||||
%% Restricted commercial не показываем в search/discovery (deep-link остаётся).
|
||||
%% Personal всегда приватны — только list/deep-link для owner|share.
|
||||
calendar_discoverable(#calendar{type = commercial} = C) ->
|
||||
logic_calendar:booking_open(C);
|
||||
calendar_discoverable(#calendar{type = personal}) ->
|
||||
false;
|
||||
calendar_discoverable(_) ->
|
||||
true.
|
||||
false.
|
||||
|
||||
%% @doc Builds a #{calendar_id => #calendar{}} map using dirty_read
|
||||
%% (key-based lookups). Used to batch-fetch calendars and avoid N+1
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
%% @doc calendar_share: id PK + mirror_to_default; create calendar_share_invite.
|
||||
-module('20260815220000_calendar_share_invite').
|
||||
|
||||
-export([up/0, down/0]).
|
||||
|
||||
-include("records.hrl").
|
||||
|
||||
up() ->
|
||||
transform_calendar_share(),
|
||||
ensure_index(calendar_share, calendar_id),
|
||||
ensure_index(calendar_share, user_id),
|
||||
ensure_table(calendar_share_invite, record_info(fields, calendar_share_invite)),
|
||||
ensure_index(calendar_share_invite, calendar_id),
|
||||
ensure_index(calendar_share_invite, invitee_user_id),
|
||||
ensure_index(calendar_share_invite, invitee_email),
|
||||
ensure_index(calendar_share_invite, token),
|
||||
ensure_index(calendar_share_invite, status),
|
||||
ok.
|
||||
|
||||
down() ->
|
||||
_ = mnesia:delete_table(calendar_share_invite),
|
||||
ok.
|
||||
|
||||
transform_calendar_share() ->
|
||||
case lists:member(calendar_share, mnesia:system_info(tables)) of
|
||||
false ->
|
||||
ensure_table(calendar_share, record_info(fields, calendar_share));
|
||||
true ->
|
||||
Attrs = mnesia:table_info(calendar_share, attributes),
|
||||
case Attrs =:= record_info(fields, calendar_share) of
|
||||
true ->
|
||||
ok;
|
||||
false ->
|
||||
Fun = fun(Rec) ->
|
||||
case tuple_to_list(Rec) of
|
||||
[calendar_share, CalId, UserId, Rights] ->
|
||||
#calendar_share{
|
||||
id = infra_utils:generate_id(16),
|
||||
calendar_id = CalId,
|
||||
user_id = UserId,
|
||||
rights = Rights,
|
||||
mirror_to_default = false
|
||||
};
|
||||
[calendar_share, Id, CalId, UserId, Rights] ->
|
||||
#calendar_share{
|
||||
id = Id,
|
||||
calendar_id = CalId,
|
||||
user_id = UserId,
|
||||
rights = Rights,
|
||||
mirror_to_default = false
|
||||
};
|
||||
_ ->
|
||||
Rec
|
||||
end
|
||||
end,
|
||||
case mnesia:transform_table(calendar_share, Fun,
|
||||
record_info(fields, calendar_share)) of
|
||||
{atomic, ok} -> ok;
|
||||
{aborted, Reason} -> error({transform_calendar_share_failed, Reason})
|
||||
end
|
||||
end
|
||||
end.
|
||||
|
||||
ensure_table(Table, Attrs) ->
|
||||
case lists:member(Table, mnesia:system_info(tables)) of
|
||||
true ->
|
||||
ok;
|
||||
false ->
|
||||
case mnesia:create_table(Table, [{disc_copies, [node()]}, {attributes, Attrs}]) of
|
||||
{atomic, ok} -> ok;
|
||||
{aborted, {already_exists, Table}} -> ok;
|
||||
{aborted, Reason} -> error({create_table_failed, Table, Reason})
|
||||
end
|
||||
end.
|
||||
|
||||
ensure_index(Table, Attr) ->
|
||||
case lists:member(Table, mnesia:system_info(tables)) of
|
||||
false ->
|
||||
ok;
|
||||
true ->
|
||||
case mnesia:add_table_index(Table, Attr) of
|
||||
{atomic, ok} -> ok;
|
||||
{aborted, {already_exists, Table, _Pos}} -> ok;
|
||||
{aborted, {already_exists, Table, Attr}} -> ok;
|
||||
{aborted, {already_exists, _}} -> ok;
|
||||
{aborted, Reason} -> error({add_index_failed, Table, Attr, Reason})
|
||||
end
|
||||
end.
|
||||
@@ -143,7 +143,11 @@ table_opts(admin) ->
|
||||
table_opts(calendar) ->
|
||||
[{ram_copies, [node()]}, {attributes, record_info(fields, calendar)}];
|
||||
table_opts(calendar_share) ->
|
||||
[{ram_copies, [node()]}, {attributes, record_info(fields, calendar_share)}];
|
||||
[{ram_copies, [node()]}, {attributes, record_info(fields, calendar_share)},
|
||||
{index, [calendar_id, user_id]}];
|
||||
table_opts(calendar_share_invite) ->
|
||||
[{ram_copies, [node()]}, {attributes, record_info(fields, calendar_share_invite)},
|
||||
{index, [calendar_id, invitee_user_id, invitee_email, token, status]}];
|
||||
table_opts(calendar_follow) ->
|
||||
[{ram_copies, [node()]}, {attributes, record_info(fields, calendar_follow)}];
|
||||
table_opts(calendar_specialist) ->
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
-module(logic_calendar_share_tests).
|
||||
-include_lib("eunit/include/eunit.hrl").
|
||||
-include("records.hrl").
|
||||
|
||||
-define(TABLES, [user, calendar, calendar_share, calendar_share_invite, notification, subscription]).
|
||||
|
||||
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_calendar_share_test_() ->
|
||||
{foreach, fun setup/0, fun cleanup/1, [
|
||||
{"personal invite accept mirror default", fun test_personal_invite_accept/0},
|
||||
{"commercial deputy write can_edit", fun test_commercial_deputy/0},
|
||||
{"decline invite", fun test_decline/0},
|
||||
{"duplicate pending", fun test_duplicate_pending/0},
|
||||
{"revoke share", fun test_revoke/0},
|
||||
{"personal access via share read", fun test_personal_share_access/0},
|
||||
{"list calendars includes shared", fun test_list_includes_shared/0},
|
||||
{"search excludes personal", fun test_search_excludes_personal/0}
|
||||
]}.
|
||||
|
||||
seed_owner_personal() ->
|
||||
Id = base64:encode(crypto:strong_rand_bytes(12), #{padding => false}),
|
||||
Owner = eh_test_support:make_user(#{
|
||||
id => Id, email => <<"own-", Id/binary, "@ex.com">>, status => active}),
|
||||
mnesia:dirty_write(Owner),
|
||||
{ok, Cal} = core_calendar:create(Owner#user.id, <<"Default">>, <<>>, manual, personal),
|
||||
{Owner#user.id, Cal#calendar.id}.
|
||||
|
||||
seed_owner_commercial() ->
|
||||
Id = base64:encode(crypto:strong_rand_bytes(12), #{padding => false}),
|
||||
Owner = eh_test_support:make_user(#{
|
||||
id => Id, email => <<"co-", Id/binary, "@ex.com">>, status => active}),
|
||||
mnesia:dirty_write(Owner),
|
||||
{ok, _} = core_subscription:create(Owner#user.id, monthly, true),
|
||||
{ok, Cal} = core_calendar:create(Owner#user.id, <<"Studio">>, <<>>, manual, commercial),
|
||||
{Owner#user.id, Cal#calendar.id}.
|
||||
|
||||
make_active_user(Email) ->
|
||||
Id = base64:encode(crypto:strong_rand_bytes(12), #{padding => false}),
|
||||
U = eh_test_support:make_user(#{
|
||||
id => Id, email => Email, status => active, nickname => <<"n-", Id/binary>>}),
|
||||
mnesia:dirty_write(U),
|
||||
U.
|
||||
|
||||
test_personal_invite_accept() ->
|
||||
{OwnerId, CalId} = seed_owner_personal(),
|
||||
Co = make_active_user(<<"co@ex.com">>),
|
||||
{ok, Inv} = logic_calendar_share_invite:create(OwnerId, CalId,
|
||||
#{user_id => Co#user.id}, #{rights => write}),
|
||||
?assertEqual(pending, Inv#calendar_share_invite.status),
|
||||
{ok, Inv2, Share} = logic_calendar_share_invite:accept(Co#user.id,
|
||||
Inv#calendar_share_invite.id, #{}),
|
||||
?assertEqual(accepted, Inv2#calendar_share_invite.status),
|
||||
?assertEqual(write, Share#calendar_share.rights),
|
||||
?assertEqual(true, Share#calendar_share.mirror_to_default),
|
||||
{ok, Cal} = core_calendar:get_by_id(CalId),
|
||||
?assert(logic_calendar:can_edit(Co#user.id, Cal)),
|
||||
?assert(logic_calendar:can_access(Co#user.id, Cal)).
|
||||
|
||||
test_commercial_deputy() ->
|
||||
{OwnerId, CalId} = seed_owner_commercial(),
|
||||
Dep = make_active_user(<<"dep@ex.com">>),
|
||||
{ok, Inv} = logic_calendar_share_invite:create(OwnerId, CalId,
|
||||
#{user_id => Dep#user.id}, #{rights => write}),
|
||||
{ok, _, Share} = logic_calendar_share_invite:accept(Dep#user.id,
|
||||
Inv#calendar_share_invite.id, #{}),
|
||||
?assertEqual(false, Share#calendar_share.mirror_to_default),
|
||||
{ok, Cal} = core_calendar:get_by_id(CalId),
|
||||
?assert(logic_calendar:can_edit(Dep#user.id, Cal)),
|
||||
?assertEqual({error, access_denied},
|
||||
logic_calendar:delete_calendar(Dep#user.id, CalId)).
|
||||
|
||||
test_decline() ->
|
||||
{OwnerId, CalId} = seed_owner_personal(),
|
||||
U = make_active_user(<<"d@ex.com">>),
|
||||
{ok, Inv} = logic_calendar_share_invite:create(OwnerId, CalId,
|
||||
#{user_id => U#user.id}, #{}),
|
||||
{ok, Inv2} = logic_calendar_share_invite:decline(U#user.id, Inv#calendar_share_invite.id),
|
||||
?assertEqual(declined, Inv2#calendar_share_invite.status),
|
||||
?assertEqual({error, not_found}, core_calendar_share:get(CalId, U#user.id)).
|
||||
|
||||
test_duplicate_pending() ->
|
||||
{OwnerId, CalId} = seed_owner_personal(),
|
||||
U = make_active_user(<<"dup@ex.com">>),
|
||||
{ok, _} = logic_calendar_share_invite:create(OwnerId, CalId, #{user_id => U#user.id}, #{}),
|
||||
?assertEqual({error, already_pending},
|
||||
logic_calendar_share_invite:create(OwnerId, CalId, #{user_id => U#user.id}, #{})).
|
||||
|
||||
test_revoke() ->
|
||||
{OwnerId, CalId} = seed_owner_personal(),
|
||||
U = make_active_user(<<"rev@ex.com">>),
|
||||
{ok, Inv} = logic_calendar_share_invite:create(OwnerId, CalId, #{user_id => U#user.id}, #{}),
|
||||
{ok, _, _} = logic_calendar_share_invite:accept(U#user.id, Inv#calendar_share_invite.id, #{}),
|
||||
ok = logic_calendar_share:revoke(OwnerId, CalId, U#user.id),
|
||||
{ok, Cal} = core_calendar:get_by_id(CalId),
|
||||
?assertNot(logic_calendar:can_access(U#user.id, Cal)).
|
||||
|
||||
test_personal_share_access() ->
|
||||
{OwnerId, CalId} = seed_owner_personal(),
|
||||
U = make_active_user(<<"read@ex.com">>),
|
||||
Stranger = make_active_user(<<"str@ex.com">>),
|
||||
{ok, Inv} = logic_calendar_share_invite:create(OwnerId, CalId,
|
||||
#{user_id => U#user.id}, #{rights => read}),
|
||||
{ok, _, _} = logic_calendar_share_invite:accept(U#user.id, Inv#calendar_share_invite.id,
|
||||
#{mirror_to_default => false}),
|
||||
{ok, Cal} = core_calendar:get_by_id(CalId),
|
||||
?assert(logic_calendar:can_access(U#user.id, Cal)),
|
||||
?assertNot(logic_calendar:can_edit(U#user.id, Cal)),
|
||||
?assertNot(logic_calendar:can_access(Stranger#user.id, Cal)).
|
||||
|
||||
test_list_includes_shared() ->
|
||||
{OwnerId, CalId} = seed_owner_personal(),
|
||||
U = make_active_user(<<"list@ex.com">>),
|
||||
{ok, Inv} = logic_calendar_share_invite:create(OwnerId, CalId, #{user_id => U#user.id}, #{}),
|
||||
{ok, _, _} = logic_calendar_share_invite:accept(U#user.id, Inv#calendar_share_invite.id, #{}),
|
||||
{ok, List} = logic_calendar:list_calendars(U#user.id),
|
||||
Ids = [C#calendar.id || C <- List],
|
||||
?assert(lists:member(CalId, Ids)).
|
||||
|
||||
test_search_excludes_personal() ->
|
||||
{_OwnerId, CalId} = seed_owner_personal(),
|
||||
{ok, Cal} = core_calendar:get_by_id(CalId),
|
||||
?assertEqual(false, logic_search_discoverable(Cal)).
|
||||
|
||||
%% Access private helper via same rule as logic_search (duplicated check).
|
||||
logic_search_discoverable(#calendar{type = personal}) -> false;
|
||||
logic_search_discoverable(#calendar{type = commercial} = C) ->
|
||||
logic_calendar:booking_open(C);
|
||||
logic_search_discoverable(_) -> false.
|
||||
Reference in New Issue
Block a user