feat: specialist_invite API + user lookup. Fixes EventHub/EventHubBack#55
CI / test (push) Successful in 6m47s
CI / deploy-ift (push) Successful in 3m23s
CI / e2e-ift (push) Successful in 1m58s
CI / deploy-stage (push) Successful in 2m1s
CI / e2e-stage (push) Successful in 1m13s

This commit is contained in:
2026-07-22 18:52:35 +03:00
parent af5f506866
commit 571f04737d
18 changed files with 1000 additions and 6 deletions
+319
View File
@@ -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.