diff --git a/include/records.hrl b/include/records.hrl index eeeb1f4..14bef78 100755 --- a/include/records.hrl +++ b/include/records.hrl @@ -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(), diff --git a/src/core/core_notification.erl b/src/core/core_notification.erl new file mode 100755 index 0000000..9e772ab --- /dev/null +++ b/src/core/core_notification.erl @@ -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, _ = '_'}). diff --git a/src/core/core_specialist_invite.erl b/src/core/core_specialist_invite.erl new file mode 100755 index 0000000..ccccd1a --- /dev/null +++ b/src/core/core_specialist_invite.erl @@ -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. diff --git a/src/eventhub_app.erl b/src/eventhub_app.erl index ae5da88..a55bfcf 100755 --- a/src/eventhub_app.erl +++ b/src/eventhub_app.erl @@ -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, []}, diff --git a/src/handlers/handler_calendar_specialist_invites.erl b/src/handlers/handler_calendar_specialist_invites.erl new file mode 100755 index 0000000..fc65925 --- /dev/null +++ b/src/handlers/handler_calendar_specialist_invites.erl @@ -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">>). diff --git a/src/handlers/handler_specialist_invites.erl b/src/handlers/handler_specialist_invites.erl new file mode 100755 index 0000000..f601bd0 --- /dev/null +++ b/src/handlers/handler_specialist_invites.erl @@ -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">>). diff --git a/src/handlers/handler_users_lookup.erl b/src/handlers/handler_users_lookup.erl new file mode 100755 index 0000000..596e286 --- /dev/null +++ b/src/handlers/handler_users_lookup.erl @@ -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. diff --git a/src/infra/infra_mnesia.erl b/src/infra/infra_mnesia.erl index 18c77ce..f32aa91 100755 --- a/src/infra/infra_mnesia.erl +++ b/src/infra/infra_mnesia.erl @@ -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), diff --git a/src/infra/migration_engine.erl b/src/infra/migration_engine.erl index 3513978..81c870c 100755 --- a/src/infra/migration_engine.erl +++ b/src/infra/migration_engine.erl @@ -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' ]). %% ------------------------------ diff --git a/src/logic/logic_email.erl b/src/logic/logic_email.erl old mode 100644 new mode 100755 index da99eb9..eb3debc --- a/src/logic/logic_email.erl +++ b/src/logic/logic_email.erl @@ -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]). \ No newline at end of file + 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]). diff --git a/src/logic/logic_notification.erl b/src/logic/logic_notification.erl old mode 100644 new mode 100755 index 324a24d..bb510e5 --- a/src/logic/logic_notification.erl +++ b/src/logic/logic_notification.erl @@ -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}, diff --git a/src/logic/logic_specialist_invite.erl b/src/logic/logic_specialist_invite.erl new file mode 100755 index 0000000..ba4df31 --- /dev/null +++ b/src/logic/logic_specialist_invite.erl @@ -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. diff --git a/src/logic/logic_user_lookup.erl b/src/logic/logic_user_lookup.erl new file mode 100755 index 0000000..31d10f9 --- /dev/null +++ b/src/logic/logic_user_lookup.erl @@ -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), + <>; + _ -> <<"***">> + end. diff --git a/src/migrations/20260722190000_specialist_invite.erl b/src/migrations/20260722190000_specialist_invite.erl new file mode 100755 index 0000000..9d26bd2 --- /dev/null +++ b/src/migrations/20260722190000_specialist_invite.erl @@ -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. diff --git a/src/swagger/eventhub_trails.erl b/src/swagger/eventhub_trails.erl index ef3754e..e0cf0fa 100755 --- a/src/swagger/eventhub_trails.erl +++ b/src/swagger/eventhub_trails.erl @@ -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, diff --git a/test/unit/eh_test_support.erl b/test/unit/eh_test_support.erl index a4b50b8..e05a181 100755 --- a/test/unit/eh_test_support.erl +++ b/test/unit/eh_test_support.erl @@ -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) -> diff --git a/test/unit/logic_specialist_invite_tests.erl b/test/unit/logic_specialist_invite_tests.erl new file mode 100755 index 0000000..fff9572 --- /dev/null +++ b/test/unit/logic_specialist_invite_tests.erl @@ -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">>)). diff --git a/test/unit/migration_engine_tests.erl b/test/unit/migration_engine_tests.erl index 2250fc7..8b64bdc 100755 --- a/test/unit/migration_engine_tests.erl +++ b/test/unit/migration_engine_tests.erl @@ -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() ->