diff --git a/include/records.hrl b/include/records.hrl index f5407d9..f8bcfd0 100755 --- a/include/records.hrl +++ b/include/records.hrl @@ -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) diff --git a/src/core/core_calendar_share.erl b/src/core/core_calendar_share.erl new file mode 100644 index 0000000..14f415a --- /dev/null +++ b/src/core/core_calendar_share.erl @@ -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, _ = '_'}). diff --git a/src/core/core_calendar_share_invite.erl b/src/core/core_calendar_share_invite.erl new file mode 100644 index 0000000..8dc9123 --- /dev/null +++ b/src/core/core_calendar_share_invite.erl @@ -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. diff --git a/src/eventhub_app.erl b/src/eventhub_app.erl index e425059..f39767b 100755 --- a/src/eventhub_app.erl +++ b/src/eventhub_app.erl @@ -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, []}, diff --git a/src/handlers/handler_calendar_share_invites.erl b/src/handlers/handler_calendar_share_invites.erl new file mode 100644 index 0000000..cdd4d3d --- /dev/null +++ b/src/handlers/handler_calendar_share_invites.erl @@ -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">>). diff --git a/src/handlers/handler_calendar_shares.erl b/src/handlers/handler_calendar_shares.erl new file mode 100644 index 0000000..dcaecc3 --- /dev/null +++ b/src/handlers/handler_calendar_shares.erl @@ -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">>). diff --git a/src/handlers/handler_calendars.erl b/src/handlers/handler_calendars.erl index a06e3ec..e346297 100644 --- a/src/handlers/handler_calendars.erl +++ b/src/handlers/handler_calendars.erl @@ -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), diff --git a/src/handlers/handler_share_invites.erl b/src/handlers/handler_share_invites.erl new file mode 100644 index 0000000..c49c8d6 --- /dev/null +++ b/src/handlers/handler_share_invites.erl @@ -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">>). diff --git a/src/infra/infra_mnesia.erl b/src/infra/infra_mnesia.erl index 43d7c58..2458018 100755 --- a/src/infra/infra_mnesia.erl +++ b/src/infra/infra_mnesia.erl @@ -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)}]; diff --git a/src/logic/logic_calendar.erl b/src/logic/logic_calendar.erl index 3cd463e..7239aba 100755 --- a/src/logic/logic_calendar.erl +++ b/src/logic/logic_calendar.erl @@ -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. diff --git a/src/logic/logic_calendar_share.erl b/src/logic/logic_calendar_share.erl new file mode 100644 index 0000000..01d88f2 --- /dev/null +++ b/src/logic/logic_calendar_share.erl @@ -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. diff --git a/src/logic/logic_calendar_share_invite.erl b/src/logic/logic_calendar_share_invite.erl new file mode 100644 index 0000000..c86c553 --- /dev/null +++ b/src/logic/logic_calendar_share_invite.erl @@ -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. diff --git a/src/logic/logic_notification.erl b/src/logic/logic_notification.erl index 0200683..ecda88e 100755 --- a/src/logic/logic_notification.erl +++ b/src/logic/logic_notification.erl @@ -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>>, diff --git a/src/logic/logic_search.erl b/src/logic/logic_search.erl index f46a20d..12cc7f1 100755 --- a/src/logic/logic_search.erl +++ b/src/logic/logic_search.erl @@ -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 diff --git a/src/migrations/20260815220000_calendar_share_invite.erl b/src/migrations/20260815220000_calendar_share_invite.erl new file mode 100644 index 0000000..7f0a00d --- /dev/null +++ b/src/migrations/20260815220000_calendar_share_invite.erl @@ -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. diff --git a/test/unit/eh_test_support.erl b/test/unit/eh_test_support.erl index bf8b08f..ea92520 100755 --- a/test/unit/eh_test_support.erl +++ b/test/unit/eh_test_support.erl @@ -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) -> diff --git a/test/unit/logic_calendar_share_tests.erl b/test/unit/logic_calendar_share_tests.erl new file mode 100644 index 0000000..09454a9 --- /dev/null +++ b/test/unit/logic_calendar_share_tests.erl @@ -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.