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
+18 -1
View File
@@ -125,6 +125,22 @@
updated_at :: calendar:datetime()
}).
%% ------------------- Приглашения специалистов ------------------------
%% PK = id. Pending-уникальность (calendar + user|email) — в logic.
-record(specialist_invite, {
id :: binary(),
calendar_id :: binary(),
inviter_id :: binary(),
invitee_user_id :: binary(), % <<>> если ещё неизвестен
invitee_email :: binary(), % <<>> если только user_id
name :: binary(),
specialization :: [binary()],
status :: pending | accepted | declined | expired | cancelled,
token :: binary(),
created_at :: calendar:datetime(),
expires_at :: calendar:datetime()
}).
-record(location, {
address :: binary(),
lat :: float(),
@@ -296,7 +312,8 @@
-record(notification, {
id :: binary(),
user_id :: binary(),
type :: booking_confirmed | event_reminder | event_cancelled | custom,
type :: booking_confirmed | event_reminder | event_cancelled |
specialist_invite | custom,
title :: binary(),
body :: binary(),
is_read :: boolean(),
+29
View File
@@ -0,0 +1,29 @@
%%%-------------------------------------------------------------------
%%% @doc Persist in-app notifications.
%%% @end
%%%-------------------------------------------------------------------
-module(core_notification).
-include("records.hrl").
-export([create/4, list_by_user/1]).
-spec create(UserId :: binary(), Type :: atom(), Title :: binary(), Body :: binary()) ->
{ok, #notification{}} | {error, term()}.
create(UserId, Type, Title, Body) ->
Rec = #notification{
id = infra_utils:generate_id(16),
user_id = UserId,
type = Type,
title = Title,
body = Body,
is_read = false,
created_at = calendar:universal_time()
},
case mnesia:dirty_write(Rec) of
ok -> {ok, Rec};
Error -> {error, Error}
end.
-spec list_by_user(UserId :: binary()) -> [#notification{}].
list_by_user(UserId) ->
mnesia:dirty_match_object(#notification{user_id = UserId, _ = '_'}).
+79
View File
@@ -0,0 +1,79 @@
%%%-------------------------------------------------------------------
%%% @doc Хранение приглашений специалистов.
%%% @end
%%%-------------------------------------------------------------------
-module(core_specialist_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]).
-spec create(#specialist_invite{}) -> {ok, #specialist_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 get_by_id(Id :: binary()) -> {ok, #specialist_invite{}} | {error, not_found}.
get_by_id(Id) ->
case mnesia:dirty_read(specialist_invite, Id) of
[Rec] -> {ok, Rec};
[] -> {error, not_found}
end.
-spec get_by_token(Token :: binary()) -> {ok, #specialist_invite{}} | {error, not_found}.
get_by_token(Token) ->
case mnesia:dirty_index_read(specialist_invite, Token, #specialist_invite.token) of
[Rec] -> {ok, Rec};
[] -> {error, not_found};
[Rec | _] -> {ok, Rec}
end.
-spec list_by_calendar(CalendarId :: binary()) -> [#specialist_invite{}].
list_by_calendar(CalendarId) ->
mnesia:dirty_match_object(#specialist_invite{calendar_id = CalendarId, _ = '_'}).
-spec list_by_invitee(UserId :: binary()) -> [#specialist_invite{}].
list_by_invitee(UserId) ->
mnesia:dirty_match_object(#specialist_invite{invitee_user_id = UserId, _ = '_'}).
-spec list_by_email(Email :: binary()) -> [#specialist_invite{}].
list_by_email(Email) ->
mnesia:dirty_match_object(#specialist_invite{invitee_email = Email, _ = '_'}).
-spec update_status(Id :: binary(), Status :: atom()) ->
{ok, #specialist_invite{}} | {error, not_found | term()}.
update_status(Id, Status) ->
F = fun() ->
case mnesia:read(specialist_invite, Id) of
[] ->
{error, not_found};
[Rec] ->
Updated = Rec#specialist_invite{status = Status},
mnesia:write(Updated),
{ok, Updated}
end
end,
case mnesia:transaction(F) of
{atomic, Result} -> Result;
{aborted, Reason} -> {error, Reason}
end.
%% @doc Pending invite for calendar by user_id and/or email (either may be <<>>).
-spec find_pending(CalendarId :: binary(), UserId :: binary(), Email :: binary()) ->
[#specialist_invite{}].
find_pending(CalendarId, UserId, Email) ->
All = list_by_calendar(CalendarId),
[I || I <- All, I#specialist_invite.status =:= pending,
matches_pending(I, UserId, Email)].
matches_pending(#specialist_invite{invitee_user_id = U}, UserId, _)
when UserId =/= <<>>, U =:= UserId -> true;
matches_pending(#specialist_invite{invitee_email = E}, _, Email)
when Email =/= <<>>, E =/= <<>>, E =:= Email -> true;
matches_pending(_, _, _) -> false.
+7
View File
@@ -91,12 +91,19 @@ start_http() ->
{"/v1/user/bookings", handler_user_bookings, []},
{"/v1/user/reviews", handler_user_reviews, []},
{"/v1/user/following", handler_user_following, []},
{"/v1/user/specialist-invites", handler_specialist_invites, []},
{"/v1/users/lookup", handler_users_lookup, []},
{"/v1/search", handler_search, []},
{"/v1/calendars", handler_calendars, []},
{"/v1/calendars/:id", handler_calendar_by_id, []},
{"/v1/calendars/:id/follow", handler_calendar_follow, []},
{"/v1/calendars/:id/specialists", handler_calendar_specialists, []},
{"/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/specialist-invites/accept", handler_specialist_invites, []},
{"/v1/specialist-invites/:id/accept", handler_specialist_invites, []},
{"/v1/specialist-invites/:id/decline", handler_specialist_invites, []},
{"/v1/calendars/:calendar_id/view", handler_calendar_view, []},
{"/v1/calendars/:calendar_id/events", handler_events, []},
{"/v1/events/:id", handler_event_by_id, []},
+147
View File
@@ -0,0 +1,147 @@
%%%-------------------------------------------------------------------
%%% @doc Owner: исходящие specialist invites.
%%%
%%% GET/POST /v1/calendars/:id/specialist-invites
%%% DELETE /v1/calendars/:id/specialist-invites/:invite_id
%%% @end
%%%-------------------------------------------------------------------
-module(handler_calendar_specialist_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/specialist-invites">>, method => <<"GET">>,
description => <<"List outgoing specialist invites">>, tags => [<<"Calendars">>],
parameters => [IdParam], responses => #{200 => #{description => <<"OK">>}}},
#{path => <<"/v1/calendars/:id/specialist-invites">>, method => <<"POST">>,
description => <<"Create specialist invite">>, tags => [<<"Calendars">>],
parameters => [IdParam], responses => #{201 => #{description => <<"Created">>}}},
#{path => <<"/v1/calendars/:id/specialist-invites/:invite_id">>, method => <<"DELETE">>,
description => <<"Cancel pending 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, OwnerId, Req1} ->
CalendarId = cowboy_req:binding(id, Req1),
case logic_specialist_invite:list_outgoing(OwnerId, CalendarId) of
{ok, List} ->
handler_utils:send_json(Req1, 200,
[logic_specialist_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, not_commercial} ->
handler_utils:send_error(Req1, 400, <<"Calendar is not commercial">>);
{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, OwnerId, 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 = #{
name => maps:get(<<"name">>, Map, <<>>),
specialization => case maps:get(<<"specialization">>, Map, []) of
L when is_list(L) -> L;
_ -> []
end
},
case logic_specialist_invite:create(OwnerId, CalendarId, Target, Opts) of
{ok, Inv} ->
handler_utils:send_json(Req2, 201, logic_specialist_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, OwnerId, Req1} ->
CalendarId = cowboy_req:binding(id, Req1),
InviteId = cowboy_req:binding(invite_id, Req1),
case logic_specialist_invite:cancel(OwnerId, CalendarId, InviteId) of
{ok, Inv} ->
handler_utils:send_json(Req1, 200, logic_specialist_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_commercial} ->
handler_utils:send_error(Req1, 400, <<"Calendar is not commercial">>);
{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, not_commercial) ->
handler_utils:send_error(Req, 400, <<"Calendar is not commercial">>);
map_create_error(Req, subscription_inactive) ->
handler_utils:send_error(Req, 403, <<"subscription_inactive">>);
map_create_error(Req, user_not_found) ->
handler_utils:send_error(Req, 404, <<"User not found">>);
map_create_error(Req, already_specialist) ->
handler_utils:send_error(Req, 409, <<"Already a specialist">>);
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, <<"user_id or email required">>);
map_create_error(Req, _) ->
handler_utils:send_error(Req, 500, <<"Internal server error">>).
+133
View File
@@ -0,0 +1,133 @@
%%%-------------------------------------------------------------------
%%% @doc Invitee: входящие invites + accept/decline.
%%%
%%% GET /v1/user/specialist-invites
%%% POST /v1/specialist-invites/:id/accept
%%% POST /v1/specialist-invites/:id/decline
%%% POST /v1/specialist-invites/accept body: {token}
%%% @end
%%%-------------------------------------------------------------------
-module(handler_specialist_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/specialist-invites">>, method => <<"GET">>,
description => <<"Incoming specialist invites">>, tags => [<<"Users">>],
responses => #{200 => #{description => <<"OK">>}}},
#{path => <<"/v1/specialist-invites/accept">>, method => <<"POST">>,
description => <<"Accept invite by email token">>, tags => [<<"Users">>],
responses => #{200 => #{description => <<"OK">>}}},
#{path => <<"/v1/specialist-invites/:id/accept">>, method => <<"POST">>,
description => <<"Accept specialist invite">>, tags => [<<"Users">>],
parameters => [IdParam], responses => #{200 => #{description => <<"OK">>}}},
#{path => <<"/v1/specialist-invites/:id/decline">>, method => <<"POST">>,
description => <<"Decline specialist 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/specialist-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_specialist_invite:list_incoming(UserId),
handler_utils:send_json(Req1, 200,
[logic_specialist_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} ->
reply_accept(Req1, logic_specialist_invite:accept(UserId, InviteId));
{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_specialist_invite:decline(UserId, InviteId) of
{ok, Inv} ->
handler_utils:send_json(Req1, 200, logic_specialist_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} when is_binary(Token), Token =/= <<>> ->
reply_accept(Req2, logic_specialist_invite:accept_by_token(UserId, Token));
_ ->
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.
reply_accept(Req, {ok, Inv, Spec}) ->
handler_utils:send_json(Req, 200, #{
invite => logic_specialist_invite:to_json(Inv),
specialist => logic_calendar_specialist:to_json(Spec)
});
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">>).
+47
View File
@@ -0,0 +1,47 @@
%%%-------------------------------------------------------------------
%%% @doc GET /v1/users/lookup?q= — typeahead для specialist invite.
%%% @end
%%%-------------------------------------------------------------------
-module(handler_users_lookup).
-behaviour(cowboy_handler).
-export([init/2, trails/0]).
init(Req, Opts) ->
handle(Req, Opts).
trails() ->
[
#{
path => <<"/v1/users/lookup">>,
method => <<"GET">>,
description => <<"Lookup users by email/nickname (typeahead)">>,
tags => [<<"Users">>],
parameters => [
#{name => <<"q">>, in => <<"query">>, required => true,
schema => #{type => string}}
],
responses => #{200 => #{description => <<"OK">>}}
}
].
handle(Req, _Opts) ->
case cowboy_req:method(Req) of
<<"GET">> -> lookup(Req);
_ -> handler_utils:send_error(Req, 405, <<"Method not allowed">>)
end.
lookup(Req) ->
case handler_utils:auth_user(Req) of
{ok, _UserId, Req1} ->
Qs = cowboy_req:parse_qs(Req1),
Q = proplists:get_value(<<"q">>, Qs, <<>>),
case logic_user_lookup:lookup(Q) of
{ok, List} ->
handler_utils:send_json(Req1, 200, List);
{error, bad_request} ->
handler_utils:send_error(Req1, 400, <<"q too short">>)
end;
{error, Code, Message, Req1} ->
handler_utils:send_error(Req1, Code, Message)
end.
+7 -1
View File
@@ -14,7 +14,7 @@
-define(TABLES, [
user, session, verification, admin, admin_session, auth_session,
calendar, calendar_share, calendar_follow, calendar_specialist,
calendar, calendar_share, calendar_follow, calendar_specialist, specialist_invite,
event, recurrence_exception,
booking,
review, review_vote, report, banned_word, automod_settings, automod_hit,
@@ -319,6 +319,7 @@ table_opts(calendar) -> [{disc_copies, [node()]}, {attributes, record_info(field
table_opts(calendar_share) -> [{disc_copies, [node()]}, {attributes, record_info(fields, calendar_share)}];
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)}];
table_opts(event) -> [{disc_copies, [node()]}, {attributes, record_info(fields, event)}];
table_opts(recurrence_exception) -> [{disc_copies, [node()]}, {attributes, record_info(fields, recurrence_exception)}];
table_opts(booking) -> [{disc_copies, [node()]}, {attributes, record_info(fields, booking)}];
@@ -365,6 +366,11 @@ create_indices() ->
mnesia:add_table_index(calendar, category),
mnesia:add_table_index(calendar_specialist, calendar_id),
mnesia:add_table_index(calendar_specialist, user_id),
mnesia:add_table_index(specialist_invite, calendar_id),
mnesia:add_table_index(specialist_invite, invitee_user_id),
mnesia:add_table_index(specialist_invite, invitee_email),
mnesia:add_table_index(specialist_invite, token),
mnesia:add_table_index(specialist_invite, status),
mnesia:add_table_index(user, nickname),
mnesia:add_table_index(user, email),
mnesia:add_table_index(verification, user_id),
+2 -1
View File
@@ -26,7 +26,8 @@
'20260717190000_admin_stats_indexes',
'20260719210000_review_vote',
'20260720210000_calendar_follow',
'20260722150000_calendar_specialist_id'
'20260722150000_calendar_specialist_id',
'20260722190000_specialist_invite'
]).
%% ------------------------------
Regular → Executable
+5 -1
View File
@@ -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]).
send_specialist_invite(Email, Token) ->
io:format("Sending specialist invite email to ~s with token ~s~n", [Email, Token]).
Regular → Executable
+10
View File
@@ -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},
+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.
+54
View File
@@ -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.
+40
View File
@@ -0,0 +1,40 @@
%% @doc Create specialist_invite table and indexes.
-module('20260722190000_specialist_invite').
-export([up/0, down/0]).
-include("records.hrl").
up() ->
ensure_table(specialist_invite, record_info(fields, specialist_invite)),
ensure_index(specialist_invite, calendar_id),
ensure_index(specialist_invite, invitee_user_id),
ensure_index(specialist_invite, invitee_email),
ensure_index(specialist_invite, token),
ensure_index(specialist_invite, status),
ok.
down() ->
_ = mnesia:delete_table(specialist_invite),
ok.
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 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.
+3
View File
@@ -74,6 +74,9 @@ user() ->
handler_calendar_by_id,
handler_calendar_follow,
handler_calendar_specialists,
handler_calendar_specialist_invites,
handler_specialist_invites,
handler_users_lookup,
handler_calendar_view,
handler_calendars,
handler_event_by_id,
+3
View File
@@ -131,6 +131,9 @@ table_opts(calendar_follow) ->
table_opts(calendar_specialist) ->
[{ram_copies, [node()]}, {attributes, record_info(fields, calendar_specialist)},
{index, [calendar_id, user_id]}];
table_opts(specialist_invite) ->
[{ram_copies, [node()]}, {attributes, record_info(fields, specialist_invite)},
{index, [calendar_id, invitee_user_id, invitee_email, token, status]}];
table_opts(event) ->
[{ram_copies, [node()]}, {attributes, record_info(fields, event)}];
table_opts(recurrence_exception) ->
+94
View File
@@ -0,0 +1,94 @@
-module(logic_specialist_invite_tests).
-include_lib("eunit/include/eunit.hrl").
-include("records.hrl").
-define(TABLES, [user, calendar, calendar_specialist, specialist_invite, subscription, notification]).
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_specialist_invite_test_() ->
{foreach, fun setup/0, fun cleanup/1, [
{"invite by user_id and accept", fun test_invite_accept/0},
{"invite by email unknown user", fun test_invite_email/0},
{"decline invite", fun test_decline/0},
{"duplicate pending", fun test_duplicate_pending/0},
{"lookup typeahead", fun test_lookup/0}
]}.
seed_owner_commercial() ->
Id = base64:encode(crypto:strong_rand_bytes(12), #{padding => false}),
Owner = eh_test_support:make_user(#{
id => Id, email => <<"owner-", Id/binary, "@ex.com">>, status => active}),
OwnerId = Owner#user.id,
mnesia:dirty_write(Owner),
{ok, _} = core_subscription:create(OwnerId, monthly, true),
{ok, Cal} = core_calendar:create(OwnerId, <<"Studio">>, <<>>, manual, commercial),
{OwnerId, Cal#calendar.id}.
make_active_user(Email, Nick) ->
Id = base64:encode(crypto:strong_rand_bytes(12), #{padding => false}),
U = eh_test_support:make_user(#{
id => Id, email => Email, status => active, nickname => Nick}),
mnesia:dirty_write(U),
U.
test_invite_accept() ->
{OwnerId, CalId} = seed_owner_commercial(),
Spec = make_active_user(<<"spec@ex.com">>, <<"speccy">>),
SpecId = Spec#user.id,
{ok, Inv} = logic_specialist_invite:create(OwnerId, CalId, #{user_id => SpecId},
#{name => <<"Doc">>, specialization => [<<"yoga">>]}),
?assertEqual(pending, Inv#specialist_invite.status),
?assertEqual(SpecId, Inv#specialist_invite.invitee_user_id),
{ok, Incoming} = logic_specialist_invite:list_incoming(SpecId),
?assertEqual(1, length(Incoming)),
{ok, Inv2, Specialist} = logic_specialist_invite:accept(SpecId, Inv#specialist_invite.id),
?assertEqual(accepted, Inv2#specialist_invite.status),
?assertEqual(active, Specialist#calendar_specialist.status),
?assert(core_calendar_specialist:is_active_specialist(CalId, SpecId)).
test_invite_email() ->
{OwnerId, CalId} = seed_owner_commercial(),
{ok, Inv} = logic_specialist_invite:create(OwnerId, CalId,
#{email => <<"new@ex.com">>}, #{name => <<"New">>}),
?assertEqual(<<>>, Inv#specialist_invite.invitee_user_id),
?assertEqual(<<"new@ex.com">>, Inv#specialist_invite.invitee_email),
User = make_active_user(<<"new@ex.com">>, <<"newbie">>),
{ok, _, Spec} = logic_specialist_invite:accept(User#user.id, Inv#specialist_invite.id),
?assertEqual(User#user.id, Spec#calendar_specialist.user_id).
test_decline() ->
{OwnerId, CalId} = seed_owner_commercial(),
Spec = make_active_user(<<"d@ex.com">>, <<"dee">>),
{ok, Inv} = logic_specialist_invite:create(OwnerId, CalId, #{user_id => Spec#user.id}, #{}),
{ok, Inv2} = logic_specialist_invite:decline(Spec#user.id, Inv#specialist_invite.id),
?assertEqual(declined, Inv2#specialist_invite.status),
?assertNot(core_calendar_specialist:is_active_specialist(CalId, Spec#user.id)).
test_duplicate_pending() ->
{OwnerId, CalId} = seed_owner_commercial(),
Spec = make_active_user(<<"dup@ex.com">>, <<"dup">>),
{ok, _} = logic_specialist_invite:create(OwnerId, CalId, #{user_id => Spec#user.id}, #{}),
?assertEqual({error, already_pending},
logic_specialist_invite:create(OwnerId, CalId, #{user_id => Spec#user.id}, #{})).
test_lookup() ->
_ = make_active_user(<<"alice@ex.com">>, <<"alice">>),
_ = make_active_user(<<"bob@ex.com">>, <<"bobby">>),
{ok, Exact} = logic_user_lookup:lookup(<<"alice@ex.com">>),
?assertEqual(1, length(Exact)),
[A] = Exact,
?assertEqual(<<"alice@ex.com">>, maps:get(email, A)),
{ok, Pref} = logic_user_lookup:lookup(<<"bob">>),
?assert(length(Pref) >= 1),
[B | _] = Pref,
?assertEqual(<<"b***@ex.com">>, maps:get(email, B)),
?assertEqual({error, bad_request}, logic_user_lookup:lookup(<<"x">>)).
+2 -1
View File
@@ -12,7 +12,8 @@
"20260717190000_admin_stats_indexes",
"20260719210000_review_vote",
"20260720210000_calendar_follow",
"20260722150000_calendar_specialist_id"
"20260722150000_calendar_specialist_id",
"20260722190000_specialist_invite"
]).
setup() ->