feat: specialist_invite API + user lookup. Fixes EventHub/EventHubBack#55
This commit is contained in:
Regular → Executable
+6
-2
@@ -1,4 +1,8 @@
|
||||
-module(logic_email).
|
||||
-export([send_verification_email/2]).
|
||||
-export([send_verification_email/2, send_specialist_invite/2]).
|
||||
|
||||
send_verification_email(Email, Token) ->
|
||||
io:format("Sending verification email to ~s with token ~s~n", [Email, Token]).
|
||||
io:format("Sending verification email to ~s with token ~s~n", [Email, Token]).
|
||||
|
||||
send_specialist_invite(Email, Token) ->
|
||||
io:format("Sending specialist invite email to ~s with token ~s~n", [Email, Token]).
|
||||
|
||||
Regular → Executable
+10
@@ -5,6 +5,7 @@
|
||||
-export([notify_calendar_update/1]).
|
||||
-export([notify_event_update/1]).
|
||||
-export([notify_admin/2]).
|
||||
-export([notify_specialist_invite/2]).
|
||||
|
||||
%% Уведомление о бронировании
|
||||
notify_booking(UserId, Booking) ->
|
||||
@@ -35,6 +36,15 @@ notify_event_update(Event) ->
|
||||
},
|
||||
broadcast_to_calendar_subscribers(Event#event.calendar_id, event_update, Data).
|
||||
|
||||
%% In-app / WS: приглашение специалиста
|
||||
notify_specialist_invite(UserId, Invite) ->
|
||||
Data = #{
|
||||
invite_id => Invite#specialist_invite.id,
|
||||
calendar_id => Invite#specialist_invite.calendar_id,
|
||||
status => Invite#specialist_invite.status
|
||||
},
|
||||
broadcast_to_user(UserId, specialist_invite, Data).
|
||||
|
||||
%% Уведомление для администраторов
|
||||
notify_admin(Type, Data) ->
|
||||
Message = {admin_notification, Type, Data},
|
||||
|
||||
Executable
+319
@@ -0,0 +1,319 @@
|
||||
%%%-------------------------------------------------------------------
|
||||
%%% @doc Приглашения специалистов commercial-календаря (Spec §2.1.2).
|
||||
%%% @end
|
||||
%%%-------------------------------------------------------------------
|
||||
-module(logic_specialist_invite).
|
||||
-include("records.hrl").
|
||||
|
||||
-export([
|
||||
create/4,
|
||||
list_outgoing/2,
|
||||
cancel/3,
|
||||
list_incoming/1,
|
||||
accept/2,
|
||||
decline/2,
|
||||
accept_by_token/2,
|
||||
to_json/1
|
||||
]).
|
||||
|
||||
-define(INVITE_TTL_DAYS, 7).
|
||||
|
||||
-spec create(OwnerId :: binary(), CalendarId :: binary(), Target :: map(),
|
||||
Opts :: map()) ->
|
||||
{ok, #specialist_invite{}} |
|
||||
{error, not_found | access_denied | not_commercial | subscription_inactive |
|
||||
user_not_found | already_specialist | already_pending | bad_request | term()}.
|
||||
create(OwnerId, CalendarId, Target, Opts) ->
|
||||
case require_owner_can_invite(OwnerId, CalendarId) of
|
||||
{ok, _Cal} ->
|
||||
case resolve_target(Target) of
|
||||
{error, _} = E -> E;
|
||||
{ok, UserId, Email} ->
|
||||
case already_active_specialist(CalendarId, UserId) of
|
||||
true ->
|
||||
{error, already_specialist};
|
||||
false ->
|
||||
case core_specialist_invite:find_pending(CalendarId, UserId, Email) of
|
||||
[_ | _] ->
|
||||
{error, already_pending};
|
||||
[] ->
|
||||
do_create(OwnerId, CalendarId, UserId, Email, Opts)
|
||||
end
|
||||
end
|
||||
end;
|
||||
Error -> Error
|
||||
end.
|
||||
|
||||
-spec list_outgoing(OwnerId :: binary(), CalendarId :: binary()) ->
|
||||
{ok, [#specialist_invite{}]} |
|
||||
{error, not_found | access_denied | not_commercial}.
|
||||
list_outgoing(OwnerId, CalendarId) ->
|
||||
case require_owner_commercial(OwnerId, CalendarId) of
|
||||
{ok, _} ->
|
||||
List = [maybe_expire(I) || I <- core_specialist_invite:list_by_calendar(CalendarId)],
|
||||
{ok, List};
|
||||
Error -> Error
|
||||
end.
|
||||
|
||||
-spec cancel(OwnerId :: binary(), CalendarId :: binary(), InviteId :: binary()) ->
|
||||
{ok, #specialist_invite{}} |
|
||||
{error, not_found | access_denied | not_commercial | not_pending | term()}.
|
||||
cancel(OwnerId, CalendarId, InviteId) ->
|
||||
case require_owner_commercial(OwnerId, CalendarId) of
|
||||
{ok, _} ->
|
||||
case core_specialist_invite:get_by_id(InviteId) of
|
||||
{ok, #specialist_invite{calendar_id = CalendarId, status = pending} = Inv} ->
|
||||
Inv2 = maybe_expire(Inv),
|
||||
case Inv2#specialist_invite.status of
|
||||
pending -> core_specialist_invite:update_status(InviteId, cancelled);
|
||||
_ -> {error, not_pending}
|
||||
end;
|
||||
{ok, #specialist_invite{calendar_id = CalendarId}} ->
|
||||
{error, not_pending};
|
||||
{ok, _} ->
|
||||
{error, not_found};
|
||||
{error, _} = E -> E
|
||||
end;
|
||||
Error -> Error
|
||||
end.
|
||||
|
||||
-spec list_incoming(UserId :: binary()) -> {ok, [#specialist_invite{}]}.
|
||||
list_incoming(UserId) ->
|
||||
case core_user:get_by_id(UserId) of
|
||||
{ok, #user{email = Email}} ->
|
||||
ByUser = core_specialist_invite:list_by_invitee(UserId),
|
||||
ByEmail = case Email of
|
||||
<<>> -> [];
|
||||
_ -> core_specialist_invite:list_by_email(Email)
|
||||
end,
|
||||
Merged = lists:ukeysort(1, [{I#specialist_invite.id, maybe_expire(I)}
|
||||
|| I <- ByUser ++ ByEmail]),
|
||||
{ok, [I || {_, I} <- Merged]};
|
||||
{error, _} ->
|
||||
{ok, [maybe_expire(I) || I <- core_specialist_invite:list_by_invitee(UserId)]}
|
||||
end.
|
||||
|
||||
-spec accept(UserId :: binary(), InviteId :: binary()) ->
|
||||
{ok, #specialist_invite{}, #calendar_specialist{}} |
|
||||
{error, not_found | access_denied | not_pending | expired | term()}.
|
||||
accept(UserId, InviteId) ->
|
||||
case core_specialist_invite:get_by_id(InviteId) of
|
||||
{ok, Inv0} ->
|
||||
Inv = maybe_expire(Inv0),
|
||||
case Inv#specialist_invite.status of
|
||||
pending ->
|
||||
case can_accept(UserId, Inv) of
|
||||
true -> finalize_accept(UserId, Inv);
|
||||
false -> {error, access_denied}
|
||||
end;
|
||||
expired -> {error, expired};
|
||||
_ -> {error, not_pending}
|
||||
end;
|
||||
{error, _} = E -> E
|
||||
end.
|
||||
|
||||
-spec decline(UserId :: binary(), InviteId :: binary()) ->
|
||||
{ok, #specialist_invite{}} |
|
||||
{error, not_found | access_denied | not_pending | expired | term()}.
|
||||
decline(UserId, InviteId) ->
|
||||
case core_specialist_invite:get_by_id(InviteId) of
|
||||
{ok, Inv0} ->
|
||||
Inv = maybe_expire(Inv0),
|
||||
case Inv#specialist_invite.status of
|
||||
pending ->
|
||||
case can_accept(UserId, Inv) of
|
||||
true -> core_specialist_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()) ->
|
||||
{ok, #specialist_invite{}, #calendar_specialist{}} |
|
||||
{error, not_found | access_denied | not_pending | expired | term()}.
|
||||
accept_by_token(UserId, Token) ->
|
||||
case core_specialist_invite:get_by_token(Token) of
|
||||
{ok, #specialist_invite{id = Id}} ->
|
||||
accept(UserId, Id);
|
||||
{error, _} = E -> E
|
||||
end.
|
||||
|
||||
-spec to_json(#specialist_invite{}) -> map().
|
||||
to_json(I) ->
|
||||
#{
|
||||
id => I#specialist_invite.id,
|
||||
calendar_id => I#specialist_invite.calendar_id,
|
||||
inviter_id => I#specialist_invite.inviter_id,
|
||||
invitee_user_id => null_if_empty(I#specialist_invite.invitee_user_id),
|
||||
invitee_email => null_if_empty(I#specialist_invite.invitee_email),
|
||||
name => I#specialist_invite.name,
|
||||
specialization => I#specialist_invite.specialization,
|
||||
status => I#specialist_invite.status,
|
||||
created_at => handler_utils:datetime_to_iso8601(I#specialist_invite.created_at),
|
||||
expires_at => handler_utils:datetime_to_iso8601(I#specialist_invite.expires_at)
|
||||
}.
|
||||
|
||||
%%%===================================================================
|
||||
%%% Internal
|
||||
%%%===================================================================
|
||||
|
||||
do_create(OwnerId, CalendarId, UserId, Email, Opts) ->
|
||||
Now = calendar:universal_time(),
|
||||
Expires = add_days(Now, ?INVITE_TTL_DAYS),
|
||||
Name = maps:get(name, Opts, <<>>),
|
||||
Specs = maps:get(specialization, Opts, []),
|
||||
Rec = #specialist_invite{
|
||||
id = infra_utils:generate_id(16),
|
||||
calendar_id = CalendarId,
|
||||
inviter_id = OwnerId,
|
||||
invitee_user_id = UserId,
|
||||
invitee_email = Email,
|
||||
name = Name,
|
||||
specialization = Specs,
|
||||
status = pending,
|
||||
token = infra_utils:generate_id(32),
|
||||
created_at = Now,
|
||||
expires_at = Expires
|
||||
},
|
||||
case core_specialist_invite:create(Rec) of
|
||||
{ok, Created} ->
|
||||
notify_invite(Created),
|
||||
{ok, Created};
|
||||
Error -> Error
|
||||
end.
|
||||
|
||||
notify_invite(#specialist_invite{invitee_user_id = UserId, calendar_id = CalId,
|
||||
token = Token} = Inv) when UserId =/= <<>> ->
|
||||
Title = <<"Приглашение стать специалистом">>,
|
||||
Body = <<"Вас пригласили в календарь ", CalId/binary>>,
|
||||
_ = core_notification:create(UserId, specialist_invite, Title, Body),
|
||||
logic_notification:notify_specialist_invite(UserId, Inv),
|
||||
maybe_email(Inv, Token);
|
||||
notify_invite(#specialist_invite{token = Token} = Inv) ->
|
||||
maybe_email(Inv, Token).
|
||||
|
||||
maybe_email(#specialist_invite{invitee_email = Email, token = Token}, _)
|
||||
when Email =/= <<>> ->
|
||||
logic_email:send_specialist_invite(Email, Token);
|
||||
maybe_email(#specialist_invite{invitee_user_id = UserId, token = Token}, _)
|
||||
when UserId =/= <<>> ->
|
||||
case core_user:get_by_id(UserId) of
|
||||
{ok, #user{email = Email}} when Email =/= <<>> ->
|
||||
logic_email:send_specialist_invite(Email, Token);
|
||||
_ -> ok
|
||||
end;
|
||||
maybe_email(_, _) -> ok.
|
||||
|
||||
finalize_accept(UserId, #specialist_invite{
|
||||
id = InviteId, calendar_id = CalendarId, name = Name,
|
||||
specialization = Specs, invitee_user_id = PrevUser}) ->
|
||||
SpecRes = case core_calendar_specialist:create(CalendarId, UserId, Name, Specs) of
|
||||
{ok, S} -> {ok, S};
|
||||
{error, already_exists} ->
|
||||
core_calendar_specialist:update(CalendarId, UserId,
|
||||
[{status, active}, {name, Name}, {specialization, Specs}])
|
||||
end,
|
||||
case SpecRes of
|
||||
{ok, Spec} ->
|
||||
case core_specialist_invite:update_status(InviteId, accepted) of
|
||||
{ok, Inv2} ->
|
||||
Inv3 = case PrevUser of
|
||||
<<>> ->
|
||||
Filled = Inv2#specialist_invite{invitee_user_id = UserId},
|
||||
ok = mnesia:dirty_write(Filled),
|
||||
Filled;
|
||||
_ -> Inv2
|
||||
end,
|
||||
{ok, Inv3, Spec};
|
||||
Error -> Error
|
||||
end;
|
||||
Error -> Error
|
||||
end.
|
||||
|
||||
can_accept(UserId, #specialist_invite{invitee_user_id = UserId})
|
||||
when UserId =/= <<>> -> true;
|
||||
can_accept(UserId, #specialist_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_active_specialist(<<>>, _) -> false;
|
||||
already_active_specialist(CalendarId, UserId) when UserId =/= <<>> ->
|
||||
core_calendar_specialist:is_active_specialist(CalendarId, UserId);
|
||||
already_active_specialist(_, _) -> false.
|
||||
|
||||
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_owner_can_invite(OwnerId, CalendarId) ->
|
||||
case require_owner_commercial(OwnerId, CalendarId) of
|
||||
{ok, Cal} ->
|
||||
case logic_calendar:booking_open(Cal) of
|
||||
true -> {ok, Cal};
|
||||
false -> {error, subscription_inactive}
|
||||
end;
|
||||
Error -> Error
|
||||
end.
|
||||
|
||||
require_owner_commercial(OwnerId, CalendarId) ->
|
||||
case core_calendar:get_by_id(CalendarId) of
|
||||
{ok, #calendar{owner_id = OwnerId, type = commercial, status = active} = Cal} ->
|
||||
{ok, Cal};
|
||||
{ok, #calendar{owner_id = OwnerId, type = personal}} ->
|
||||
{error, not_commercial};
|
||||
{ok, _} ->
|
||||
{error, access_denied};
|
||||
Error -> Error
|
||||
end.
|
||||
|
||||
maybe_expire(#specialist_invite{status = pending, expires_at = Exp, id = Id} = Inv) ->
|
||||
case Exp < calendar:universal_time() of
|
||||
true ->
|
||||
_ = core_specialist_invite:update_status(Id, expired),
|
||||
Inv#specialist_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.
|
||||
Executable
+54
@@ -0,0 +1,54 @@
|
||||
%%%-------------------------------------------------------------------
|
||||
%%% @doc Lookup пользователей для typeahead (specialist invite).
|
||||
%%% @end
|
||||
%%%-------------------------------------------------------------------
|
||||
-module(logic_user_lookup).
|
||||
-include("records.hrl").
|
||||
|
||||
-export([lookup/1]).
|
||||
|
||||
-define(MAX_RESULTS, 20).
|
||||
-define(MIN_Q, 2).
|
||||
|
||||
-spec lookup(Q :: binary()) -> {ok, [map()]} | {error, bad_request}.
|
||||
lookup(Q0) when is_binary(Q0) ->
|
||||
Q = string:trim(Q0),
|
||||
case byte_size(Q) < ?MIN_Q of
|
||||
true -> {error, bad_request};
|
||||
false ->
|
||||
QLower = string:lowercase(Q),
|
||||
Users = [U || U <- mnesia:dirty_match_object(#user{_ = '_'}),
|
||||
U#user.status =:= active],
|
||||
ExactEmail = [U || U <- Users, string:lowercase(U#user.email) =:= QLower],
|
||||
NickPrefix = [U || U <- Users, is_nick_prefix(QLower, U#user.nickname)],
|
||||
Merged = unique_by_id(ExactEmail ++ NickPrefix),
|
||||
Limited = lists:sublist(Merged, ?MAX_RESULTS),
|
||||
{ok, [to_public(U, string:lowercase(U#user.email) =:= QLower) || U <- Limited]}
|
||||
end;
|
||||
lookup(_) ->
|
||||
{error, bad_request}.
|
||||
|
||||
is_nick_prefix(QLower, Nick) when is_binary(Nick) ->
|
||||
Lower = string:lowercase(Nick),
|
||||
byte_size(Lower) >= byte_size(QLower) andalso
|
||||
binary:part(Lower, 0, byte_size(QLower)) =:= QLower;
|
||||
is_nick_prefix(_, _) -> false.
|
||||
|
||||
unique_by_id(Users) ->
|
||||
maps:values(lists:foldl(fun(U, Acc) ->
|
||||
maps:put(U#user.id, U, Acc)
|
||||
end, #{}, Users)).
|
||||
|
||||
to_public(#user{id = Id, nickname = Nick, email = Email}, true) ->
|
||||
#{id => Id, nickname => Nick, email => Email};
|
||||
to_public(#user{id = Id, nickname = Nick, email = Email}, false) ->
|
||||
#{id => Id, nickname => Nick, email => mask_email(Email)}.
|
||||
|
||||
mask_email(<<>>) -> <<>>;
|
||||
mask_email(Email) ->
|
||||
case binary:split(Email, <<"@">>) of
|
||||
[Local, Domain] when byte_size(Local) > 0 ->
|
||||
First = binary:part(Local, 0, 1),
|
||||
<<First/binary, "***@", Domain/binary>>;
|
||||
_ -> <<"***">>
|
||||
end.
|
||||
Reference in New Issue
Block a user