Compare commits
15 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 61816e15b1 | |||
| e3013075cc | |||
| 571f04737d | |||
| af5f506866 | |||
| fdb08eb453 | |||
| 9886dff8bf | |||
| 5ac23219ca | |||
| 7368adfb37 | |||
| 59220b955e | |||
| 4932f9abae | |||
| dcb5163946 | |||
| fc38f63497 | |||
| 6d52bc3a8e | |||
| e94c94d1a6 | |||
| 7c1fe1940d |
Regular → Executable
+4
-1
@@ -39,4 +39,7 @@ COPY docker/observer_web/dev.exs ./dev.exs
|
||||
EXPOSE 4000
|
||||
|
||||
ENV RELEASE_COOKIE=eventhub_cookie
|
||||
CMD elixir --sname observer_web@observer_web --cookie "${RELEASE_COOKIE}" -S mix run --no-halt dev.exs
|
||||
# --hidden: observer must not join the visible global mesh. Without it, OTP
|
||||
# prevent_overlapping_partitions disconnects eventhub-node1 <-> eventhub-node2
|
||||
# when observer connects to both (Mnesia running_partitioned_network → e2e 404).
|
||||
CMD elixir --hidden --sname observer_web@observer_web --cookie "${RELEASE_COOKIE}" -S mix run --no-halt dev.exs
|
||||
|
||||
Regular → Executable
+35
-1
@@ -35,6 +35,12 @@
|
||||
expires_at :: calendar:datetime()
|
||||
}).
|
||||
|
||||
-record(password_reset, {
|
||||
token :: binary(),
|
||||
user_id :: binary(),
|
||||
expires_at :: calendar:datetime()
|
||||
}).
|
||||
|
||||
%% ------------------- АДМИНИСТРАТОРЫ ------------------------------------
|
||||
-record(admin, {
|
||||
id :: binary(),
|
||||
@@ -103,8 +109,19 @@
|
||||
rights :: read | write | admin
|
||||
}).
|
||||
|
||||
%% Follow чужого календаря (не путать с платной subscription / calendar_share)
|
||||
-record(calendar_follow, {
|
||||
id :: binary(),
|
||||
calendar_id :: binary(),
|
||||
user_id :: binary(),
|
||||
created_at :: calendar:datetime()
|
||||
}).
|
||||
|
||||
%% ------------------- Специалисты календаря ---------------------------
|
||||
%% PK = id (несколько специалистов на календарь). Уникальность пары
|
||||
%% calendar_id+user_id — на уровне logic.
|
||||
-record(calendar_specialist, {
|
||||
id :: binary(),
|
||||
calendar_id :: binary(),
|
||||
user_id :: binary(), % id пользователя-специалиста
|
||||
name :: binary(), % отображаемое имя в этом календаре
|
||||
@@ -114,6 +131,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(),
|
||||
@@ -285,7 +318,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(),
|
||||
|
||||
Regular → Executable
+21
-1
@@ -5,7 +5,7 @@
|
||||
%%%-------------------------------------------------------------------
|
||||
-module(core_auth_session).
|
||||
-include("records.hrl").
|
||||
-export([create/3, get/1, rotate/2, revoke/1, revoke_family/1]).
|
||||
-export([create/3, get/1, rotate/2, revoke/1, revoke_family/1, revoke_all_for_subject/2]).
|
||||
|
||||
-define(REFRESH_TTL_SECONDS, 30 * 24 * 3600).
|
||||
|
||||
@@ -99,6 +99,26 @@ revoke_family(FamilyId) ->
|
||||
end, Sessions),
|
||||
ok.
|
||||
|
||||
%%%-------------------------------------------------------------------
|
||||
%%% @doc Отозвать все сессии субъекта (например после сброса пароля).
|
||||
%%% @end
|
||||
%%%-------------------------------------------------------------------
|
||||
-spec revoke_all_for_subject(SubjectId :: binary(), SubjectType :: user | admin) -> ok.
|
||||
revoke_all_for_subject(SubjectId, SubjectType) ->
|
||||
Sessions = mnesia:dirty_match_object(#auth_session{subject_id = SubjectId, _ = '_'}),
|
||||
Now = calendar:universal_time(),
|
||||
lists:foreach(fun(Session) ->
|
||||
case Session#auth_session.subject_type =:= SubjectType andalso
|
||||
Session#auth_session.revoked =:= false of
|
||||
true ->
|
||||
mnesia:dirty_write(Session#auth_session{revoked = true, updated_at = Now}),
|
||||
dec_counter(Session#auth_session.subject_type);
|
||||
false ->
|
||||
ok
|
||||
end
|
||||
end, Sessions),
|
||||
ok.
|
||||
|
||||
%%%===================================================================
|
||||
%%% Internal
|
||||
%%%===================================================================
|
||||
|
||||
Executable
+111
@@ -0,0 +1,111 @@
|
||||
%%%-------------------------------------------------------------------
|
||||
%%% @doc Хранение follow чужих календарей (уникальность calendar_id+user_id).
|
||||
%%% @end
|
||||
%%%-------------------------------------------------------------------
|
||||
-module(core_calendar_follow).
|
||||
-include("records.hrl").
|
||||
|
||||
-export([follow/2, unfollow/2, is_following/2, list_by_user/1,
|
||||
list_by_calendar/1, delete_by_calendar/1]).
|
||||
|
||||
%%%-------------------------------------------------------------------
|
||||
%%% @doc Подписаться на календарь. Идемпотентно, если уже follow.
|
||||
%%% @end
|
||||
%%%-------------------------------------------------------------------
|
||||
-spec follow(CalendarId :: binary(), UserId :: binary()) ->
|
||||
{ok, #calendar_follow{}} | {error, term()}.
|
||||
follow(CalendarId, UserId) ->
|
||||
Now = calendar:universal_time(),
|
||||
F = fun() ->
|
||||
case find(CalendarId, UserId) of
|
||||
[#calendar_follow{} = Existing] ->
|
||||
{ok, Existing};
|
||||
[] ->
|
||||
Rec = #calendar_follow{
|
||||
id = infra_utils:generate_id(16),
|
||||
calendar_id = CalendarId,
|
||||
user_id = UserId,
|
||||
created_at = Now
|
||||
},
|
||||
mnesia:write(Rec),
|
||||
{ok, Rec}
|
||||
end
|
||||
end,
|
||||
case mnesia:transaction(F) of
|
||||
{atomic, Result} -> Result;
|
||||
{aborted, Reason} -> {error, Reason}
|
||||
end.
|
||||
|
||||
%%%-------------------------------------------------------------------
|
||||
%%% @doc Отписаться. Идемпотентно, если follow нет.
|
||||
%%% @end
|
||||
%%%-------------------------------------------------------------------
|
||||
-spec unfollow(CalendarId :: binary(), UserId :: binary()) -> ok | {error, term()}.
|
||||
unfollow(CalendarId, UserId) ->
|
||||
F = fun() ->
|
||||
case find(CalendarId, UserId) of
|
||||
[] ->
|
||||
ok;
|
||||
[#calendar_follow{id = Id}] ->
|
||||
mnesia:delete({calendar_follow, Id}),
|
||||
ok
|
||||
end
|
||||
end,
|
||||
case mnesia:transaction(F) of
|
||||
{atomic, ok} -> ok;
|
||||
{aborted, Reason} -> {error, Reason}
|
||||
end.
|
||||
|
||||
%%%-------------------------------------------------------------------
|
||||
%%% @doc Есть ли follow у пользователя.
|
||||
%%% @end
|
||||
%%%-------------------------------------------------------------------
|
||||
-spec is_following(UserId :: binary(), CalendarId :: binary()) -> boolean().
|
||||
is_following(UserId, CalendarId) ->
|
||||
case mnesia:dirty_match_object(
|
||||
#calendar_follow{calendar_id = CalendarId, user_id = UserId, _ = '_'}) of
|
||||
[] -> false;
|
||||
_ -> true
|
||||
end.
|
||||
|
||||
%%%-------------------------------------------------------------------
|
||||
%%% @doc Все follow пользователя.
|
||||
%%% @end
|
||||
%%%-------------------------------------------------------------------
|
||||
-spec list_by_user(UserId :: binary()) -> [#calendar_follow{}].
|
||||
list_by_user(UserId) ->
|
||||
mnesia:dirty_match_object(#calendar_follow{user_id = UserId, _ = '_'}).
|
||||
|
||||
%%%-------------------------------------------------------------------
|
||||
%%% @doc Все follow календаря.
|
||||
%%% @end
|
||||
%%%-------------------------------------------------------------------
|
||||
-spec list_by_calendar(CalendarId :: binary()) -> [#calendar_follow{}].
|
||||
list_by_calendar(CalendarId) ->
|
||||
mnesia:dirty_match_object(#calendar_follow{calendar_id = CalendarId, _ = '_'}).
|
||||
|
||||
%%%-------------------------------------------------------------------
|
||||
%%% @doc Удалить все follow календаря (при удалении calendar).
|
||||
%%% @end
|
||||
%%%-------------------------------------------------------------------
|
||||
-spec delete_by_calendar(CalendarId :: binary()) -> ok.
|
||||
delete_by_calendar(CalendarId) ->
|
||||
F = fun() ->
|
||||
lists:foreach(
|
||||
fun(#calendar_follow{id = Id}) ->
|
||||
mnesia:delete({calendar_follow, Id})
|
||||
end,
|
||||
mnesia:match_object(#calendar_follow{calendar_id = CalendarId, _ = '_'})),
|
||||
ok
|
||||
end,
|
||||
case mnesia:transaction(F) of
|
||||
{atomic, ok} -> ok;
|
||||
{aborted, Reason} -> error({delete_follows_failed, Reason})
|
||||
end.
|
||||
|
||||
%%%===================================================================
|
||||
%%% Internal
|
||||
%%%===================================================================
|
||||
|
||||
find(CalendarId, UserId) ->
|
||||
mnesia:match_object(#calendar_follow{calendar_id = CalendarId, user_id = UserId, _ = '_'}).
|
||||
Executable
+111
@@ -0,0 +1,111 @@
|
||||
%%%-------------------------------------------------------------------
|
||||
%%% @doc Хранение специалистов commercial-календаря.
|
||||
%%% @end
|
||||
%%%-------------------------------------------------------------------
|
||||
-module(core_calendar_specialist).
|
||||
-include("records.hrl").
|
||||
|
||||
-export([create/4, get_by_calendar_and_user/2, list_by_calendar/1,
|
||||
update/3, delete/2, is_active_specialist/2]).
|
||||
|
||||
-spec create(CalendarId :: binary(), UserId :: binary(), Name :: binary(),
|
||||
Specs :: [binary()]) ->
|
||||
{ok, #calendar_specialist{}} | {error, term()}.
|
||||
create(CalendarId, UserId, Name, Specs) ->
|
||||
Now = calendar:universal_time(),
|
||||
F = fun() ->
|
||||
case find(CalendarId, UserId) of
|
||||
[#calendar_specialist{}] ->
|
||||
{error, already_exists};
|
||||
[] ->
|
||||
Rec = #calendar_specialist{
|
||||
id = infra_utils:generate_id(16),
|
||||
calendar_id = CalendarId,
|
||||
user_id = UserId,
|
||||
name = Name,
|
||||
specialization = Specs,
|
||||
status = active,
|
||||
added_at = Now,
|
||||
updated_at = Now
|
||||
},
|
||||
mnesia:write(Rec),
|
||||
{ok, Rec}
|
||||
end
|
||||
end,
|
||||
case mnesia:transaction(F) of
|
||||
{atomic, Result} -> Result;
|
||||
{aborted, Reason} -> {error, Reason}
|
||||
end.
|
||||
|
||||
-spec get_by_calendar_and_user(CalendarId :: binary(), UserId :: binary()) ->
|
||||
{ok, #calendar_specialist{}} | {error, not_found}.
|
||||
get_by_calendar_and_user(CalendarId, UserId) ->
|
||||
case mnesia:dirty_match_object(
|
||||
#calendar_specialist{calendar_id = CalendarId, user_id = UserId, _ = '_'}) of
|
||||
[Rec] -> {ok, Rec};
|
||||
[] -> {error, not_found};
|
||||
[Rec | _] -> {ok, Rec}
|
||||
end.
|
||||
|
||||
-spec list_by_calendar(CalendarId :: binary()) -> [#calendar_specialist{}].
|
||||
list_by_calendar(CalendarId) ->
|
||||
mnesia:dirty_match_object(#calendar_specialist{calendar_id = CalendarId, _ = '_'}).
|
||||
|
||||
-spec update(CalendarId :: binary(), UserId :: binary(), Updates :: [{atom(), term()}]) ->
|
||||
{ok, #calendar_specialist{}} | {error, not_found | term()}.
|
||||
update(CalendarId, UserId, Updates) ->
|
||||
F = fun() ->
|
||||
case find(CalendarId, UserId) of
|
||||
[] ->
|
||||
{error, not_found};
|
||||
[Rec] ->
|
||||
Updated = apply_updates(Rec, Updates),
|
||||
mnesia:write(Updated),
|
||||
{ok, Updated}
|
||||
end
|
||||
end,
|
||||
case mnesia:transaction(F) of
|
||||
{atomic, Result} -> Result;
|
||||
{aborted, Reason} -> {error, Reason}
|
||||
end.
|
||||
|
||||
-spec delete(CalendarId :: binary(), UserId :: binary()) -> ok | {error, not_found | term()}.
|
||||
delete(CalendarId, UserId) ->
|
||||
F = fun() ->
|
||||
case find(CalendarId, UserId) of
|
||||
[] ->
|
||||
{error, not_found};
|
||||
[#calendar_specialist{id = Id}] ->
|
||||
mnesia:delete({calendar_specialist, Id}),
|
||||
ok
|
||||
end
|
||||
end,
|
||||
case mnesia:transaction(F) of
|
||||
{atomic, Result} -> Result;
|
||||
{aborted, Reason} -> {error, Reason}
|
||||
end.
|
||||
|
||||
-spec is_active_specialist(CalendarId :: binary(), UserId :: binary()) -> boolean().
|
||||
is_active_specialist(CalendarId, UserId) ->
|
||||
case get_by_calendar_and_user(CalendarId, UserId) of
|
||||
{ok, #calendar_specialist{status = active}} -> true;
|
||||
_ -> false
|
||||
end.
|
||||
|
||||
%%%===================================================================
|
||||
%%% Internal
|
||||
%%%===================================================================
|
||||
|
||||
find(CalendarId, UserId) ->
|
||||
mnesia:match_object(
|
||||
#calendar_specialist{calendar_id = CalendarId, user_id = UserId, _ = '_'}).
|
||||
|
||||
apply_updates(Rec, Updates) ->
|
||||
Updated = lists:foldl(fun set_field/2, Rec, Updates),
|
||||
Updated#calendar_specialist{updated_at = calendar:universal_time()}.
|
||||
|
||||
set_field({name, V}, R) when is_binary(V) -> R#calendar_specialist{name = V};
|
||||
set_field({specialization, V}, R) when is_list(V) -> R#calendar_specialist{specialization = V};
|
||||
set_field({status, active}, R) -> R#calendar_specialist{status = active};
|
||||
set_field({status, inactive}, R) -> R#calendar_specialist{status = inactive};
|
||||
set_field(_, R) -> R.
|
||||
Executable
+29
@@ -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, _ = '_'}).
|
||||
Executable
+85
@@ -0,0 +1,85 @@
|
||||
-module(core_password_reset).
|
||||
-include("records.hrl").
|
||||
-export([create_token/1, verify_token/1, get_or_create_token/1, delete_token/1]).
|
||||
|
||||
-define(TOKEN_LIFETIME_HOURS, 1).
|
||||
|
||||
%%%-------------------------------------------------------------------
|
||||
%%% @doc Создаёт токен сброса пароля для пользователя.
|
||||
%%% @end
|
||||
%%%-------------------------------------------------------------------
|
||||
-spec create_token(UserId :: binary()) -> {ok, Token :: binary(), ExpiresAt :: calendar:datetime()}.
|
||||
create_token(UserId) ->
|
||||
Token = infra_utils:generate_id(32),
|
||||
Expires = calendar:gregorian_seconds_to_datetime(
|
||||
calendar:datetime_to_gregorian_seconds(calendar:universal_time()) + ?TOKEN_LIFETIME_HOURS * 3600),
|
||||
mnesia:dirty_write(#password_reset{token = Token, user_id = UserId, expires_at = Expires}),
|
||||
{ok, Token, Expires}.
|
||||
|
||||
%%%-------------------------------------------------------------------
|
||||
%%% @doc Проверяет токен. Возвращает `{ok, UserId}` или ошибку.
|
||||
%%% @end
|
||||
%%%-------------------------------------------------------------------
|
||||
-spec verify_token(Token :: binary()) ->
|
||||
{ok, UserId :: binary()} | {error, expired | not_found}.
|
||||
verify_token(Token) ->
|
||||
case mnesia:dirty_read(password_reset, Token) of
|
||||
[#password_reset{user_id = UserId, expires_at = Expires}] ->
|
||||
case Expires > calendar:universal_time() of
|
||||
true -> {ok, UserId};
|
||||
false -> {error, expired}
|
||||
end;
|
||||
[] -> {error, not_found}
|
||||
end.
|
||||
|
||||
%%%-------------------------------------------------------------------
|
||||
%%% @doc Возвращает неистёкший токен пользователя или создаёт новый.
|
||||
%%% @end
|
||||
%%%-------------------------------------------------------------------
|
||||
-spec get_or_create_token(UserId :: binary()) ->
|
||||
{ok, Token :: binary(), ExpiresAt :: calendar:datetime()} | {error, not_found}.
|
||||
get_or_create_token(UserId) ->
|
||||
case find_valid_token(UserId) of
|
||||
{ok, Token, ExpiresAt} ->
|
||||
{ok, Token, ExpiresAt};
|
||||
not_found ->
|
||||
case user_exists(UserId) of
|
||||
true -> create_token(UserId);
|
||||
false -> {error, not_found}
|
||||
end
|
||||
end.
|
||||
|
||||
%%%-------------------------------------------------------------------
|
||||
%%% @doc Удаляет токен.
|
||||
%%% @end
|
||||
%%%-------------------------------------------------------------------
|
||||
-spec delete_token(Token :: binary()) -> ok.
|
||||
delete_token(Token) ->
|
||||
mnesia:dirty_delete(password_reset, Token),
|
||||
ok.
|
||||
|
||||
%%--------------------------------------------------------------------
|
||||
find_valid_token(UserId) ->
|
||||
Now = calendar:universal_time(),
|
||||
case mnesia:dirty_match_object(#password_reset{user_id = UserId, _ = '_'}) of
|
||||
[] ->
|
||||
not_found;
|
||||
Rows ->
|
||||
Valid = [R || R <- Rows, R#password_reset.expires_at > Now],
|
||||
case Valid of
|
||||
[#password_reset{token = Token, expires_at = ExpiresAt} | _] ->
|
||||
{ok, Token, ExpiresAt};
|
||||
[] ->
|
||||
not_found
|
||||
end
|
||||
end.
|
||||
|
||||
user_exists(UserId) ->
|
||||
case core_user:get_by_id(UserId) of
|
||||
{ok, _} -> true;
|
||||
{error, not_found} ->
|
||||
case mnesia:transaction(fun() -> mnesia:read(user, UserId) end) of
|
||||
{atomic, [_ | _]} -> true;
|
||||
_ -> false
|
||||
end
|
||||
end.
|
||||
Executable
+79
@@ -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.
|
||||
Regular → Executable
+16
-35
@@ -8,35 +8,27 @@
|
||||
-export([count_subscriptions_by_plan/0, count_subscriptions_by_status/0,
|
||||
count_trial_subscriptions/0, get_ending_paid_subscriptions/1]).
|
||||
|
||||
-define(TRIAL_DAYS, 30).
|
||||
|
||||
%%%-------------------------------------------------------------------
|
||||
%%% @doc Создание подписки.
|
||||
%%% `TrialUsed` – `true`, если подписка платная; `false` для пробного периода.
|
||||
%%% `TrialUsed` – флаг (использован ли trial); на длительность не влияет.
|
||||
%%% Длительность всегда считается из `Plan` через `plan_to_months/1`.
|
||||
%%% Все поля записи инициализированы, `undefined` не возникает.
|
||||
%%% @end
|
||||
%%%-------------------------------------------------------------------
|
||||
-spec create(UserId :: binary(), Plan :: monthly | quarterly | biannual | annual,
|
||||
-spec create(UserId :: binary(), Plan :: monthly | quarterly | biannual | annual | trial,
|
||||
TrialUsed :: boolean()) -> {ok, #subscription{}} | {error, term()}.
|
||||
create(UserId, Plan, TrialUsed) ->
|
||||
Id = infra_utils:generate_id(16),
|
||||
Now = calendar:universal_time(),
|
||||
{StartDate, EndDate} = case TrialUsed of
|
||||
true ->
|
||||
DurationMonths = plan_to_months(Plan),
|
||||
End = add_months(Now, DurationMonths),
|
||||
{Now, End};
|
||||
false ->
|
||||
End = add_days(Now, ?TRIAL_DAYS),
|
||||
{Now, End}
|
||||
end,
|
||||
EndDate = add_months(Now, DurationMonths),
|
||||
Subscription = #subscription{
|
||||
id = Id,
|
||||
user_id = UserId,
|
||||
plan = Plan,
|
||||
status = active,
|
||||
trial_used = TrialUsed,
|
||||
started_at = StartDate,
|
||||
started_at = Now,
|
||||
expires_at = EndDate,
|
||||
created_at = Now,
|
||||
updated_at = Now
|
||||
@@ -65,9 +57,10 @@ get_by_id(Id) ->
|
||||
-spec get_active_by_user(UserId :: binary()) -> {ok, #subscription{}} | {error, not_found}.
|
||||
get_active_by_user(UserId) ->
|
||||
Match = #subscription{user_id = UserId, status = active, _ = '_'},
|
||||
case mnesia:dirty_match_object(Match) of
|
||||
case catch mnesia:dirty_match_object(Match) of
|
||||
{'EXIT', _} -> {error, not_found};
|
||||
[] -> {error, not_found};
|
||||
[Subscription] -> {ok, Subscription}
|
||||
[Subscription | _] -> {ok, Subscription}
|
||||
end.
|
||||
|
||||
%%%-------------------------------------------------------------------
|
||||
@@ -77,9 +70,13 @@ get_active_by_user(UserId) ->
|
||||
-spec list_by_user(UserId :: binary()) -> {ok, [#subscription{}]}.
|
||||
list_by_user(UserId) ->
|
||||
Match = #subscription{user_id = UserId, _ = '_'},
|
||||
Subscriptions = mnesia:dirty_match_object(Match),
|
||||
case catch mnesia:dirty_match_object(Match) of
|
||||
{'EXIT', _} ->
|
||||
{ok, []};
|
||||
Subscriptions when is_list(Subscriptions) ->
|
||||
{ok, lists:sort(fun(A, B) -> A#subscription.created_at >= B#subscription.created_at end,
|
||||
Subscriptions)}.
|
||||
Subscriptions)}
|
||||
end.
|
||||
|
||||
%%%-------------------------------------------------------------------
|
||||
%%% @doc Список всех подписок (для администраторов).
|
||||
@@ -130,25 +127,14 @@ check_expired() ->
|
||||
update_status(Sub#subscription.id, expired),
|
||||
case get_active_by_user(Sub#subscription.user_id) of
|
||||
{error, not_found} ->
|
||||
downgrade_user_calendars(Sub#subscription.user_id);
|
||||
%% type commercial сохраняем; pending гасим (restricted mode)
|
||||
logic_booking:cancel_pending_for_owner(Sub#subscription.user_id);
|
||||
_ -> ok
|
||||
end;
|
||||
false -> ok
|
||||
end
|
||||
end, ActiveSubscriptions).
|
||||
|
||||
%%%-------------------------------------------------------------------
|
||||
%%% @doc Понижение календарей пользователя до personal при истечении подписки.
|
||||
%%% @end
|
||||
%%%-------------------------------------------------------------------
|
||||
-spec downgrade_user_calendars(UserId :: binary()) -> ok.
|
||||
downgrade_user_calendars(UserId) ->
|
||||
Match = #calendar{owner_id = UserId, type = commercial, _ = '_'},
|
||||
Calendars = mnesia:dirty_match_object(Match),
|
||||
lists:foreach(fun(Cal) ->
|
||||
core_calendar:update(Cal#calendar.id, [{type, personal}])
|
||||
end, Calendars).
|
||||
|
||||
%%%-------------------------------------------------------------------
|
||||
%%% Вспомогательные функции
|
||||
%%%-------------------------------------------------------------------
|
||||
@@ -167,11 +153,6 @@ add_months(DateTime, Months) ->
|
||||
NewDays = Days + (Months * 30),
|
||||
calendar:gregorian_seconds_to_datetime(NewDays * 86400).
|
||||
|
||||
-spec add_days(calendar:datetime(), pos_integer()) -> calendar:datetime().
|
||||
add_days(DateTime, Days) ->
|
||||
Seconds = calendar:datetime_to_gregorian_seconds(DateTime),
|
||||
calendar:gregorian_seconds_to_datetime(Seconds + (Days * 86400)).
|
||||
|
||||
%%%===================================================================
|
||||
%%% Новые обёртки для админки
|
||||
%%%===================================================================
|
||||
|
||||
Regular → Executable
+14
@@ -85,14 +85,27 @@ start_http() ->
|
||||
{"/health", handler_health, []},
|
||||
{"/v1/register", handler_register, []},
|
||||
{"/v1/verify", handler_verify, []},
|
||||
{"/v1/forgot-password", handler_forgot_password, []},
|
||||
{"/v1/reset-password", handler_reset_password, []},
|
||||
{"/v1/login", handler_login, []},
|
||||
{"/v1/refresh", handler_refresh, []},
|
||||
{"/v1/user/me", handler_user_me, []},
|
||||
{"/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, []},
|
||||
@@ -137,6 +150,7 @@ start_admin_http() ->
|
||||
{"/v1/admin/users/stats", admin_handler_user_stats, []},
|
||||
{"/v1/admin/users/:id", admin_handler_user_by_id, []},
|
||||
{"/v1/admin/users/:id/verification-token", admin_handler_user_verification_token, []},
|
||||
{"/v1/admin/users/:id/password-reset-token", admin_handler_user_password_reset_token, []},
|
||||
% ================== КАЛЕНДАРИ ==================
|
||||
{"/v1/admin/calendars", admin_handler_calendars, []},
|
||||
{"/v1/admin/calendars/stats", admin_handler_calendar_stats, []},
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
-module(admin_handler_user_password_reset_token).
|
||||
-behaviour(cowboy_handler).
|
||||
-export([init/2]).
|
||||
-export([trails/0]).
|
||||
|
||||
init(Req, _Opts) ->
|
||||
case cowboy_req:method(Req) of
|
||||
<<"GET">> -> get_token(Req);
|
||||
_ -> handler_utils:send_error(Req, 405, <<"Method not allowed">>)
|
||||
end.
|
||||
|
||||
get_token(Req) ->
|
||||
case handler_utils:auth_admin(Req) of
|
||||
{ok, _AdminId, Req1} ->
|
||||
UserId = cowboy_req:binding(id, Req1),
|
||||
case core_password_reset:get_or_create_token(UserId) of
|
||||
{ok, Token, ExpiresAt} ->
|
||||
handler_utils:send_json(Req1, 200, #{
|
||||
<<"token">> => Token,
|
||||
<<"expires_at">> => handler_utils:datetime_to_iso8601(ExpiresAt)
|
||||
});
|
||||
{error, not_found} ->
|
||||
handler_utils:send_error(Req1, 404, <<"User not found">>)
|
||||
end;
|
||||
{error, Code, Msg, Req1} ->
|
||||
handler_utils:send_error(Req1, Code, Msg)
|
||||
end.
|
||||
|
||||
trails() ->
|
||||
[#{path => <<"/v1/admin/users/:id/password-reset-token">>,
|
||||
method => <<"GET">>,
|
||||
description => <<"Get or create password reset token for user (admin)">>,
|
||||
tags => [<<"Users">>],
|
||||
parameters => [#{name => <<"id">>, in => <<"path">>, required => true, schema => #{type => string}}],
|
||||
responses => #{200 => #{description => <<"Token">>}}}].
|
||||
Regular → Executable
+2
-14
@@ -64,6 +64,7 @@ trails() ->
|
||||
responses => #{
|
||||
200 => #{description => <<"Booking updated">>},
|
||||
400 => #{description => <<"Invalid action">>},
|
||||
403 => #{description => <<"Access denied">>},
|
||||
404 => #{description => <<"Booking not found">>}
|
||||
}
|
||||
},
|
||||
@@ -180,17 +181,4 @@ cancel_booking(Req) ->
|
||||
%% Учитывает все поля из records.hrl.
|
||||
-spec booking_to_json(#booking{}) -> map().
|
||||
booking_to_json(Booking) ->
|
||||
#{
|
||||
id => Booking#booking.id,
|
||||
event_id => Booking#booking.event_id,
|
||||
user_id => Booking#booking.user_id,
|
||||
status => Booking#booking.status,
|
||||
notes => Booking#booking.notes,
|
||||
reminder_sent => Booking#booking.reminder_sent,
|
||||
confirmed_at => case Booking#booking.confirmed_at of
|
||||
undefined -> null;
|
||||
Dt -> handler_utils:datetime_to_iso8601(Dt)
|
||||
end,
|
||||
created_at => handler_utils:datetime_to_iso8601(Booking#booking.created_at),
|
||||
updated_at => handler_utils:datetime_to_iso8601(Booking#booking.updated_at)
|
||||
}.
|
||||
handler_utils:booking_to_json(Booking).
|
||||
Regular → Executable
+9
-14
@@ -116,10 +116,18 @@ create_booking(Req) ->
|
||||
handler_utils:send_json(Req1, 201, booking_to_json(Booking));
|
||||
{error, already_booked} ->
|
||||
handler_utils:send_error(Req1, 409, <<"Already booked">>);
|
||||
{error, full} ->
|
||||
handler_utils:send_error(Req1, 400, <<"Event is full">>);
|
||||
{error, event_full} ->
|
||||
handler_utils:send_error(Req1, 400, <<"Event is full">>);
|
||||
{error, event_not_active} ->
|
||||
handler_utils:send_error(Req1, 400, <<"Event is not active">>);
|
||||
{error, personal_calendar} ->
|
||||
handler_utils:send_error(Req1, 403, <<"personal_calendar">>);
|
||||
{error, subscription_inactive} ->
|
||||
handler_utils:send_error(Req1, 403, <<"subscription_inactive">>);
|
||||
{error, own_event} ->
|
||||
handler_utils:send_error(Req1, 403, <<"Cannot book own event">>);
|
||||
{error, access_denied} ->
|
||||
handler_utils:send_error(Req1, 403, <<"Access denied">>);
|
||||
{error, not_found} ->
|
||||
@@ -159,17 +167,4 @@ list_bookings(Req) ->
|
||||
%% @private Формирует JSON-представление записи #booking{}.
|
||||
-spec booking_to_json(#booking{}) -> map().
|
||||
booking_to_json(Booking) ->
|
||||
#{
|
||||
id => Booking#booking.id,
|
||||
event_id => Booking#booking.event_id,
|
||||
user_id => Booking#booking.user_id,
|
||||
status => Booking#booking.status,
|
||||
notes => Booking#booking.notes,
|
||||
reminder_sent => Booking#booking.reminder_sent,
|
||||
confirmed_at => case Booking#booking.confirmed_at of
|
||||
undefined -> null;
|
||||
Dt -> handler_utils:datetime_to_iso8601(Dt)
|
||||
end,
|
||||
created_at => handler_utils:datetime_to_iso8601(Booking#booking.created_at),
|
||||
updated_at => handler_utils:datetime_to_iso8601(Booking#booking.updated_at)
|
||||
}.
|
||||
handler_utils:booking_to_json(Booking).
|
||||
Regular → Executable
+10
-2
@@ -114,7 +114,9 @@ get_calendar(Req) ->
|
||||
CalendarId = cowboy_req:binding(id, Req1),
|
||||
case logic_calendar:get_calendar(UserId, CalendarId) of
|
||||
{ok, Calendar} ->
|
||||
handler_utils:send_json(Req1, 200, handler_utils:calendar_to_json(Calendar));
|
||||
Json0 = handler_utils:calendar_to_json(Calendar),
|
||||
Following = logic_calendar_follow:is_following(UserId, CalendarId),
|
||||
handler_utils:send_json(Req1, 200, Json0#{following => Following});
|
||||
{error, access_denied} ->
|
||||
handler_utils:send_error(Req1, 403, <<"Access denied">>);
|
||||
{error, not_found} ->
|
||||
@@ -137,9 +139,13 @@ update_calendar(Req) ->
|
||||
Updates = convert_calendar_fields(Updates0),
|
||||
case logic_calendar:update_calendar(UserId, CalendarId, Updates) of
|
||||
{ok, Calendar} ->
|
||||
handler_utils:send_json(Req2, 200, handler_utils:calendar_to_json(Calendar));
|
||||
Json0 = handler_utils:calendar_to_json(Calendar),
|
||||
Following = logic_calendar_follow:is_following(UserId, CalendarId),
|
||||
handler_utils:send_json(Req2, 200, Json0#{following => Following});
|
||||
{error, access_denied} ->
|
||||
handler_utils:send_error(Req2, 403, <<"Access denied">>);
|
||||
{error, subscription_required} ->
|
||||
handler_utils:send_error(Req2, 402, <<"Subscription required for commercial calendar">>);
|
||||
{error, not_found} ->
|
||||
handler_utils:send_error(Req2, 404, <<"Calendar not found">>);
|
||||
{error, _} ->
|
||||
@@ -180,6 +186,8 @@ convert_calendar_fields(Updates) ->
|
||||
-spec convert_field({binary(), term()}) -> {atom(), term()}.
|
||||
convert_field({<<"title">>, Val}) -> {title, Val};
|
||||
convert_field({<<"description">>, Val}) -> {description, Val};
|
||||
convert_field({<<"type">>, <<"personal">>}) -> {type, personal};
|
||||
convert_field({<<"type">>, <<"commercial">>}) -> {type, commercial};
|
||||
convert_field({<<"type">>, Val}) -> {type, Val};
|
||||
convert_field({<<"confirmation">>, <<"auto">>}) -> {confirmation, auto};
|
||||
convert_field({<<"confirmation">>, <<"manual">>}) -> {confirmation, manual};
|
||||
|
||||
Executable
+123
@@ -0,0 +1,123 @@
|
||||
%%%-------------------------------------------------------------------
|
||||
%%% @doc Follow / unfollow чужого календаря.
|
||||
%%%
|
||||
%%% POST /v1/calendars/:id/follow — подписаться
|
||||
%%% DELETE /v1/calendars/:id/follow — отписаться
|
||||
%%% @end
|
||||
%%%-------------------------------------------------------------------
|
||||
-module(handler_calendar_follow).
|
||||
-behaviour(cowboy_handler).
|
||||
|
||||
-export([init/2]).
|
||||
-export([trails/0]).
|
||||
|
||||
-include("records.hrl").
|
||||
|
||||
-spec init(cowboy_req:req(), any()) -> {ok, cowboy_req:req(), any()}.
|
||||
init(Req, Opts) ->
|
||||
handle(Req, Opts).
|
||||
|
||||
-spec trails() -> [map()].
|
||||
trails() ->
|
||||
BaseParams = [
|
||||
#{
|
||||
name => <<"id">>,
|
||||
in => <<"path">>,
|
||||
description => <<"Calendar ID">>,
|
||||
required => true,
|
||||
schema => #{type => string}
|
||||
}
|
||||
],
|
||||
FollowResponse = #{
|
||||
type => object,
|
||||
properties => #{
|
||||
calendar_id => #{type => string},
|
||||
following => #{type => boolean}
|
||||
}
|
||||
},
|
||||
[
|
||||
#{
|
||||
path => <<"/v1/calendars/:id/follow">>,
|
||||
method => <<"POST">>,
|
||||
description => <<"Follow a calendar (not paid subscription)">>,
|
||||
tags => [<<"Calendars">>],
|
||||
parameters => BaseParams,
|
||||
responses => #{
|
||||
200 => #{
|
||||
description => <<"Following">>,
|
||||
content => #{<<"application/json">> => #{schema => FollowResponse}}
|
||||
},
|
||||
401 => #{description => <<"Unauthorized">>},
|
||||
403 => #{description => <<"Own calendar or access denied">>},
|
||||
404 => #{description => <<"Calendar not found">>}
|
||||
}
|
||||
},
|
||||
#{
|
||||
path => <<"/v1/calendars/:id/follow">>,
|
||||
method => <<"DELETE">>,
|
||||
description => <<"Unfollow a calendar">>,
|
||||
tags => [<<"Calendars">>],
|
||||
parameters => BaseParams,
|
||||
responses => #{
|
||||
200 => #{
|
||||
description => <<"Unfollowed">>,
|
||||
content => #{<<"application/json">> => #{schema => FollowResponse}}
|
||||
},
|
||||
401 => #{description => <<"Unauthorized">>},
|
||||
403 => #{description => <<"Own calendar">>},
|
||||
404 => #{description => <<"Calendar not found">>}
|
||||
}
|
||||
}
|
||||
].
|
||||
|
||||
-spec handle(cowboy_req:req(), any()) -> {ok, cowboy_req:req(), any()}.
|
||||
handle(Req, _Opts) ->
|
||||
case cowboy_req:method(Req) of
|
||||
<<"POST">> -> follow(Req);
|
||||
<<"DELETE">> -> unfollow(Req);
|
||||
_ -> handler_utils:send_error(Req, 405, <<"Method not allowed">>)
|
||||
end.
|
||||
|
||||
-spec follow(cowboy_req:req()) -> {ok, cowboy_req:req(), any()}.
|
||||
follow(Req) ->
|
||||
case handler_utils:auth_user(Req) of
|
||||
{ok, UserId, Req1} ->
|
||||
CalendarId = cowboy_req:binding(id, Req1),
|
||||
case logic_calendar_follow:follow(UserId, CalendarId) of
|
||||
{ok, _} ->
|
||||
handler_utils:send_json(Req1, 200, #{
|
||||
calendar_id => CalendarId,
|
||||
following => true
|
||||
});
|
||||
{error, not_found} ->
|
||||
handler_utils:send_error(Req1, 404, <<"Calendar not found">>);
|
||||
{error, own_calendar} ->
|
||||
handler_utils:send_error(Req1, 403, <<"Cannot follow own calendar">>);
|
||||
{error, access_denied} ->
|
||||
handler_utils:send_error(Req1, 403, <<"Access denied">>);
|
||||
{error, _} ->
|
||||
handler_utils:send_error(Req1, 500, <<"Internal server error">>)
|
||||
end;
|
||||
{error, Code, Message, Req1} ->
|
||||
handler_utils:send_error(Req1, Code, Message)
|
||||
end.
|
||||
|
||||
-spec unfollow(cowboy_req:req()) -> {ok, cowboy_req:req(), any()}.
|
||||
unfollow(Req) ->
|
||||
case handler_utils:auth_user(Req) of
|
||||
{ok, UserId, Req1} ->
|
||||
CalendarId = cowboy_req:binding(id, Req1),
|
||||
case logic_calendar_follow:unfollow(UserId, CalendarId) of
|
||||
ok ->
|
||||
handler_utils:send_json(Req1, 200, #{
|
||||
calendar_id => CalendarId,
|
||||
following => false
|
||||
});
|
||||
{error, own_calendar} ->
|
||||
handler_utils:send_error(Req1, 403, <<"Cannot unfollow own calendar">>);
|
||||
{error, _} ->
|
||||
handler_utils:send_error(Req1, 500, <<"Internal server error">>)
|
||||
end;
|
||||
{error, Code, Message, Req1} ->
|
||||
handler_utils:send_error(Req1, Code, Message)
|
||||
end.
|
||||
+147
@@ -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">>).
|
||||
+208
@@ -0,0 +1,208 @@
|
||||
%%%-------------------------------------------------------------------
|
||||
%%% @doc CRUD специалистов календаря.
|
||||
%%%
|
||||
%%% GET /v1/calendars/:id/specialists
|
||||
%%% POST /v1/calendars/:id/specialists
|
||||
%%% PUT /v1/calendars/:id/specialists/:user_id
|
||||
%%% DELETE /v1/calendars/:id/specialists/:user_id
|
||||
%%% @end
|
||||
%%%-------------------------------------------------------------------
|
||||
-module(handler_calendar_specialists).
|
||||
-behaviour(cowboy_handler).
|
||||
|
||||
-export([init/2]).
|
||||
-export([trails/0]).
|
||||
|
||||
-include("records.hrl").
|
||||
|
||||
-spec init(cowboy_req:req(), any()) -> {ok, cowboy_req:req(), any()}.
|
||||
init(Req, Opts) ->
|
||||
handle(Req, Opts).
|
||||
|
||||
-spec trails() -> [map()].
|
||||
trails() ->
|
||||
SpecSchema = #{
|
||||
type => object,
|
||||
properties => #{
|
||||
id => #{type => string},
|
||||
calendar_id => #{type => string},
|
||||
user_id => #{type => string},
|
||||
name => #{type => string},
|
||||
specialization => #{type => array, items => #{type => string}},
|
||||
status => #{type => string, enum => [<<"active">>, <<"inactive">>]}
|
||||
}
|
||||
},
|
||||
IdParam = #{
|
||||
name => <<"id">>, in => <<"path">>, required => true,
|
||||
schema => #{type => string}
|
||||
},
|
||||
UserParam = #{
|
||||
name => <<"user_id">>, in => <<"path">>, required => true,
|
||||
schema => #{type => string}
|
||||
},
|
||||
[
|
||||
#{
|
||||
path => <<"/v1/calendars/:id/specialists">>,
|
||||
method => <<"GET">>,
|
||||
description => <<"List calendar specialists">>,
|
||||
tags => [<<"Calendars">>],
|
||||
parameters => [IdParam],
|
||||
responses => #{
|
||||
200 => #{description => <<"OK">>,
|
||||
content => #{<<"application/json">> => #{schema => #{type => array, items => SpecSchema}}}}
|
||||
}
|
||||
},
|
||||
#{
|
||||
path => <<"/v1/calendars/:id/specialists">>,
|
||||
method => <<"POST">>,
|
||||
description => <<"Add specialist (owner, commercial)">>,
|
||||
tags => [<<"Calendars">>],
|
||||
parameters => [IdParam],
|
||||
responses => #{201 => #{description => <<"Created">>}}
|
||||
},
|
||||
#{
|
||||
path => <<"/v1/calendars/:id/specialists/:user_id">>,
|
||||
method => <<"PUT">>,
|
||||
description => <<"Update specialist">>,
|
||||
tags => [<<"Calendars">>],
|
||||
parameters => [IdParam, UserParam],
|
||||
responses => #{200 => #{description => <<"Updated">>}}
|
||||
},
|
||||
#{
|
||||
path => <<"/v1/calendars/:id/specialists/:user_id">>,
|
||||
method => <<"DELETE">>,
|
||||
description => <<"Remove specialist">>,
|
||||
tags => [<<"Calendars">>],
|
||||
parameters => [IdParam, UserParam],
|
||||
responses => #{200 => #{description => <<"Deleted">>}}
|
||||
}
|
||||
].
|
||||
|
||||
-spec handle(cowboy_req:req(), any()) -> {ok, cowboy_req:req(), any()}.
|
||||
handle(Req, _Opts) ->
|
||||
Method = cowboy_req:method(Req),
|
||||
case {Method, has_user_binding(Req)} of
|
||||
{<<"GET">>, false} -> list_specialists(Req);
|
||||
{<<"POST">>, false} -> add_specialist(Req);
|
||||
{<<"PUT">>, true} -> update_specialist(Req);
|
||||
{<<"DELETE">>, true} -> remove_specialist(Req);
|
||||
_ ->
|
||||
handler_utils:send_error(Req, 405, <<"Method not allowed">>)
|
||||
end.
|
||||
|
||||
has_user_binding(Req) ->
|
||||
cowboy_req:binding(user_id, Req) =/= undefined.
|
||||
|
||||
list_specialists(Req) ->
|
||||
case handler_utils:auth_user(Req) of
|
||||
{ok, UserId, Req1} ->
|
||||
CalendarId = cowboy_req:binding(id, Req1),
|
||||
case logic_calendar_specialist:list(UserId, CalendarId) of
|
||||
{ok, List} ->
|
||||
handler_utils:send_json(Req1, 200,
|
||||
[logic_calendar_specialist:to_json(S) || S <- List]);
|
||||
{error, not_found} ->
|
||||
handler_utils:send_error(Req1, 404, <<"Calendar not found">>);
|
||||
{error, access_denied} ->
|
||||
handler_utils:send_error(Req1, 403, <<"Access denied">>);
|
||||
{error, _} ->
|
||||
handler_utils:send_error(Req1, 500, <<"Internal server error">>)
|
||||
end;
|
||||
{error, Code, Message, Req1} ->
|
||||
handler_utils:send_error(Req1, Code, Message)
|
||||
end.
|
||||
|
||||
add_specialist(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
|
||||
#{<<"user_id">> := SpecUserId} = Map when is_binary(SpecUserId) ->
|
||||
Name = maps:get(<<"name">>, Map, <<>>),
|
||||
Specs = maps:get(<<"specialization">>, Map, []),
|
||||
Specs2 = case is_list(Specs) of true -> Specs; false -> [] end,
|
||||
case logic_calendar_specialist:add(OwnerId, CalendarId, SpecUserId, Name, Specs2) of
|
||||
{ok, Rec} ->
|
||||
handler_utils:send_json(Req2, 201, logic_calendar_specialist:to_json(Rec));
|
||||
{error, not_found} ->
|
||||
handler_utils:send_error(Req2, 404, <<"Calendar not found">>);
|
||||
{error, access_denied} ->
|
||||
handler_utils:send_error(Req2, 403, <<"Access denied">>);
|
||||
{error, not_commercial} ->
|
||||
handler_utils:send_error(Req2, 400, <<"Calendar is not commercial">>);
|
||||
{error, user_not_found} ->
|
||||
handler_utils:send_error(Req2, 404, <<"User not found">>);
|
||||
{error, already_exists} ->
|
||||
handler_utils:send_error(Req2, 409, <<"Specialist already exists">>);
|
||||
{error, _} ->
|
||||
handler_utils:send_error(Req2, 500, <<"Internal server error">>)
|
||||
end;
|
||||
_ ->
|
||||
handler_utils:send_error(Req2, 400, <<"user_id required">>)
|
||||
catch
|
||||
_:_ -> handler_utils:send_error(Req2, 400, <<"Invalid JSON">>)
|
||||
end;
|
||||
{error, Code, Message, Req1} ->
|
||||
handler_utils:send_error(Req1, Code, Message)
|
||||
end.
|
||||
|
||||
update_specialist(Req) ->
|
||||
case handler_utils:auth_user(Req) of
|
||||
{ok, OwnerId, Req1} ->
|
||||
CalendarId = cowboy_req:binding(id, Req1),
|
||||
SpecUserId = cowboy_req:binding(user_id, Req1),
|
||||
{ok, Body, Req2} = cowboy_req:read_body(Req1),
|
||||
try jsx:decode(Body, [return_maps]) of
|
||||
Map when is_map(Map) ->
|
||||
Updates = parse_updates(Map),
|
||||
case logic_calendar_specialist:update(OwnerId, CalendarId, SpecUserId, Updates) of
|
||||
{ok, Rec} ->
|
||||
handler_utils:send_json(Req2, 200, logic_calendar_specialist:to_json(Rec));
|
||||
{error, not_found} ->
|
||||
handler_utils:send_error(Req2, 404, <<"Not found">>);
|
||||
{error, access_denied} ->
|
||||
handler_utils:send_error(Req2, 403, <<"Access denied">>);
|
||||
{error, not_commercial} ->
|
||||
handler_utils:send_error(Req2, 400, <<"Calendar is not commercial">>);
|
||||
{error, _} ->
|
||||
handler_utils:send_error(Req2, 500, <<"Internal server error">>)
|
||||
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.
|
||||
|
||||
remove_specialist(Req) ->
|
||||
case handler_utils:auth_user(Req) of
|
||||
{ok, OwnerId, Req1} ->
|
||||
CalendarId = cowboy_req:binding(id, Req1),
|
||||
SpecUserId = cowboy_req:binding(user_id, Req1),
|
||||
case logic_calendar_specialist:remove(OwnerId, CalendarId, SpecUserId) of
|
||||
ok ->
|
||||
handler_utils:send_json(Req1, 200, #{status => <<"deleted">>});
|
||||
{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, _} ->
|
||||
handler_utils:send_error(Req1, 500, <<"Internal server error">>)
|
||||
end;
|
||||
{error, Code, Message, Req1} ->
|
||||
handler_utils:send_error(Req1, Code, Message)
|
||||
end.
|
||||
|
||||
parse_updates(Map) ->
|
||||
lists:filtermap(fun
|
||||
({<<"name">>, V}) when is_binary(V) -> {true, {name, V}};
|
||||
({<<"specialization">>, V}) when is_list(V) -> {true, {specialization, V}};
|
||||
({<<"status">>, <<"active">>}) -> {true, {status, active}};
|
||||
({<<"status">>, <<"inactive">>}) -> {true, {status, inactive}};
|
||||
(_) -> false
|
||||
end, maps:to_list(Map)).
|
||||
Executable
+62
@@ -0,0 +1,62 @@
|
||||
%%%-------------------------------------------------------------------
|
||||
%%% @doc POST /v1/forgot-password — запрос сброса пароля по email.
|
||||
%%% Ответ всегда 200 (без enumeration).
|
||||
%%% @end
|
||||
%%%-------------------------------------------------------------------
|
||||
-module(handler_forgot_password).
|
||||
-behaviour(cowboy_handler).
|
||||
-export([init/2]).
|
||||
-export([trails/0]).
|
||||
|
||||
init(Req, _Opts) ->
|
||||
case cowboy_req:method(Req) of
|
||||
<<"POST">> -> forgot(Req);
|
||||
_ -> handler_utils:send_error(Req, 405, <<"Method not allowed">>)
|
||||
end.
|
||||
|
||||
forgot(Req) ->
|
||||
case cowboy_req:has_body(Req) of
|
||||
false ->
|
||||
handler_utils:send_error(Req, 400, <<"Missing request body">>);
|
||||
true ->
|
||||
{ok, Body, Req1} = cowboy_req:read_body(Req),
|
||||
try jsx:decode(Body, [return_maps]) of
|
||||
#{<<"email">> := Email} when is_binary(Email), Email =/= <<>> ->
|
||||
logic_password_reset:request_reset(Email),
|
||||
handler_utils:send_json(Req1, 200, #{
|
||||
<<"message">> => <<"If the account exists, a reset email was sent">>
|
||||
});
|
||||
_ ->
|
||||
handler_utils:send_error(Req1, 400, <<"Missing email">>)
|
||||
catch
|
||||
_:_ -> handler_utils:send_error(Req1, 400, <<"Invalid JSON">>)
|
||||
end
|
||||
end.
|
||||
|
||||
trails() ->
|
||||
[
|
||||
#{
|
||||
path => <<"/v1/forgot-password">>,
|
||||
method => <<"POST">>,
|
||||
description => <<"Request password reset email (always 200; no email enumeration)">>,
|
||||
tags => [<<"Auth">>],
|
||||
requestBody => #{
|
||||
required => true,
|
||||
content => #{
|
||||
<<"application/json">> => #{
|
||||
schema => #{
|
||||
type => object,
|
||||
required => [<<"email">>],
|
||||
properties => #{
|
||||
email => #{type => string, format => <<"email">>}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
responses => #{
|
||||
200 => #{description => <<"Accepted (sent or silently ignored)">>},
|
||||
400 => #{description => <<"Missing email or invalid JSON">>}
|
||||
}
|
||||
}
|
||||
].
|
||||
Executable
+76
@@ -0,0 +1,76 @@
|
||||
%%%-------------------------------------------------------------------
|
||||
%%% @doc POST /v1/reset-password — установка нового пароля по токену.
|
||||
%%% @end
|
||||
%%%-------------------------------------------------------------------
|
||||
-module(handler_reset_password).
|
||||
-behaviour(cowboy_handler).
|
||||
-export([init/2]).
|
||||
-export([trails/0]).
|
||||
|
||||
init(Req, _Opts) ->
|
||||
case cowboy_req:method(Req) of
|
||||
<<"POST">> -> reset(Req);
|
||||
_ -> handler_utils:send_error(Req, 405, <<"Method not allowed">>)
|
||||
end.
|
||||
|
||||
reset(Req) ->
|
||||
case cowboy_req:has_body(Req) of
|
||||
false ->
|
||||
handler_utils:send_error(Req, 400, <<"Missing request body">>);
|
||||
true ->
|
||||
{ok, Body, Req1} = cowboy_req:read_body(Req),
|
||||
try jsx:decode(Body, [return_maps]) of
|
||||
#{<<"token">> := Token, <<"password">> := Password}
|
||||
when is_binary(Token), is_binary(Password) ->
|
||||
case logic_password_reset:reset_password(Token, Password) of
|
||||
ok ->
|
||||
handler_utils:send_json(Req1, 200, #{<<"message">> => <<"Password updated">>});
|
||||
{error, expired} ->
|
||||
handler_utils:send_error(Req1, 410, <<"Token expired">>);
|
||||
{error, not_found} ->
|
||||
handler_utils:send_error(Req1, 404, <<"Token not found">>);
|
||||
{error, invalid_password} ->
|
||||
handler_utils:send_error(Req1, 400, <<"Invalid password">>);
|
||||
{error, forbidden} ->
|
||||
handler_utils:send_error(Req1, 403, <<"Account cannot reset password">>);
|
||||
{error, _} ->
|
||||
handler_utils:send_error(Req1, 500, <<"Internal server error">>)
|
||||
end;
|
||||
_ ->
|
||||
handler_utils:send_error(Req1, 400, <<"Missing token or password">>)
|
||||
catch
|
||||
_:_ -> handler_utils:send_error(Req1, 400, <<"Invalid JSON">>)
|
||||
end
|
||||
end.
|
||||
|
||||
trails() ->
|
||||
[
|
||||
#{
|
||||
path => <<"/v1/reset-password">>,
|
||||
method => <<"POST">>,
|
||||
description => <<"Reset password using token from email">>,
|
||||
tags => [<<"Auth">>],
|
||||
requestBody => #{
|
||||
required => true,
|
||||
content => #{
|
||||
<<"application/json">> => #{
|
||||
schema => #{
|
||||
type => object,
|
||||
required => [<<"token">>, <<"password">>],
|
||||
properties => #{
|
||||
token => #{type => string},
|
||||
password => #{type => string, format => <<"password">>, minLength => 8}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
responses => #{
|
||||
200 => #{description => <<"Password updated">>},
|
||||
400 => #{description => <<"Missing fields or invalid password">>},
|
||||
403 => #{description => <<"Account not eligible">>},
|
||||
404 => #{description => <<"Token not found">>},
|
||||
410 => #{description => <<"Token expired">>}
|
||||
}
|
||||
}
|
||||
].
|
||||
@@ -24,7 +24,7 @@ trails() ->
|
||||
#{
|
||||
path => <<"/v1/search">>,
|
||||
method => <<"GET">>,
|
||||
description => <<"Search calendars and events">>,
|
||||
description => <<"Search calendars and events. Empty query (auth only) returns discovery tops by rating; use q/tags/geo/from/to for filtered search.">>,
|
||||
tags => [<<"Search">>],
|
||||
parameters => [
|
||||
#{name => <<"type">>, in => <<"query">>, schema => #{type => string, enum => [<<"calendar">>, <<"event">>]}, description => <<"Type of entities to search">>},
|
||||
@@ -101,28 +101,42 @@ search(Req) ->
|
||||
%%%===================================================================
|
||||
|
||||
%% @private Собирает карту параметров для поискового движка.
|
||||
%% Не кладёт sort/tags/geo/даты, если их нет в QS — иначе
|
||||
%% logic_search:is_discovery_request/2 никогда не сработает (Back#50).
|
||||
-spec parse_params(cowboy_req:qs()) -> map().
|
||||
parse_params(Qs) ->
|
||||
Params = #{
|
||||
Params0 = #{
|
||||
limit => parse_int_param(Qs, <<"limit">>, 20),
|
||||
offset => parse_int_param(Qs, <<"offset">>, 0),
|
||||
tags => proplists:get_value(<<"tags">>, Qs),
|
||||
sort => proplists:get_value(<<"sort">>, Qs, <<"start_time">>),
|
||||
order => proplists:get_value(<<"order">>, Qs, <<"asc">>)
|
||||
offset => parse_int_param(Qs, <<"offset">>, 0)
|
||||
},
|
||||
Params1 = case {parse_float_param(Qs, <<"lat">>), parse_float_param(Qs, <<"lon">>)} of
|
||||
Params1 = case proplists:get_value(<<"tags">>, Qs) of
|
||||
undefined -> Params0;
|
||||
<<>> -> Params0;
|
||||
Tags -> Params0#{tags => Tags}
|
||||
end,
|
||||
Params2 = case proplists:get_value(<<"sort">>, Qs) of
|
||||
undefined -> Params1;
|
||||
<<>> -> Params1;
|
||||
Sort ->
|
||||
Order = case proplists:get_value(<<"order">>, Qs) of
|
||||
undefined -> <<"asc">>;
|
||||
<<>> -> <<"asc">>;
|
||||
O -> O
|
||||
end,
|
||||
Params1#{sort => Sort, order => Order}
|
||||
end,
|
||||
Params3 = case {parse_float_param(Qs, <<"lat">>), parse_float_param(Qs, <<"lon">>)} of
|
||||
{{ok, Lat}, {ok, Lon}} ->
|
||||
Radius = parse_int_param(Qs, <<"radius">>, 10),
|
||||
Params#{lat => Lat, lon => Lon, radius => Radius};
|
||||
_ -> Params
|
||||
Params2#{lat => Lat, lon => Lon, radius => Radius};
|
||||
_ -> Params2
|
||||
end,
|
||||
Params2 = case {parse_datetime_param(Qs, <<"from">>), parse_datetime_param(Qs, <<"to">>)} of
|
||||
{{ok, From}, {ok, To}} -> Params1#{from => From, to => To};
|
||||
{{ok, From}, error} -> Params1#{from => From};
|
||||
{error, {ok, To}} -> Params1#{to => To};
|
||||
_ -> Params1
|
||||
end,
|
||||
Params2.
|
||||
case {parse_datetime_param(Qs, <<"from">>), parse_datetime_param(Qs, <<"to">>)} of
|
||||
{{ok, From}, {ok, To}} -> Params3#{from => From, to => To};
|
||||
{{ok, From}, error} -> Params3#{from => From};
|
||||
{error, {ok, To}} -> Params3#{to => To};
|
||||
_ -> Params3
|
||||
end.
|
||||
|
||||
-spec parse_int_param(cowboy_req:qs(), binary(), integer()) -> integer().
|
||||
parse_int_param(Qs, Key, Default) ->
|
||||
|
||||
Executable
+133
@@ -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">>).
|
||||
Regular → Executable
+1
-14
@@ -89,17 +89,4 @@ list_user_bookings(Req) ->
|
||||
%% @private Формирует JSON-представление записи #booking{}.
|
||||
-spec booking_to_json(#booking{}) -> map().
|
||||
booking_to_json(Booking) ->
|
||||
#{
|
||||
id => Booking#booking.id,
|
||||
event_id => Booking#booking.event_id,
|
||||
user_id => Booking#booking.user_id,
|
||||
status => Booking#booking.status,
|
||||
notes => Booking#booking.notes,
|
||||
reminder_sent => Booking#booking.reminder_sent,
|
||||
confirmed_at => case Booking#booking.confirmed_at of
|
||||
undefined -> null;
|
||||
Dt -> handler_utils:datetime_to_iso8601(Dt)
|
||||
end,
|
||||
created_at => handler_utils:datetime_to_iso8601(Booking#booking.created_at),
|
||||
updated_at => handler_utils:datetime_to_iso8601(Booking#booking.updated_at)
|
||||
}.
|
||||
handler_utils:booking_to_json(Booking).
|
||||
Executable
+55
@@ -0,0 +1,55 @@
|
||||
%%%-------------------------------------------------------------------
|
||||
%%% @doc Список календарей, которые пользователь отслеживает (follow).
|
||||
%%%
|
||||
%%% GET /v1/user/following
|
||||
%%% @end
|
||||
%%%-------------------------------------------------------------------
|
||||
-module(handler_user_following).
|
||||
-behaviour(cowboy_handler).
|
||||
|
||||
-export([init/2]).
|
||||
-export([trails/0]).
|
||||
|
||||
-include("records.hrl").
|
||||
|
||||
-spec init(cowboy_req:req(), any()) -> {ok, cowboy_req:req(), any()}.
|
||||
init(Req0, _State) ->
|
||||
case cowboy_req:method(Req0) of
|
||||
<<"GET">> -> list_following(Req0);
|
||||
_ -> handler_utils:send_error(Req0, 405, <<"Method not allowed">>)
|
||||
end.
|
||||
|
||||
-spec trails() -> [map()].
|
||||
trails() ->
|
||||
[
|
||||
#{
|
||||
path => <<"/v1/user/following">>,
|
||||
method => <<"GET">>,
|
||||
description => <<"List calendars the current user follows">>,
|
||||
tags => [<<"Calendars">>],
|
||||
responses => #{
|
||||
200 => #{
|
||||
description => <<"Array of followed calendars">>,
|
||||
content => #{<<"application/json">> => #{schema => #{
|
||||
type => array,
|
||||
items => #{type => object}
|
||||
}}}
|
||||
},
|
||||
401 => #{description => <<"Unauthorized">>}
|
||||
}
|
||||
}
|
||||
].
|
||||
|
||||
-spec list_following(cowboy_req:req()) -> {ok, cowboy_req:req(), any()}.
|
||||
list_following(Req) ->
|
||||
case handler_utils:auth_user(Req) of
|
||||
{ok, UserId, Req1} ->
|
||||
{ok, Calendars} = logic_calendar_follow:list_following_calendars(UserId),
|
||||
Response = [
|
||||
maps:put(following, true, handler_utils:calendar_to_json(C))
|
||||
|| C <- Calendars
|
||||
],
|
||||
handler_utils:send_json(Req1, 200, Response);
|
||||
{error, Code, Message, Req1} ->
|
||||
handler_utils:send_error(Req1, Code, Message)
|
||||
end.
|
||||
Executable
+47
@@ -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.
|
||||
Regular → Executable
+46
-1
@@ -26,6 +26,7 @@
|
||||
ticket_to_json/1,
|
||||
calendar_to_json/1,
|
||||
subscription_to_json/1,
|
||||
booking_to_json/1,
|
||||
trails_for_crud/4,
|
||||
is_superadmin/1,
|
||||
pagination_headers/2,
|
||||
@@ -443,7 +444,8 @@ calendar_to_json(Calendar) ->
|
||||
settings => Calendar#calendar.settings,
|
||||
tags => Calendar#calendar.tags,
|
||||
type => Calendar#calendar.type,
|
||||
confirmation => Calendar#calendar.confirmation,
|
||||
confirmation => confirmation_to_json(Calendar#calendar.confirmation),
|
||||
booking_open => logic_calendar:booking_open(Calendar),
|
||||
rating_avg => Calendar#calendar.rating_avg,
|
||||
rating_count => Calendar#calendar.rating_count,
|
||||
status => Calendar#calendar.status,
|
||||
@@ -452,6 +454,11 @@ calendar_to_json(Calendar) ->
|
||||
updated_at => datetime_to_iso8601(Calendar#calendar.updated_at)
|
||||
}.
|
||||
|
||||
confirmation_to_json(auto) -> <<"auto">>;
|
||||
confirmation_to_json(manual) -> <<"manual">>;
|
||||
confirmation_to_json({timeout, N}) when is_integer(N) -> #{<<"timeout">> => N};
|
||||
confirmation_to_json(Other) -> Other.
|
||||
|
||||
%% @doc Преобразует #subscription{} в JSON-карту.
|
||||
-spec subscription_to_json(#subscription{}) -> map().
|
||||
subscription_to_json(Subscription) ->
|
||||
@@ -467,6 +474,44 @@ subscription_to_json(Subscription) ->
|
||||
updated_at => datetime_to_iso8601(Subscription#subscription.updated_at)
|
||||
}.
|
||||
|
||||
%% @doc Booking JSON with optional booker nickname/email for owner UI.
|
||||
-spec booking_to_json(#booking{}) -> map().
|
||||
booking_to_json(Booking) ->
|
||||
Base = #{
|
||||
id => Booking#booking.id,
|
||||
event_id => Booking#booking.event_id,
|
||||
user_id => Booking#booking.user_id,
|
||||
status => Booking#booking.status,
|
||||
notes => Booking#booking.notes,
|
||||
reminder_sent => Booking#booking.reminder_sent,
|
||||
confirmed_at => case Booking#booking.confirmed_at of
|
||||
undefined -> null;
|
||||
Dt -> datetime_to_iso8601(Dt)
|
||||
end,
|
||||
created_at => datetime_to_iso8601(Booking#booking.created_at),
|
||||
updated_at => datetime_to_iso8601(Booking#booking.updated_at)
|
||||
},
|
||||
maps:merge(Base, booking_user_fields(Booking#booking.user_id)).
|
||||
|
||||
%% @private
|
||||
booking_user_fields(UserId) when is_binary(UserId) ->
|
||||
case core_user:get_by_id(UserId) of
|
||||
{ok, #user{nickname = Nick, email = Email}} ->
|
||||
#{
|
||||
user_nickname => empty_to_null(Nick),
|
||||
user_email => empty_to_null(Email)
|
||||
};
|
||||
_ ->
|
||||
#{user_nickname => null, user_email => null}
|
||||
end;
|
||||
booking_user_fields(_) ->
|
||||
#{user_nickname => null, user_email => null}.
|
||||
|
||||
empty_to_null(undefined) -> null;
|
||||
empty_to_null(null) -> null;
|
||||
empty_to_null(<<>>) -> null;
|
||||
empty_to_null(V) -> V.
|
||||
|
||||
%%%===================================================================
|
||||
%%% Вспомогательные внутренние функции
|
||||
%%%===================================================================
|
||||
|
||||
@@ -19,7 +19,12 @@ init(Req, _Opts) ->
|
||||
{ok, UserId} ->
|
||||
core_user:update(UserId, [{status, active}]),
|
||||
core_verification:delete_token(Token),
|
||||
case logic_calendar:ensure_default_calendar(UserId) of
|
||||
ok ->
|
||||
handler_utils:send_json(Req1, 200, #{<<"message">> => <<"Account verified">>});
|
||||
{error, Reason} ->
|
||||
handler_utils:send_error(Req1, 500, Reason)
|
||||
end;
|
||||
{error, expired} ->
|
||||
handler_utils:send_error(Req1, 410, <<"Token expired">>);
|
||||
{error, not_found} ->
|
||||
|
||||
Regular → Executable
+8
-1
@@ -16,14 +16,21 @@ discover_loop() ->
|
||||
io:format("Checking epmd on ~s...~n", [IPStr]),
|
||||
case erl_epmd:names(IP) of
|
||||
{ok, List} ->
|
||||
%% Only eventhub-node* — connecting to observer_web (same cookie)
|
||||
%% triggers OTP prevent_overlapping_partitions and splits the mesh.
|
||||
lists:foreach(fun({Name, _Port}) ->
|
||||
case lists:prefix("eventhub-node", Name) of
|
||||
false ->
|
||||
ok;
|
||||
true ->
|
||||
Node = list_to_atom(Name ++ "@" ++ Name),
|
||||
io:format(" Trying net_kernel:connect_node(~s)...~n", [Node]),
|
||||
case net_kernel:connect_node(Node) of
|
||||
true -> io:format(" *** Connected to ~s ***~n", [Node]), join_and_replicate(Node); %io:format(" *** Connected to ~s ***~n", [Node]);
|
||||
true -> io:format(" *** Connected to ~s ***~n", [Node]), join_and_replicate(Node);
|
||||
false -> io:format(" *** Failed to connect to ~s ***~n", [Node]);
|
||||
ignored -> ok
|
||||
end
|
||||
end
|
||||
end, List);
|
||||
{error, Reason} ->
|
||||
io:format(" epmd error on ~s: ~p~n", [IPStr, Reason])
|
||||
|
||||
Regular → Executable
+13
-4
@@ -13,8 +13,8 @@
|
||||
terminate/2, code_change/3]).
|
||||
|
||||
-define(TABLES, [
|
||||
user, session, verification, admin, admin_session, auth_session,
|
||||
calendar, calendar_share, calendar_specialist,
|
||||
user, session, verification, password_reset, admin, admin_session, auth_session,
|
||||
calendar, calendar_share, calendar_follow, calendar_specialist, specialist_invite,
|
||||
event, recurrence_exception,
|
||||
booking,
|
||||
review, review_vote, report, banned_word, automod_settings, automod_hit,
|
||||
@@ -23,9 +23,9 @@
|
||||
stats_counter, stats_daily, node_metric, schema_migration
|
||||
]).
|
||||
|
||||
-define(DISC_TABLES, ?TABLES -- [session, verification, admin_session, node_metric]).
|
||||
-define(DISC_TABLES, ?TABLES -- [session, verification, password_reset, admin_session, node_metric]).
|
||||
%% ram_copies: joining nodes must add_table_copy — create_table already_exists skips it.
|
||||
-define(RAM_TABLES, [session, verification, admin_session]).
|
||||
-define(RAM_TABLES, [session, verification, password_reset, admin_session]).
|
||||
%% Disc load on IFT after crash-loop can exceed default gen_server:call 5s.
|
||||
-define(TABLE_WAIT_TIMEOUT, 120000).
|
||||
-define(CLEANUP_INTERVAL, 30000). % 30 секунд
|
||||
@@ -317,7 +317,9 @@ table_opts(user) -> [{disc_copies, [node()]}, {attributes, record_info(fields, u
|
||||
table_opts(admin) -> [{disc_copies, [node()]}, {attributes, record_info(fields, admin)}];
|
||||
table_opts(calendar) -> [{disc_copies, [node()]}, {attributes, record_info(fields, calendar)}];
|
||||
table_opts(calendar_share) -> [{disc_copies, [node()]}, {attributes, record_info(fields, calendar_share)}];
|
||||
table_opts(calendar_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)}];
|
||||
@@ -336,6 +338,7 @@ table_opts(stats_daily) -> [{disc_copies, [node()]}, {attributes, record_info(fi
|
||||
table_opts(schema_migration) -> [{disc_copies, [node()]}, {attributes, record_info(fields, schema_migration)}];
|
||||
table_opts(session) -> [{ram_copies, [node()]}, {attributes, record_info(fields, session)}];
|
||||
table_opts(verification) -> [{ram_copies, [node()]}, {attributes, record_info(fields, verification)}];
|
||||
table_opts(password_reset) -> [{ram_copies, [node()]}, {attributes, record_info(fields, password_reset)}];
|
||||
table_opts(admin_session) -> [{ram_copies, [node()]}, {attributes, record_info(fields, admin_session)}];
|
||||
table_opts(auth_session) -> [{disc_copies, [node()]}, {attributes, record_info(fields, auth_session)}];
|
||||
table_opts(node_metric) -> [{disc_copies, [node()]}, {local_content, true}, {attributes, record_info(fields, node_metric)}].
|
||||
@@ -364,9 +367,15 @@ 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),
|
||||
mnesia:add_table_index(password_reset, user_id),
|
||||
mnesia:add_table_index(notification, user_id),
|
||||
mnesia:add_table_index(notification, is_read),
|
||||
mnesia:add_table_index(auth_session, family_id),
|
||||
|
||||
Regular → Executable
+7
-1
@@ -50,6 +50,12 @@ init([]) ->
|
||||
restart => permanent,
|
||||
shutdown => 5000,
|
||||
type => worker,
|
||||
modules => [migration_engine]}
|
||||
modules => [migration_engine]},
|
||||
#{id => subscription_worker,
|
||||
start => {subscription_worker, start_link, []},
|
||||
restart => permanent,
|
||||
shutdown => 5000,
|
||||
type => worker,
|
||||
modules => [subscription_worker]}
|
||||
],
|
||||
{ok, {SupFlags, Children}}.
|
||||
Regular → Executable
+4
-1
@@ -24,7 +24,10 @@
|
||||
'20260716230000_ticket_source_and_hash_index',
|
||||
'20260717180000_stats_counters',
|
||||
'20260717190000_admin_stats_indexes',
|
||||
'20260719210000_review_vote'
|
||||
'20260719210000_review_vote',
|
||||
'20260720210000_calendar_follow',
|
||||
'20260722150000_calendar_specialist_id',
|
||||
'20260722190000_specialist_invite'
|
||||
]).
|
||||
|
||||
%% ------------------------------
|
||||
|
||||
Executable
+38
@@ -0,0 +1,38 @@
|
||||
%%%-------------------------------------------------------------------
|
||||
%%% @doc Периодическая обработка: истечение подписок и timeout-booking.
|
||||
%%% @end
|
||||
%%%-------------------------------------------------------------------
|
||||
-module(subscription_worker).
|
||||
-behaviour(gen_server).
|
||||
|
||||
-export([start_link/0]).
|
||||
-export([init/1, handle_call/3, handle_cast/2, handle_info/2, terminate/2, code_change/3]).
|
||||
|
||||
-define(INTERVAL_MS, 15000).
|
||||
|
||||
start_link() ->
|
||||
gen_server:start_link({local, ?MODULE}, ?MODULE, [], []).
|
||||
|
||||
init([]) ->
|
||||
self() ! tick,
|
||||
{ok, #{}}.
|
||||
|
||||
handle_call(_Req, _From, State) ->
|
||||
{reply, ok, State}.
|
||||
|
||||
handle_cast(_Msg, State) ->
|
||||
{noreply, State}.
|
||||
|
||||
handle_info(tick, State) ->
|
||||
_ = catch logic_subscription:handle_expired_subscriptions(),
|
||||
_ = catch logic_booking:process_timeout_bookings(),
|
||||
erlang:send_after(?INTERVAL_MS, self(), tick),
|
||||
{noreply, State};
|
||||
handle_info(_Info, State) ->
|
||||
{noreply, State}.
|
||||
|
||||
terminate(_Reason, _State) ->
|
||||
ok.
|
||||
|
||||
code_change(_Old, State, _Extra) ->
|
||||
{ok, State}.
|
||||
Regular → Executable
+216
-72
@@ -3,64 +3,116 @@
|
||||
-export([create_booking/2, confirm_booking/2, confirm_booking/3,
|
||||
cancel_booking/2, cancel_booking/3, get_booking/2,
|
||||
list_bookings/2, list_user_bookings/1, delete_booking/2,
|
||||
list_bookings_admin/0, get_booking_admin/1, list_event_bookings/1]).
|
||||
list_bookings_admin/0, get_booking_admin/1,
|
||||
list_event_bookings/1, list_event_bookings/2,
|
||||
process_timeout_bookings/0, cancel_pending_for_owner/1,
|
||||
cancel_pending_for_calendar/1]).
|
||||
|
||||
%%%-------------------------------------------------------------------
|
||||
%%% @doc Создание бронирования со статусом `pending`.
|
||||
%%% @doc Создание бронирования с учётом commercial / confirmation / capacity.
|
||||
%%% @end
|
||||
%%%-------------------------------------------------------------------
|
||||
-spec create_booking(UserId :: binary(), EventId :: binary()) ->
|
||||
{ok, #booking{}} | {error, full | already_booked | not_found}.
|
||||
{ok, #booking{}} |
|
||||
{error, full | already_booked | not_found | personal_calendar |
|
||||
subscription_inactive | own_event | event_not_active | access_denied}.
|
||||
create_booking(UserId, EventId) ->
|
||||
case core_event:get_by_id(EventId) of
|
||||
{ok, Event} ->
|
||||
case check_capacity(EventId, Event#event.capacity) of
|
||||
{ok, _} ->
|
||||
case core_booking:get_by_event_and_user(EventId, UserId) of
|
||||
{ok, #event{status = active} = Event} ->
|
||||
case core_calendar:get_by_id(Event#event.calendar_id) of
|
||||
{ok, Calendar} ->
|
||||
create_on_calendar(UserId, Event, Calendar);
|
||||
{error, not_found} ->
|
||||
core_booking:create(EventId, UserId, pending);
|
||||
{error, not_found}
|
||||
end;
|
||||
{ok, _} ->
|
||||
{error, already_booked}
|
||||
end;
|
||||
{error, full} ->
|
||||
{error, full}
|
||||
end;
|
||||
{error, event_not_active};
|
||||
{error, not_found} ->
|
||||
{error, not_found}
|
||||
end.
|
||||
|
||||
%%%-------------------------------------------------------------------
|
||||
%%% @doc Подтверждение бронирования (двухарная версия).
|
||||
%%% @end
|
||||
create_on_calendar(UserId, _Event, #calendar{owner_id = UserId}) ->
|
||||
{error, own_event};
|
||||
create_on_calendar(_UserId, _Event, #calendar{type = personal}) ->
|
||||
{error, personal_calendar};
|
||||
create_on_calendar(UserId, Event, #calendar{type = commercial} = Calendar) ->
|
||||
case logic_calendar:booking_open(Calendar) of
|
||||
false ->
|
||||
{error, subscription_inactive};
|
||||
true ->
|
||||
case active_booking(Event#event.id, UserId) of
|
||||
{ok, _} ->
|
||||
{error, already_booked};
|
||||
{error, not_found} ->
|
||||
case check_capacity(Event#event.id, Event#event.capacity) of
|
||||
{ok, _} ->
|
||||
Initial = initial_status(Calendar#calendar.confirmation),
|
||||
case core_booking:create(Event#event.id, UserId, Initial) of
|
||||
{ok, Booking} when Initial =:= confirmed ->
|
||||
Now = calendar:universal_time(),
|
||||
core_booking:update(Booking#booking.id,
|
||||
[{status, confirmed}, {confirmed_at, Now}]);
|
||||
Other ->
|
||||
Other
|
||||
end;
|
||||
{error, full} ->
|
||||
{error, full}
|
||||
end
|
||||
end
|
||||
end;
|
||||
create_on_calendar(_, _, _) ->
|
||||
{error, access_denied}.
|
||||
|
||||
initial_status(auto) -> confirmed;
|
||||
initial_status(_) -> pending.
|
||||
|
||||
%%%-------------------------------------------------------------------
|
||||
-spec confirm_booking(BookingId :: binary(), UserId :: binary()) ->
|
||||
{ok, #booking{}} | {error, not_found | access_denied}.
|
||||
confirm_booking(BookingId, _UserId) ->
|
||||
{ok, #booking{}} | {error, not_found | access_denied | full}.
|
||||
confirm_booking(BookingId, UserId) ->
|
||||
case core_booking:get_by_id(BookingId) of
|
||||
{ok, Booking} ->
|
||||
case can_manage_event_bookings(UserId, Booking#booking.event_id) of
|
||||
true ->
|
||||
case Booking#booking.status of
|
||||
pending ->
|
||||
case event_capacity_ok(Booking#booking.event_id) of
|
||||
true ->
|
||||
Now = calendar:universal_time(),
|
||||
core_booking:update(BookingId, [{status, confirmed}, {confirmed_at, Now}]);
|
||||
false ->
|
||||
{error, full}
|
||||
end;
|
||||
_ ->
|
||||
{error, access_denied}
|
||||
end;
|
||||
{error, Reason} ->
|
||||
{error, Reason}
|
||||
end;
|
||||
Error -> Error
|
||||
end.
|
||||
|
||||
%%%-------------------------------------------------------------------
|
||||
%%% @doc Подтверждение бронирования (трёхарная версия для обработчиков).
|
||||
%%% @end
|
||||
%%%-------------------------------------------------------------------
|
||||
-spec confirm_booking(UserId :: binary(), BookingId :: binary(), confirm) ->
|
||||
{ok, #booking{}} | {error, not_found | access_denied}.
|
||||
-spec confirm_booking(UserId :: binary(), BookingId :: binary(), confirm | decline) ->
|
||||
{ok, #booking{}} | {error, not_found | access_denied | full}.
|
||||
confirm_booking(UserId, BookingId, confirm) ->
|
||||
confirm_booking(BookingId, UserId).
|
||||
confirm_booking(BookingId, UserId);
|
||||
confirm_booking(UserId, BookingId, decline) ->
|
||||
case core_booking:get_by_id(BookingId) of
|
||||
{ok, Booking} ->
|
||||
case can_manage_event_bookings(UserId, Booking#booking.event_id) of
|
||||
true ->
|
||||
case Booking#booking.status of
|
||||
pending ->
|
||||
core_booking:update(BookingId, [{status, cancelled}]);
|
||||
_ ->
|
||||
{error, access_denied}
|
||||
end;
|
||||
{error, Reason} ->
|
||||
{error, Reason}
|
||||
end;
|
||||
Error -> Error
|
||||
end.
|
||||
|
||||
%%%-------------------------------------------------------------------
|
||||
%%% @doc Отмена бронирования (двухарная версия).
|
||||
%%% @end
|
||||
%%%-------------------------------------------------------------------
|
||||
-spec cancel_booking(BookingId :: binary(), UserId :: binary()) ->
|
||||
{ok, #booking{}} | {error, not_found | access_denied}.
|
||||
cancel_booking(BookingId, UserId) ->
|
||||
@@ -78,19 +130,11 @@ cancel_booking(BookingId, UserId) ->
|
||||
Error -> Error
|
||||
end.
|
||||
|
||||
%%%-------------------------------------------------------------------
|
||||
%%% @doc Отмена бронирования (трёхарная версия для обработчиков).
|
||||
%%% @end
|
||||
%%%-------------------------------------------------------------------
|
||||
-spec cancel_booking(UserId :: binary(), BookingId :: binary(), cancel) ->
|
||||
{ok, #booking{}} | {error, not_found | access_denied}.
|
||||
cancel_booking(UserId, BookingId, cancel) ->
|
||||
cancel_booking(BookingId, UserId).
|
||||
|
||||
%%%-------------------------------------------------------------------
|
||||
%%% @doc Получение бронирования по ID.
|
||||
%%% @end
|
||||
%%%-------------------------------------------------------------------
|
||||
-spec get_booking(BookingId :: binary(), UserId :: binary()) ->
|
||||
{ok, #booking{}} | {error, not_found | access_denied}.
|
||||
get_booking(BookingId, UserId) ->
|
||||
@@ -98,15 +142,15 @@ get_booking(BookingId, UserId) ->
|
||||
{ok, Booking} ->
|
||||
case Booking#booking.user_id =:= UserId of
|
||||
true -> {ok, Booking};
|
||||
false -> {error, access_denied}
|
||||
false ->
|
||||
case can_manage_event_bookings(UserId, Booking#booking.event_id) of
|
||||
true -> {ok, Booking};
|
||||
{error, _} -> {error, access_denied}
|
||||
end
|
||||
end;
|
||||
Error -> Error
|
||||
end.
|
||||
|
||||
%%%-------------------------------------------------------------------
|
||||
%%% @doc Список бронирований события.
|
||||
%%% @end
|
||||
%%%-------------------------------------------------------------------
|
||||
-spec list_bookings(EventId :: binary(), UserId :: binary()) ->
|
||||
{ok, [#booking{}]}.
|
||||
list_bookings(EventId, UserId) ->
|
||||
@@ -117,18 +161,10 @@ list_bookings(EventId, UserId) ->
|
||||
end,
|
||||
{ok, Filtered}.
|
||||
|
||||
%%%-------------------------------------------------------------------
|
||||
%%% @doc Список бронирований пользователя.
|
||||
%%% @end
|
||||
%%%-------------------------------------------------------------------
|
||||
-spec list_user_bookings(UserId :: binary()) -> {ok, [#booking{}]}.
|
||||
list_user_bookings(UserId) ->
|
||||
core_booking:list_by_user(UserId).
|
||||
|
||||
%%%-------------------------------------------------------------------
|
||||
%%% @doc Удаление бронирования (только владелец).
|
||||
%%% @end
|
||||
%%%-------------------------------------------------------------------
|
||||
-spec delete_booking(BookingId :: binary(), UserId :: binary()) ->
|
||||
ok | {error, not_found | access_denied}.
|
||||
delete_booking(BookingId, UserId) ->
|
||||
@@ -141,48 +177,156 @@ delete_booking(BookingId, UserId) ->
|
||||
Error -> Error
|
||||
end.
|
||||
|
||||
%%%-------------------------------------------------------------------
|
||||
%%% @doc Административное получение бронирования.
|
||||
%%% @end
|
||||
%%%-------------------------------------------------------------------
|
||||
-spec get_booking_admin(BookingId :: binary()) ->
|
||||
{ok, #booking{}} | {error, not_found}.
|
||||
get_booking_admin(BookingId) ->
|
||||
core_booking:get_by_id(BookingId).
|
||||
|
||||
%%%-------------------------------------------------------------------
|
||||
%%% @doc Список бронирований события (административный).
|
||||
%%% @end
|
||||
%%%-------------------------------------------------------------------
|
||||
-spec list_event_bookings(EventId :: binary()) -> {ok, [#booking{}]}.
|
||||
list_event_bookings(EventId) ->
|
||||
core_booking:list_by_event(EventId).
|
||||
|
||||
%%%-------------------------------------------------------------------
|
||||
%%% @doc Список всех бронирований (административный).
|
||||
%%% @end
|
||||
%%%-------------------------------------------------------------------
|
||||
-spec list_event_bookings(UserId :: binary(), EventId :: binary()) ->
|
||||
{ok, [#booking{}]} | {error, not_found | access_denied}.
|
||||
list_event_bookings(UserId, EventId) ->
|
||||
case can_manage_event_bookings(UserId, EventId) of
|
||||
true ->
|
||||
core_booking:list_by_event(EventId);
|
||||
{error, Reason} ->
|
||||
{error, Reason}
|
||||
end.
|
||||
|
||||
-spec list_bookings_admin() -> {ok, [#booking{}]}.
|
||||
list_bookings_admin() ->
|
||||
{ok, core_booking:list_all()}.
|
||||
|
||||
%%%===================================================================
|
||||
%%% ВНУТРЕННИЕ ФУНКЦИИ
|
||||
%%%===================================================================
|
||||
|
||||
%%%-------------------------------------------------------------------
|
||||
%%% @doc Проверка вместимости события.
|
||||
%%% `undefined` и `0` означают неограниченную вместимость.
|
||||
%%% @doc Авто-confirm/cancel по политике {timeout, N}.
|
||||
%%% @end
|
||||
%%%-------------------------------------------------------------------
|
||||
-spec process_timeout_bookings() -> ok.
|
||||
process_timeout_bookings() ->
|
||||
NowSec = calendar:datetime_to_gregorian_seconds(calendar:universal_time()),
|
||||
Pending = mnesia:dirty_match_object(#booking{status = pending, _ = '_'}),
|
||||
lists:foreach(fun(B) -> maybe_timeout(B, NowSec) end, Pending),
|
||||
ok.
|
||||
|
||||
maybe_timeout(#booking{id = Id, event_id = EventId, created_at = Created} = Booking, NowSec) ->
|
||||
case core_event:get_by_id(EventId) of
|
||||
{ok, Event} ->
|
||||
case core_calendar:get_by_id(Event#event.calendar_id) of
|
||||
{ok, #calendar{confirmation = {timeout, N}}} when is_integer(N), N > 0 ->
|
||||
CreatedSec = calendar:datetime_to_gregorian_seconds(Created),
|
||||
case NowSec - CreatedSec >= N of
|
||||
true ->
|
||||
case event_capacity_ok(EventId) of
|
||||
true ->
|
||||
Now = calendar:universal_time(),
|
||||
_ = core_booking:update(Id, [{status, confirmed}, {confirmed_at, Now}]);
|
||||
false ->
|
||||
_ = core_booking:update(Id, [{status, cancelled}])
|
||||
end;
|
||||
false ->
|
||||
ok
|
||||
end;
|
||||
_ ->
|
||||
ok
|
||||
end;
|
||||
_ ->
|
||||
ok
|
||||
end,
|
||||
Booking.
|
||||
|
||||
%%%-------------------------------------------------------------------
|
||||
%%% @doc Отмена всех pending владельца (expire подписки).
|
||||
%%% @end
|
||||
%%%-------------------------------------------------------------------
|
||||
-spec cancel_pending_for_owner(UserId :: binary()) -> ok.
|
||||
cancel_pending_for_owner(UserId) ->
|
||||
case core_calendar:list_by_owner(UserId) of
|
||||
{ok, Cals} ->
|
||||
lists:foreach(fun(#calendar{id = Id, type = commercial}) ->
|
||||
cancel_pending_for_calendar(Id);
|
||||
(_) -> ok
|
||||
end, Cals);
|
||||
_ -> ok
|
||||
end.
|
||||
|
||||
-spec cancel_pending_for_calendar(CalendarId :: binary()) -> ok.
|
||||
cancel_pending_for_calendar(CalendarId) ->
|
||||
case core_event:list_by_calendar(CalendarId) of
|
||||
{ok, Events} ->
|
||||
lists:foreach(fun(#event{id = EventId}) ->
|
||||
{ok, Bookings} = core_booking:list_by_event(EventId),
|
||||
lists:foreach(fun
|
||||
(#booking{id = Bid, status = pending}) ->
|
||||
_ = core_booking:update(Bid, [{status, cancelled}]);
|
||||
(_) -> ok
|
||||
end, Bookings)
|
||||
end, Events);
|
||||
_ -> ok
|
||||
end,
|
||||
ok.
|
||||
|
||||
%%%===================================================================
|
||||
%%% INTERNAL
|
||||
%%%===================================================================
|
||||
|
||||
-spec can_manage_event_bookings(UserId :: binary(), EventId :: binary()) ->
|
||||
true | {error, not_found | access_denied}.
|
||||
can_manage_event_bookings(UserId, EventId) ->
|
||||
case core_event:get_by_id(EventId) of
|
||||
{ok, Event} ->
|
||||
case core_calendar:get_by_id(Event#event.calendar_id) of
|
||||
{ok, Calendar} ->
|
||||
OwnerOk = Calendar#calendar.owner_id =:= UserId,
|
||||
AdminOk = admin_utils:is_admin(UserId),
|
||||
SpecOk = is_binary(Event#event.specialist_id)
|
||||
andalso Event#event.specialist_id =/= <<>>
|
||||
andalso Event#event.specialist_id =:= UserId
|
||||
andalso core_calendar_specialist:is_active_specialist(
|
||||
Calendar#calendar.id, UserId),
|
||||
case OwnerOk orelse AdminOk orelse SpecOk of
|
||||
true -> true;
|
||||
false -> {error, access_denied}
|
||||
end;
|
||||
{error, not_found} ->
|
||||
{error, not_found}
|
||||
end;
|
||||
{error, not_found} ->
|
||||
{error, not_found}
|
||||
end.
|
||||
|
||||
-spec check_capacity(EventId :: binary(), Capacity :: integer() | undefined) ->
|
||||
{ok, integer() | unlimited} | {error, full}.
|
||||
check_capacity(_EventId, undefined) -> {ok, unlimited};
|
||||
check_capacity(_EventId, 0) -> {ok, unlimited};
|
||||
check_capacity(EventId, Capacity) ->
|
||||
{ok, Bookings} = core_booking:list_by_event(EventId),
|
||||
ConfirmedCount = length([B || B <- Bookings, B#booking.status =:= confirmed]),
|
||||
case ConfirmedCount < Capacity of
|
||||
true -> {ok, Capacity - ConfirmedCount};
|
||||
case occupied_slots(EventId) < Capacity of
|
||||
true -> {ok, Capacity - occupied_slots(EventId)};
|
||||
false -> {error, full}
|
||||
end.
|
||||
|
||||
event_capacity_ok(EventId) ->
|
||||
case core_event:get_by_id(EventId) of
|
||||
{ok, #event{capacity = Cap}} when Cap =:= undefined; Cap =:= 0 ->
|
||||
true;
|
||||
{ok, #event{capacity = Cap}} when is_integer(Cap) ->
|
||||
%% при confirm текущий pending уже в occupied — слот свой, ок если <= Cap
|
||||
occupied_slots(EventId) =< Cap;
|
||||
_ ->
|
||||
false
|
||||
end.
|
||||
|
||||
occupied_slots(EventId) ->
|
||||
{ok, Bookings} = core_booking:list_by_event(EventId),
|
||||
length([B || B <- Bookings,
|
||||
B#booking.status =:= pending orelse B#booking.status =:= confirmed]).
|
||||
|
||||
active_booking(EventId, UserId) ->
|
||||
Match = #booking{event_id = EventId, user_id = UserId, _ = '_'},
|
||||
case [B || B <- mnesia:dirty_match_object(Match),
|
||||
B#booking.status =:= pending orelse B#booking.status =:= confirmed] of
|
||||
[B | _] -> {ok, B};
|
||||
[] -> {error, not_found}
|
||||
end.
|
||||
|
||||
Regular → Executable
+122
-21
@@ -2,8 +2,8 @@
|
||||
-include("records.hrl").
|
||||
|
||||
-export([create_calendar/3, create_calendar/4, get_calendar/2, list_calendars/1,
|
||||
update_calendar/3, delete_calendar/2]).
|
||||
-export([can_access/2, can_edit/2]).
|
||||
update_calendar/3, delete_calendar/2, ensure_default_calendar/1]).
|
||||
-export([can_access/2, can_edit/2, booking_open/1]).
|
||||
-export([admin_list_all/0, admin_get_by_id/1, admin_update/2, admin_delete/1]).
|
||||
|
||||
%% Создание календаря с политикой по умолчанию (manual)
|
||||
@@ -50,6 +50,43 @@ create_calendar(UserId, Title, Description, Confirmation, Type) ->
|
||||
{error, user_not_found}
|
||||
end.
|
||||
|
||||
%% @doc Создаёт дефолтный personal-календарь после активации пользователя.
|
||||
%% Идемпотентно: если у владельца уже есть active personal — ok.
|
||||
-spec ensure_default_calendar(UserId :: binary()) -> ok | {error, term()}.
|
||||
ensure_default_calendar(UserId) ->
|
||||
case has_active_personal_calendar(UserId) of
|
||||
true ->
|
||||
ok;
|
||||
false ->
|
||||
case core_user:get_by_id(UserId) of
|
||||
{ok, User} ->
|
||||
Title = default_calendar_title(User),
|
||||
case create_calendar(UserId, Title, <<>>, manual, personal) of
|
||||
{ok, _} -> ok;
|
||||
Error -> Error
|
||||
end;
|
||||
Error ->
|
||||
Error
|
||||
end
|
||||
end.
|
||||
|
||||
has_active_personal_calendar(UserId) ->
|
||||
case core_calendar:list_by_owner(UserId) of
|
||||
{ok, Calendars} ->
|
||||
lists:any(
|
||||
fun(#calendar{type = personal}) -> true;
|
||||
(_) -> false
|
||||
end,
|
||||
Calendars);
|
||||
_ ->
|
||||
false
|
||||
end.
|
||||
|
||||
default_calendar_title(#user{nickname = Nick}) when is_binary(Nick), byte_size(Nick) > 0 ->
|
||||
Nick;
|
||||
default_calendar_title(_) ->
|
||||
<<"Мой календарь">>.
|
||||
|
||||
%% Получение календаря с проверкой доступа
|
||||
get_calendar(UserId, CalendarId) ->
|
||||
case core_calendar:get_by_id(CalendarId) of
|
||||
@@ -73,23 +110,11 @@ update_calendar(UserId, CalendarId, Updates) ->
|
||||
case can_edit(UserId, Calendar) of
|
||||
true ->
|
||||
ValidUpdates = validate_updates(Updates),
|
||||
case content_fields(ValidUpdates, [title, description]) of
|
||||
{[], []} ->
|
||||
core_calendar:update(CalendarId, ValidUpdates);
|
||||
{Fields, Texts} ->
|
||||
case logic_automoderation:evaluate_texts(Texts) of
|
||||
{reject, Words} ->
|
||||
{error, {content_banned, Words}};
|
||||
{ok, Action, OutTexts, Words} ->
|
||||
FinalUpdates = apply_text_results(ValidUpdates, Fields, OutTexts),
|
||||
case core_calendar:update(CalendarId, FinalUpdates) of
|
||||
{ok, _Updated} ->
|
||||
logic_automoderation:apply_after_save(calendar, CalendarId, Action, Words),
|
||||
core_calendar:get_by_id(CalendarId);
|
||||
Error ->
|
||||
Error
|
||||
end
|
||||
end
|
||||
case gate_type_change(UserId, Calendar, ValidUpdates) of
|
||||
{error, _} = Err ->
|
||||
Err;
|
||||
{ok, ValidUpdates2} ->
|
||||
apply_calendar_update(CalendarId, Calendar, ValidUpdates2)
|
||||
end;
|
||||
false ->
|
||||
{error, access_denied}
|
||||
@@ -98,6 +123,66 @@ update_calendar(UserId, CalendarId, Updates) ->
|
||||
Error
|
||||
end.
|
||||
|
||||
apply_calendar_update(CalendarId, Calendar, ValidUpdates) ->
|
||||
case content_fields(ValidUpdates, [title, description]) of
|
||||
{[], []} ->
|
||||
case core_calendar:update(CalendarId, ValidUpdates) of
|
||||
{ok, Updated} = Ok ->
|
||||
maybe_cancel_pending_on_personal(Calendar, Updated),
|
||||
Ok;
|
||||
Error ->
|
||||
Error
|
||||
end;
|
||||
{Fields, Texts} ->
|
||||
case logic_automoderation:evaluate_texts(Texts) of
|
||||
{reject, Words} ->
|
||||
{error, {content_banned, Words}};
|
||||
{ok, Action, OutTexts, Words} ->
|
||||
FinalUpdates = apply_text_results(ValidUpdates, Fields, OutTexts),
|
||||
case core_calendar:update(CalendarId, FinalUpdates) of
|
||||
{ok, Updated} ->
|
||||
logic_automoderation:apply_after_save(calendar, CalendarId, Action, Words),
|
||||
maybe_cancel_pending_on_personal(Calendar, Updated),
|
||||
core_calendar:get_by_id(CalendarId);
|
||||
Error ->
|
||||
Error
|
||||
end
|
||||
end
|
||||
end.
|
||||
|
||||
gate_type_change(UserId, #calendar{type = personal}, Updates) ->
|
||||
case lists:keyfind(type, 1, Updates) of
|
||||
{type, commercial} ->
|
||||
case logic_subscription:can_create_commercial_calendar(UserId) of
|
||||
true -> {ok, Updates};
|
||||
false -> {error, subscription_required}
|
||||
end;
|
||||
_ ->
|
||||
{ok, Updates}
|
||||
end;
|
||||
gate_type_change(_UserId, _Calendar, Updates) ->
|
||||
{ok, Updates}.
|
||||
|
||||
maybe_cancel_pending_on_personal(#calendar{type = commercial},
|
||||
#calendar{type = personal, id = Id}) ->
|
||||
logic_booking:cancel_pending_for_calendar(Id);
|
||||
maybe_cancel_pending_on_personal(_, _) ->
|
||||
ok.
|
||||
|
||||
%%%-------------------------------------------------------------------
|
||||
%%% @doc Можно ли принимать новые booking на календарь.
|
||||
%%% commercial + active + active subscription владельца.
|
||||
%%% @end
|
||||
%%%-------------------------------------------------------------------
|
||||
-spec booking_open(#calendar{}) -> boolean().
|
||||
booking_open(#calendar{type = commercial, status = active, owner_id = OwnerId}) ->
|
||||
case logic_subscription:check_user_subscription(OwnerId) of
|
||||
{ok, active, _} -> true;
|
||||
_ -> false
|
||||
end;
|
||||
booking_open(_) ->
|
||||
false.
|
||||
|
||||
content_fields(Updates, Fields) ->
|
||||
lists:foldl(fun(F, {Fs, Ts}) ->
|
||||
case lists:keyfind(F, 1, Updates) of
|
||||
@@ -142,12 +227,28 @@ can_edit(_, _) ->
|
||||
|
||||
%% Валидация полей обновления
|
||||
validate_updates(Updates) ->
|
||||
lists:filter(fun validate_update/1, Updates).
|
||||
lists:filtermap(fun(U) ->
|
||||
case normalize_update(U) of
|
||||
false -> false;
|
||||
Norm ->
|
||||
case validate_update(Norm) of
|
||||
true -> {true, Norm};
|
||||
false -> false
|
||||
end
|
||||
end
|
||||
end, Updates).
|
||||
|
||||
normalize_update({type, <<"personal">>}) -> {type, personal};
|
||||
normalize_update({type, <<"commercial">>}) -> {type, commercial};
|
||||
normalize_update({type, personal}) -> {type, personal};
|
||||
normalize_update({type, commercial}) -> {type, commercial};
|
||||
normalize_update(Other) -> Other.
|
||||
|
||||
validate_update({title, Value}) when is_binary(Value) -> true;
|
||||
validate_update({description, Value}) when is_binary(Value) -> true;
|
||||
validate_update({tags, Value}) when is_list(Value) -> true;
|
||||
validate_update({type, Value}) when Value =:= personal; Value =:= commercial -> true;
|
||||
validate_update({type, personal}) -> true;
|
||||
validate_update({type, commercial}) -> true;
|
||||
validate_update({confirmation, Value}) ->
|
||||
case Value of
|
||||
auto -> true;
|
||||
|
||||
Executable
+84
@@ -0,0 +1,84 @@
|
||||
%%%-------------------------------------------------------------------
|
||||
%%% @doc Бизнес-логика follow чужого календаря.
|
||||
%%% Follow не даёт право на отзыв (см. logic_review:can_review/3).
|
||||
%%% @end
|
||||
%%%-------------------------------------------------------------------
|
||||
-module(logic_calendar_follow).
|
||||
-include("records.hrl").
|
||||
|
||||
-export([follow/2, unfollow/2, is_following/2, list_following_calendars/1]).
|
||||
|
||||
%%%-------------------------------------------------------------------
|
||||
%%% @doc Follow доступного чужого календаря.
|
||||
%%% @end
|
||||
%%%-------------------------------------------------------------------
|
||||
-spec follow(UserId :: binary(), CalendarId :: binary()) ->
|
||||
{ok, #calendar_follow{}} | {error, term()}.
|
||||
follow(UserId, CalendarId) ->
|
||||
case core_calendar:get_by_id(CalendarId) of
|
||||
{error, not_found} ->
|
||||
{error, not_found};
|
||||
{ok, #calendar{owner_id = UserId}} ->
|
||||
{error, own_calendar};
|
||||
{ok, Calendar} ->
|
||||
case logic_calendar:can_access(UserId, Calendar) of
|
||||
false ->
|
||||
{error, access_denied};
|
||||
true ->
|
||||
case Calendar#calendar.status of
|
||||
active ->
|
||||
core_calendar_follow:follow(CalendarId, UserId);
|
||||
_ ->
|
||||
{error, not_found}
|
||||
end
|
||||
end
|
||||
end.
|
||||
|
||||
%%%-------------------------------------------------------------------
|
||||
%%% @doc Unfollow. Идемпотентно.
|
||||
%%% @end
|
||||
%%%-------------------------------------------------------------------
|
||||
-spec unfollow(UserId :: binary(), CalendarId :: binary()) -> ok | {error, term()}.
|
||||
unfollow(UserId, CalendarId) ->
|
||||
case core_calendar:get_by_id(CalendarId) of
|
||||
{error, not_found} ->
|
||||
%% всё равно снимаем локальный follow, если был
|
||||
core_calendar_follow:unfollow(CalendarId, UserId);
|
||||
{ok, #calendar{owner_id = UserId}} ->
|
||||
{error, own_calendar};
|
||||
{ok, _} ->
|
||||
core_calendar_follow:unfollow(CalendarId, UserId)
|
||||
end.
|
||||
|
||||
%%%-------------------------------------------------------------------
|
||||
%%% @doc Флаг following для текущего пользователя.
|
||||
%%% @end
|
||||
%%%-------------------------------------------------------------------
|
||||
-spec is_following(UserId :: binary(), CalendarId :: binary()) -> boolean().
|
||||
is_following(UserId, CalendarId) ->
|
||||
core_calendar_follow:is_following(UserId, CalendarId).
|
||||
|
||||
%%%-------------------------------------------------------------------
|
||||
%%% @doc Список календарей, на которые подписан пользователь (active, доступные).
|
||||
%%% @end
|
||||
%%%-------------------------------------------------------------------
|
||||
-spec list_following_calendars(UserId :: binary()) -> {ok, [#calendar{}]}.
|
||||
list_following_calendars(UserId) ->
|
||||
Follows = core_calendar_follow:list_by_user(UserId),
|
||||
Calendars = lists:filtermap(
|
||||
fun(#calendar_follow{calendar_id = CalId}) ->
|
||||
case core_calendar:get_by_id(CalId) of
|
||||
{ok, #calendar{status = active} = Cal} ->
|
||||
case logic_calendar:can_access(UserId, Cal) of
|
||||
true -> {true, Cal};
|
||||
false -> false
|
||||
end;
|
||||
_ ->
|
||||
false
|
||||
end
|
||||
end,
|
||||
Follows),
|
||||
Sorted = lists:sort(
|
||||
fun(#calendar{title = A}, #calendar{title = B}) -> A =< B end,
|
||||
Calendars),
|
||||
{ok, Sorted}.
|
||||
Executable
+84
@@ -0,0 +1,84 @@
|
||||
%%%-------------------------------------------------------------------
|
||||
%%% @doc Логика специалистов commercial-календаря.
|
||||
%%% @end
|
||||
%%%-------------------------------------------------------------------
|
||||
-module(logic_calendar_specialist).
|
||||
-include("records.hrl").
|
||||
|
||||
-export([list/2, add/5, update/4, remove/3, to_json/1]).
|
||||
|
||||
-spec list(ActorId :: binary(), CalendarId :: binary()) ->
|
||||
{ok, [#calendar_specialist{}]} | {error, not_found | access_denied}.
|
||||
list(ActorId, CalendarId) ->
|
||||
case core_calendar:get_by_id(CalendarId) of
|
||||
{ok, Cal} ->
|
||||
case logic_calendar:can_access(ActorId, Cal) of
|
||||
true -> {ok, core_calendar_specialist:list_by_calendar(CalendarId)};
|
||||
false -> {error, access_denied}
|
||||
end;
|
||||
Error -> Error
|
||||
end.
|
||||
|
||||
-spec add(OwnerId :: binary(), CalendarId :: binary(), UserId :: binary(),
|
||||
Name :: binary(), Specs :: [binary()]) ->
|
||||
{ok, #calendar_specialist{}} |
|
||||
{error, not_found | access_denied | not_commercial | user_not_found |
|
||||
already_exists | term()}.
|
||||
add(OwnerId, CalendarId, UserId, Name, Specs) ->
|
||||
case require_owner_commercial(OwnerId, CalendarId) of
|
||||
{ok, _} ->
|
||||
case core_user:get_by_id(UserId) of
|
||||
{ok, _} ->
|
||||
core_calendar_specialist:create(CalendarId, UserId, Name, Specs);
|
||||
{error, _} ->
|
||||
{error, user_not_found}
|
||||
end;
|
||||
Error -> Error
|
||||
end.
|
||||
|
||||
-spec update(OwnerId :: binary(), CalendarId :: binary(), UserId :: binary(),
|
||||
Updates :: [{atom(), term()}]) ->
|
||||
{ok, #calendar_specialist{}} | {error, not_found | access_denied | not_commercial | term()}.
|
||||
update(OwnerId, CalendarId, UserId, Updates) ->
|
||||
case require_owner_commercial(OwnerId, CalendarId) of
|
||||
{ok, _} ->
|
||||
core_calendar_specialist:update(CalendarId, UserId, Updates);
|
||||
Error -> Error
|
||||
end.
|
||||
|
||||
-spec remove(OwnerId :: binary(), CalendarId :: binary(), UserId :: binary()) ->
|
||||
ok | {error, not_found | access_denied | not_commercial | term()}.
|
||||
remove(OwnerId, CalendarId, UserId) ->
|
||||
case require_owner_commercial(OwnerId, CalendarId) of
|
||||
{ok, _} ->
|
||||
core_calendar_specialist:delete(CalendarId, UserId);
|
||||
Error -> Error
|
||||
end.
|
||||
|
||||
-spec to_json(#calendar_specialist{}) -> map().
|
||||
to_json(S) ->
|
||||
#{
|
||||
id => S#calendar_specialist.id,
|
||||
calendar_id => S#calendar_specialist.calendar_id,
|
||||
user_id => S#calendar_specialist.user_id,
|
||||
name => S#calendar_specialist.name,
|
||||
specialization => S#calendar_specialist.specialization,
|
||||
status => S#calendar_specialist.status,
|
||||
added_at => handler_utils:datetime_to_iso8601(S#calendar_specialist.added_at),
|
||||
updated_at => handler_utils:datetime_to_iso8601(S#calendar_specialist.updated_at)
|
||||
}.
|
||||
|
||||
%%%===================================================================
|
||||
%%% Internal
|
||||
%%%===================================================================
|
||||
|
||||
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.
|
||||
Regular → Executable
+8
-1
@@ -1,4 +1,11 @@
|
||||
-module(logic_email).
|
||||
-export([send_verification_email/2]).
|
||||
-export([send_verification_email/2, send_specialist_invite/2, send_password_reset/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]).
|
||||
|
||||
send_password_reset(Email, Token) ->
|
||||
io:format("Sending password reset email to ~s with token ~s~n", [Email, Token]).
|
||||
|
||||
Regular → Executable
+26
@@ -176,6 +176,10 @@ update_event(UserId, EventId, Updates) ->
|
||||
{ok, Calendar} ->
|
||||
case logic_calendar:can_edit(UserId, Calendar) of
|
||||
true ->
|
||||
case validate_specialist_update(Calendar, Updates) of
|
||||
{error, _} = E ->
|
||||
E;
|
||||
ok ->
|
||||
ValidUpdates = validate_updates(Updates, UserId),
|
||||
Title = proplists:get_value(title, ValidUpdates, Event#event.title),
|
||||
Desc = proplists:get_value(description, ValidUpdates, Event#event.description),
|
||||
@@ -204,6 +208,7 @@ update_event(UserId, EventId, Updates) ->
|
||||
Error ->
|
||||
Error
|
||||
end
|
||||
end
|
||||
end;
|
||||
false ->
|
||||
{error, access_denied}
|
||||
@@ -378,6 +383,8 @@ validate_update({start_time, Value}, UserId) ->
|
||||
end;
|
||||
validate_update({duration, Value}, _) when is_integer(Value), Value > 0 -> true;
|
||||
validate_update({specialist_id, Value}, _) when is_binary(Value) -> true;
|
||||
validate_update({specialist_id, null}, _) -> true;
|
||||
validate_update({specialist_id, undefined}, _) -> true;
|
||||
validate_update({location, Value}, _) ->
|
||||
case Value of
|
||||
#location{} -> true;
|
||||
@@ -389,6 +396,25 @@ validate_update({online_link, Value}, _) when is_binary(Value) -> true;
|
||||
validate_update({status, Value}, _) when is_atom(Value) -> true;
|
||||
validate_update(_, _) -> false.
|
||||
|
||||
validate_specialist_update(Calendar, Updates) ->
|
||||
case lists:keyfind(specialist_id, 1, Updates) of
|
||||
false -> ok;
|
||||
{specialist_id, null} -> ok;
|
||||
{specialist_id, undefined} -> ok;
|
||||
{specialist_id, <<>>} -> ok;
|
||||
{specialist_id, SpecId} when is_binary(SpecId) ->
|
||||
case Calendar#calendar.type of
|
||||
commercial ->
|
||||
case core_calendar_specialist:is_active_specialist(Calendar#calendar.id, SpecId) of
|
||||
true -> ok;
|
||||
false -> {error, invalid_specialist}
|
||||
end;
|
||||
_ ->
|
||||
{error, invalid_specialist}
|
||||
end;
|
||||
_ -> ok
|
||||
end.
|
||||
|
||||
get_exceptions(MasterId) ->
|
||||
Match = #recurrence_exception{master_id = MasterId, _ = '_'},
|
||||
mnesia:dirty_match_object(Match).
|
||||
|
||||
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
+61
@@ -0,0 +1,61 @@
|
||||
-module(logic_password_reset).
|
||||
-include("records.hrl").
|
||||
-export([request_reset/1, reset_password/2]).
|
||||
|
||||
-define(MIN_PASSWORD_LEN, 8).
|
||||
|
||||
%%%-------------------------------------------------------------------
|
||||
%%% @doc Запрос сброса пароля. Всегда `{ok, sent}` — без enumeration.
|
||||
%%% Письмо (stub) уходит только для `active` пользователей.
|
||||
%%% @end
|
||||
%%%-------------------------------------------------------------------
|
||||
-spec request_reset(Email :: binary()) -> {ok, sent}.
|
||||
request_reset(Email) when is_binary(Email) ->
|
||||
case core_user:get_by_email(Email) of
|
||||
{ok, #user{id = UserId, status = active, email = UserEmail}} ->
|
||||
{ok, Token, _Expires} = core_password_reset:create_token(UserId),
|
||||
logic_email:send_password_reset(UserEmail, Token),
|
||||
{ok, sent};
|
||||
_ ->
|
||||
{ok, sent}
|
||||
end;
|
||||
request_reset(_) ->
|
||||
{ok, sent}.
|
||||
|
||||
%%%-------------------------------------------------------------------
|
||||
%%% @doc Установка нового пароля по токену. Отзывает refresh-сессии user.
|
||||
%%% @end
|
||||
%%%-------------------------------------------------------------------
|
||||
-spec reset_password(Token :: binary(), Password :: binary()) ->
|
||||
ok | {error, expired | not_found | invalid_password | password_hash_failed | user_not_found | forbidden}.
|
||||
reset_password(Token, Password)
|
||||
when is_binary(Token), is_binary(Password), byte_size(Password) >= ?MIN_PASSWORD_LEN ->
|
||||
case core_password_reset:verify_token(Token) of
|
||||
{ok, UserId} ->
|
||||
case core_user:get_by_id(UserId) of
|
||||
{ok, #user{status = active}} ->
|
||||
case logic_auth:hash_password(Password) of
|
||||
{ok, NewHash} ->
|
||||
case core_user:update(UserId, [{password_hash, NewHash}]) of
|
||||
{ok, _} ->
|
||||
core_password_reset:delete_token(Token),
|
||||
core_auth_session:revoke_all_for_subject(UserId, user),
|
||||
ok;
|
||||
{error, not_found} ->
|
||||
{error, user_not_found};
|
||||
{error, _} = Err ->
|
||||
Err
|
||||
end;
|
||||
{error, _} ->
|
||||
{error, password_hash_failed}
|
||||
end;
|
||||
{ok, #user{}} ->
|
||||
{error, forbidden};
|
||||
{error, not_found} ->
|
||||
{error, user_not_found}
|
||||
end;
|
||||
{error, _} = Err ->
|
||||
Err
|
||||
end;
|
||||
reset_password(_, _) ->
|
||||
{error, invalid_password}.
|
||||
Regular → Executable
+16
-1
@@ -216,7 +216,22 @@ can_review(UserId, TargetType, TargetId) ->
|
||||
{ok, false}
|
||||
end;
|
||||
calendar ->
|
||||
{ok, true};
|
||||
case core_booking:list_by_user(UserId) of
|
||||
{ok, Bookings} ->
|
||||
Has = lists:any(
|
||||
fun(B) ->
|
||||
B#booking.status =:= confirmed andalso
|
||||
case core_event:get_by_id(B#booking.event_id) of
|
||||
{ok, Event} -> Event#event.calendar_id =:= TargetId;
|
||||
_ -> false
|
||||
end
|
||||
end,
|
||||
Bookings
|
||||
),
|
||||
{ok, Has};
|
||||
_ ->
|
||||
{ok, false}
|
||||
end;
|
||||
_ ->
|
||||
{ok, false}
|
||||
end.
|
||||
|
||||
Regular → Executable
+79
-11
@@ -16,6 +16,7 @@
|
||||
%% ─────────────────────────────────────────────────────────────────
|
||||
-define(DEFAULT_LIMIT, 20).
|
||||
-define(MAX_LIMIT, 100).
|
||||
-define(DISCOVERY_FETCH, 200).
|
||||
-define(EARTH_RADIUS_KM, 6371.0).
|
||||
|
||||
%%%-------------------------------------------------------------------
|
||||
@@ -36,6 +37,14 @@
|
||||
search(Type, Query, UserId, Params) ->
|
||||
Limit = min(maps:get(limit, Params, ?DEFAULT_LIMIT), ?MAX_LIMIT),
|
||||
Offset = maps:get(offset, Params, 0),
|
||||
case is_discovery_request(Query, Params) of
|
||||
true ->
|
||||
discovery_search(Type, UserId, Params, Limit, Offset);
|
||||
false ->
|
||||
filtered_search(Type, Query, UserId, Params, Limit, Offset)
|
||||
end.
|
||||
|
||||
filtered_search(Type, Query, UserId, Params, Limit, Offset) ->
|
||||
case Type of
|
||||
<<"event">> ->
|
||||
{ok, Total, Events} = search_events(Query, UserId, Params, Limit, Offset),
|
||||
@@ -52,6 +61,58 @@ search(Type, Query, UserId, Params) ->
|
||||
}}
|
||||
end.
|
||||
|
||||
%% Пустой search (страница «Главная»): tops из stats_tops; иначе — полный scan.
|
||||
is_discovery_request(Query, Params) ->
|
||||
QueryEmpty = Query =:= undefined orelse Query =:= <<>>,
|
||||
QueryEmpty andalso
|
||||
not maps:is_key(tags, Params) andalso
|
||||
not maps:is_key(from, Params) andalso
|
||||
not maps:is_key(to, Params) andalso
|
||||
not maps:is_key(lat, Params) andalso
|
||||
not maps:is_key(lon, Params) andalso
|
||||
not maps:is_key(sort, Params).
|
||||
|
||||
discovery_search(Type, UserId, Params, Limit, Offset) ->
|
||||
case Type of
|
||||
<<"event">> ->
|
||||
{ok, Total, Events} = discovery_events(UserId, Params, Limit, Offset),
|
||||
{ok, Total, #{<<"events">> => Events}};
|
||||
<<"calendar">> ->
|
||||
{ok, Total, Calendars} = discovery_calendars(UserId, Params, Limit, Offset),
|
||||
{ok, Total, #{<<"calendars">> => Calendars}};
|
||||
_ ->
|
||||
{ok, EventsTotal, Events} = discovery_events(UserId, Params, Limit, Offset),
|
||||
{ok, CalendarsTotal, Calendars} = discovery_calendars(UserId, Params, Limit, Offset),
|
||||
{ok, EventsTotal + CalendarsTotal, #{
|
||||
<<"events">> => Events,
|
||||
<<"calendars">> => Calendars
|
||||
}}
|
||||
end.
|
||||
|
||||
discovery_events(UserId, Params, Limit, Offset) ->
|
||||
FetchN = max(Limit + Offset, ?DISCOVERY_FETCH),
|
||||
Tops = core_event:get_top_events_by_rating(FetchN),
|
||||
Accessible = filter_accessible_events(Tops, UserId),
|
||||
case Accessible of
|
||||
[] ->
|
||||
search_events(undefined, UserId, Params, Limit, Offset);
|
||||
Items ->
|
||||
Total = length(Items),
|
||||
{ok, Total, format_events(paginate(Items, Limit, Offset))}
|
||||
end.
|
||||
|
||||
discovery_calendars(UserId, Params, Limit, Offset) ->
|
||||
FetchN = max(Limit + Offset, ?DISCOVERY_FETCH),
|
||||
Tops = core_calendar:get_top_calendars_by_rating(FetchN),
|
||||
Accessible = filter_accessible_calendars(Tops, UserId),
|
||||
case Accessible of
|
||||
[] ->
|
||||
search_calendars(undefined, UserId, Params, Limit, Offset);
|
||||
Items ->
|
||||
Total = length(Items),
|
||||
{ok, Total, format_calendars(paginate(Items, Limit, Offset))}
|
||||
end.
|
||||
|
||||
%% ============ Поиск событий ============
|
||||
|
||||
-spec search_events(Query :: binary() | undefined,
|
||||
@@ -102,23 +163,24 @@ filter_accessible_events(Events, UserId) ->
|
||||
lists:filter(fun(Event) ->
|
||||
case core_calendar:get_by_id(Event#event.calendar_id) of
|
||||
{ok, Calendar} ->
|
||||
CanAccess = logic_calendar:can_access(UserId, Calendar),
|
||||
case CanAccess of
|
||||
false ->
|
||||
%% io:format("Access denied for user ~p to calendar ~p (type: ~p, owner: ~p, status: ~p)~n",
|
||||
%% [UserId, Calendar#calendar.id, Calendar#calendar.type,
|
||||
%% Calendar#calendar.owner_id, Calendar#calendar.status]);
|
||||
false;
|
||||
true -> ok
|
||||
end,
|
||||
CanAccess;
|
||||
logic_calendar:can_access(UserId, Calendar)
|
||||
andalso calendar_discoverable(Calendar);
|
||||
_ -> false
|
||||
end
|
||||
end, Events).
|
||||
|
||||
-spec filter_accessible_calendars([#calendar{}], binary()) -> [#calendar{}].
|
||||
filter_accessible_calendars(Calendars, UserId) ->
|
||||
lists:filter(fun(Calendar) -> logic_calendar:can_access(UserId, Calendar) end, Calendars).
|
||||
lists:filter(fun(Calendar) ->
|
||||
logic_calendar:can_access(UserId, Calendar)
|
||||
andalso calendar_discoverable(Calendar)
|
||||
end, Calendars).
|
||||
|
||||
%% Restricted commercial не показываем в search/discovery (deep-link остаётся).
|
||||
calendar_discoverable(#calendar{type = commercial} = C) ->
|
||||
logic_calendar:booking_open(C);
|
||||
calendar_discoverable(_) ->
|
||||
true.
|
||||
|
||||
%% ============ Применение фильтров ============
|
||||
|
||||
@@ -263,9 +325,14 @@ format_event(Event) ->
|
||||
#location{address = Addr, lat = Lat, lon = Lon} ->
|
||||
#{address => Addr, lat => Lat, lon => Lon}
|
||||
end,
|
||||
CalendarTitle = case core_calendar:get_by_id(Event#event.calendar_id) of
|
||||
{ok, Cal} -> Cal#calendar.title;
|
||||
_ -> null
|
||||
end,
|
||||
#{
|
||||
id => Event#event.id,
|
||||
calendar_id => Event#event.calendar_id,
|
||||
calendar_title => CalendarTitle,
|
||||
title => Event#event.title,
|
||||
description => Event#event.description,
|
||||
event_type => Event#event.event_type,
|
||||
@@ -291,6 +358,7 @@ format_calendar(Calendar) ->
|
||||
title => Calendar#calendar.title,
|
||||
description => Calendar#calendar.description,
|
||||
type => Calendar#calendar.type,
|
||||
booking_open => logic_calendar:booking_open(Calendar),
|
||||
tags => Calendar#calendar.tags,
|
||||
rating_avg => Calendar#calendar.rating_avg,
|
||||
rating_count => Calendar#calendar.rating_count,
|
||||
|
||||
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.
|
||||
@@ -6,8 +6,6 @@
|
||||
-export([check_user_subscription/1, can_create_commercial_calendar/1]).
|
||||
-export([handle_expired_subscriptions/0]).
|
||||
|
||||
-define(TRIAL_DAYS, 30).
|
||||
|
||||
%% ============ Управление подписками ============
|
||||
|
||||
%% Начать пробный период (вызывается при первой попытке создать commercial календарь)
|
||||
@@ -22,6 +20,7 @@ start_trial(UserId) ->
|
||||
true ->
|
||||
{error, trial_already_used};
|
||||
false ->
|
||||
% trial_used=true — флаг; длительность из plan=trial (~1 месяц)
|
||||
case core_subscription:create(UserId, trial, true) of
|
||||
{ok, Subscription} ->
|
||||
{ok, Subscription};
|
||||
@@ -34,7 +33,7 @@ start_trial(UserId) ->
|
||||
activate_subscription(UserId, Plan, PaymentInfo) ->
|
||||
case process_payment(PaymentInfo, plan_price(Plan)) of
|
||||
ok ->
|
||||
% Проверяем, была ли у пользователя хоть одна подписка
|
||||
% Флаг trial_used: была ли уже любая подписка (не влияет на длительность)
|
||||
{ok, AllSubs} = core_subscription:list_by_user(UserId),
|
||||
TrialUsed = length(AllSubs) > 0,
|
||||
|
||||
@@ -45,7 +44,7 @@ activate_subscription(UserId, Plan, PaymentInfo) ->
|
||||
_ -> ok
|
||||
end,
|
||||
|
||||
% Создаём новую подписку
|
||||
% Длительность всегда из Plan (plan_to_months), TrialUsed — только флаг
|
||||
case core_subscription:create(UserId, Plan, TrialUsed) of
|
||||
{ok, Subscription} ->
|
||||
{ok, Subscription};
|
||||
|
||||
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.
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
%% @doc Create calendar_follow table and indexes if missing.
|
||||
-module('20260720210000_calendar_follow').
|
||||
|
||||
-export([up/0, down/0]).
|
||||
|
||||
-include("records.hrl").
|
||||
|
||||
up() ->
|
||||
ensure_table(calendar_follow, record_info(fields, calendar_follow)),
|
||||
ensure_index(calendar_follow, calendar_id),
|
||||
ensure_index(calendar_follow, user_id),
|
||||
ok.
|
||||
|
||||
down() ->
|
||||
_ = mnesia:delete_table(calendar_follow),
|
||||
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.
|
||||
@@ -0,0 +1,47 @@
|
||||
%% @doc calendar_specialist: PK id (было calendar_id — один specialist на календарь).
|
||||
%% Таблица не использовалась в API — безопасно пересоздать.
|
||||
-module('20260722150000_calendar_specialist_id').
|
||||
|
||||
-export([up/0, down/0]).
|
||||
|
||||
-include("records.hrl").
|
||||
|
||||
up() ->
|
||||
case lists:member(calendar_specialist, mnesia:system_info(tables)) of
|
||||
true ->
|
||||
Attrs = mnesia:table_info(calendar_specialist, attributes),
|
||||
case Attrs =:= record_info(fields, calendar_specialist) of
|
||||
true ->
|
||||
ok;
|
||||
false ->
|
||||
_ = mnesia:delete_table(calendar_specialist),
|
||||
create_table()
|
||||
end;
|
||||
false ->
|
||||
create_table()
|
||||
end,
|
||||
ensure_index(calendar_id),
|
||||
ensure_index(user_id),
|
||||
ok.
|
||||
|
||||
down() ->
|
||||
%% откат к старому layout без id не восстанавливаем данные
|
||||
ok.
|
||||
|
||||
create_table() ->
|
||||
case mnesia:create_table(calendar_specialist, [
|
||||
{disc_copies, [node()]},
|
||||
{attributes, record_info(fields, calendar_specialist)}
|
||||
]) of
|
||||
{atomic, ok} -> ok;
|
||||
{aborted, {already_exists, calendar_specialist}} -> ok;
|
||||
{aborted, Reason} -> error({create_table_failed, calendar_specialist, Reason})
|
||||
end.
|
||||
|
||||
ensure_index(Attr) ->
|
||||
case mnesia:add_table_index(calendar_specialist, Attr) of
|
||||
{atomic, ok} -> ok;
|
||||
{aborted, {already_exists, _, _}} -> ok;
|
||||
{aborted, {already_exists, _}} -> ok;
|
||||
{aborted, Reason} -> error({add_index_failed, calendar_specialist, Attr, Reason})
|
||||
end.
|
||||
+40
@@ -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.
|
||||
Regular → Executable
+9
@@ -18,6 +18,7 @@ admin() ->
|
||||
admin_handler_users,
|
||||
admin_handler_user_by_id,
|
||||
admin_handler_user_verification_token,
|
||||
admin_handler_user_password_reset_token,
|
||||
admin_handler_user_stats,
|
||||
% ================== КАЛЕНДАРИ ==================
|
||||
admin_handler_calendars,
|
||||
@@ -69,9 +70,16 @@ user() ->
|
||||
handler_login,
|
||||
handler_refresh,
|
||||
handler_verify,
|
||||
handler_forgot_password,
|
||||
handler_reset_password,
|
||||
handler_booking_by_id,
|
||||
handler_bookings,
|
||||
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,
|
||||
@@ -86,6 +94,7 @@ user() ->
|
||||
handler_ticket_by_id,
|
||||
handler_tickets,
|
||||
handler_user_bookings,
|
||||
handler_user_following,
|
||||
handler_user_me,
|
||||
handler_user_reviews
|
||||
],
|
||||
|
||||
Regular → Executable
+5
-2
@@ -24,8 +24,11 @@ test() ->
|
||||
ct:pal("=== Admin Moderation Tests ==="),
|
||||
Token = api_test_runner:get_admin_token(),
|
||||
UserToken = api_test_runner:get_user_token(),
|
||||
% Создаём календарь и событие
|
||||
CalId = api_test_runner:create_calendar(UserToken, #{title => <<"ModTestCal">>}),
|
||||
% Создаём commercial-календарь и событие (бронь только на commercial)
|
||||
CalId = api_test_runner:create_calendar(UserToken, #{
|
||||
title => <<"ModTestCal">>,
|
||||
type => <<"commercial">>,
|
||||
confirmation => <<"manual">>}),
|
||||
EventId = api_test_runner:create_event(UserToken, CalId, #{
|
||||
title => <<"Event to moderate">>,
|
||||
start_time => api_test_runner:future_date(),
|
||||
|
||||
Regular → Executable
+5
-2
@@ -28,8 +28,11 @@ test() ->
|
||||
ct:pal("=== Admin Reviews Tests ==="),
|
||||
Token = api_test_runner:get_admin_token(),
|
||||
UserToken = api_test_runner:get_user_token(),
|
||||
%% Создаём тестовые данные: календарь, событие
|
||||
CalId = api_test_runner:create_calendar(UserToken, #{title => <<"ReviewsTestCal">>}),
|
||||
%% Создаём тестовые данные: commercial-календарь (бронь только на нём), событие
|
||||
CalId = api_test_runner:create_calendar(UserToken, #{
|
||||
title => <<"ReviewsTestCal">>,
|
||||
type => <<"commercial">>,
|
||||
confirmation => <<"manual">>}),
|
||||
EventId = api_test_runner:create_event(UserToken, CalId, #{
|
||||
title => <<"Event for review testing">>,
|
||||
start_time => api_test_runner:future_date(),
|
||||
|
||||
Regular → Executable
+10
-5
@@ -40,22 +40,27 @@ test() ->
|
||||
|
||||
% Создаём тестовые данные для ненулевой статистики
|
||||
UserToken = api_test_runner:get_user_token(),
|
||||
CalId = api_test_runner:create_calendar(UserToken, #{title => <<"StatsCal">>}),
|
||||
ParticipantEmail = api_test_runner:unique_email(<<"statspart">>),
|
||||
ParticipantToken = api_test_runner:register_and_login(ParticipantEmail, <<"pass">>),
|
||||
CalId = api_test_runner:create_calendar(UserToken, #{
|
||||
title => <<"StatsCal">>,
|
||||
type => <<"commercial">>,
|
||||
confirmation => <<"manual">>}),
|
||||
EventId = api_test_runner:create_event(UserToken, CalId, #{
|
||||
title => <<"Stats Event">>,
|
||||
start_time => api_test_runner:future_date(),
|
||||
duration => 60
|
||||
}),
|
||||
% Бронируем и подтверждаем, чтобы можно было оставить отзыв
|
||||
% Бронируем участником (не владельцем) и подтверждаем, чтобы можно было оставить отзыв
|
||||
#{<<"id">> := BookingId} = api_test_runner:client_post(
|
||||
<<"/v1/events/", EventId/binary, "/bookings">>, UserToken, #{}),
|
||||
<<"/v1/events/", EventId/binary, "/bookings">>, ParticipantToken, #{}),
|
||||
api_test_runner:client_put(<<"/v1/bookings/", BookingId/binary>>, UserToken,
|
||||
#{action => <<"confirm">>}),
|
||||
% Оставляем отзыв
|
||||
api_test_runner:client_post(<<"/v1/reviews">>, UserToken,
|
||||
api_test_runner:client_post(<<"/v1/reviews">>, ParticipantToken,
|
||||
#{target_type => <<"event">>, target_id => EventId, rating => 5, comment => <<"Great!">>}),
|
||||
% Жалоба
|
||||
api_test_runner:client_post(<<"/v1/reports">>, UserToken,
|
||||
api_test_runner:client_post(<<"/v1/reports">>, ParticipantToken,
|
||||
#{<<"target_type">> => <<"event">>, <<"target_id">> => EventId, <<"reason">> => <<"Test">>}),
|
||||
% Подписка
|
||||
SubUserToken = api_test_runner:get_user_token(),
|
||||
|
||||
@@ -34,10 +34,12 @@ test() ->
|
||||
UserToken = api_test_runner:get_user_token(),
|
||||
% Создаём два тикета для разных проверок
|
||||
Ticket1 = api_test_runner:client_post(<<"/v1/tickets">>, UserToken,
|
||||
#{<<"error_message">> => <<"Test bug">>, <<"stacktrace">> => <<"trace">>}),
|
||||
#{<<"error_message">> => api_test_runner:unique_ticket_message(<<"Test bug">>),
|
||||
<<"stacktrace">> => <<"trace">>}),
|
||||
#{<<"id">> := Ticket1Id} = Ticket1,
|
||||
Ticket2 = api_test_runner:client_post(<<"/v1/tickets">>, UserToken,
|
||||
#{<<"error_message">> => <<"Another bug">>, <<"stacktrace">> => <<"trace2">>}),
|
||||
#{<<"error_message">> => api_test_runner:unique_ticket_message(<<"Another bug">>),
|
||||
<<"stacktrace">> => <<"trace2">>}),
|
||||
#{<<"id">> := Ticket2Id} = Ticket2,
|
||||
|
||||
% Получаем ID текущего администратора для теста фильтрации по исполнителю
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
get_support_token/0,
|
||||
get_user_token/0,
|
||||
unique_email/1,
|
||||
unique_ticket_message/1,
|
||||
future_date/0,
|
||||
register_and_login/2,
|
||||
create_calendar/2,
|
||||
@@ -335,6 +336,16 @@ unique_email(Prefix) ->
|
||||
Unique = integer_to_binary(erlang:system_time()),
|
||||
<<Prefix/binary, "_", Unique/binary, "@test.local">>.
|
||||
|
||||
%% Уникальное сообщение тикета на прогон (IFT: иначе дедуп по error_hash
|
||||
%% возвращает чужой reporter_id и list/get своих тикетов падает).
|
||||
%% system_time — стабильно уникален между контейнерами; unique_integer —
|
||||
%% различает вызовы внутри одного прогона (monotonic сбрасывается при старте BEAM).
|
||||
-spec unique_ticket_message(binary()) -> binary().
|
||||
unique_ticket_message(Prefix) ->
|
||||
Time = integer_to_binary(erlang:system_time()),
|
||||
Seq = integer_to_binary(erlang:unique_integer([positive, monotonic])),
|
||||
<<Prefix/binary, " ", Time/binary, "-", Seq/binary>>.
|
||||
|
||||
-spec future_date() -> calendar:datetime().
|
||||
future_date() ->
|
||||
Seconds = calendar:datetime_to_gregorian_seconds(calendar:universal_time()) + 86400,
|
||||
|
||||
Regular → Executable
+11
-1
@@ -91,8 +91,18 @@ test_report_on_review(Admin) ->
|
||||
<<"report_threshold">> => 3
|
||||
}),
|
||||
Owner = api_test_runner:get_user_token(),
|
||||
CalId = api_test_runner:create_calendar(Owner, #{title => <<"RevRepCal">>}),
|
||||
CalId = api_test_runner:create_calendar(Owner, #{
|
||||
title => <<"RevRepCal">>, type => <<"commercial">>, confirmation => <<"manual">>}),
|
||||
#{<<"id">> := EventId} = api_test_runner:client_post(
|
||||
<<"/v1/calendars/", CalId/binary, "/events">>, Owner,
|
||||
#{title => <<"Event for review report">>,
|
||||
start_time => api_test_runner:future_date_iso8601(),
|
||||
duration => 60}),
|
||||
Reviewer = api_test_runner:get_user_token(),
|
||||
#{<<"id">> := BookingId} = api_test_runner:client_post(
|
||||
<<"/v1/events/", EventId/binary, "/bookings">>, Reviewer, #{}),
|
||||
api_test_runner:client_put(<<"/v1/bookings/", BookingId/binary>>, Owner,
|
||||
#{action => <<"confirm">>}),
|
||||
{ok, 201, _, RevBody} = api_test_runner:client_request(post, <<"/v1/reviews">>, Reviewer,
|
||||
jsx:encode(#{
|
||||
target_type => <<"calendar">>,
|
||||
|
||||
Regular → Executable
+5
-1
@@ -31,7 +31,11 @@ test() ->
|
||||
ParticipantToken = api_test_runner:register_and_login(ParticipantEmail, <<"pass">>),
|
||||
|
||||
% Создаём календарь и событие
|
||||
CalId = api_test_runner:create_calendar(OwnerToken, #{title => <<"BookingTest">>}),
|
||||
CalId = api_test_runner:create_calendar(OwnerToken, #{
|
||||
title => <<"BookingTest">>,
|
||||
type => <<"commercial">>,
|
||||
confirmation => <<"manual">>
|
||||
}),
|
||||
#{<<"id">> := EventId} = api_test_runner:client_post(
|
||||
<<"/v1/calendars/", CalId/binary, "/events">>, OwnerToken,
|
||||
#{title => <<"Event to book">>,
|
||||
|
||||
+113
@@ -0,0 +1,113 @@
|
||||
%%%-------------------------------------------------------------------
|
||||
%%% @doc Тесты follow / unfollow чужого календаря.
|
||||
%%%
|
||||
%%% POST /v1/calendars/:id/follow
|
||||
%%% DELETE /v1/calendars/:id/follow
|
||||
%%% GET /v1/user/following
|
||||
%%% GET /v1/calendars/:id (поле following)
|
||||
%%% @end
|
||||
%%%-------------------------------------------------------------------
|
||||
-module(user_calendar_follow_tests).
|
||||
-include_lib("eunit/include/eunit.hrl").
|
||||
|
||||
-export([test/0]).
|
||||
|
||||
-spec test() -> ok.
|
||||
test() ->
|
||||
ct:pal("=== User Calendar Follow Tests ==="),
|
||||
OwnerEmail = api_test_runner:unique_email(<<"flowner">>),
|
||||
OwnerToken = api_test_runner:register_and_login(OwnerEmail, <<"pass">>),
|
||||
FollowerEmail = api_test_runner:unique_email(<<"fluser">>),
|
||||
FollowerToken = api_test_runner:register_and_login(FollowerEmail, <<"pass">>),
|
||||
|
||||
%% commercial calendar requires subscription
|
||||
{ok, 201, _, _} = api_test_runner:client_request(
|
||||
post, <<"/v1/subscription">>, OwnerToken,
|
||||
jsx:encode(#{action => <<"start_trial">>})),
|
||||
CalId = api_test_runner:create_calendar(OwnerToken, #{
|
||||
title => <<"FollowMe">>,
|
||||
type => <<"commercial">>
|
||||
}),
|
||||
FollowPath = <<"/v1/calendars/", CalId/binary, "/follow">>,
|
||||
|
||||
test_follow(FollowerToken, FollowPath, CalId),
|
||||
test_follow_idempotent(FollowerToken, FollowPath, CalId),
|
||||
test_get_calendar_following(FollowerToken, CalId, true),
|
||||
test_list_following(FollowerToken, CalId),
|
||||
test_own_forbidden(OwnerToken, FollowPath),
|
||||
test_unfollow(FollowerToken, FollowPath, CalId),
|
||||
test_unfollow_idempotent(FollowerToken, FollowPath, CalId),
|
||||
test_get_calendar_following(FollowerToken, CalId, false),
|
||||
test_list_following_empty(FollowerToken),
|
||||
test_unauthorized(FollowPath),
|
||||
|
||||
ct:pal("=== All user calendar follow tests passed ==="),
|
||||
ok.
|
||||
|
||||
%%%===================================================================
|
||||
%%% Cases
|
||||
%%%===================================================================
|
||||
|
||||
test_follow(Token, Path, CalId) ->
|
||||
ct:pal(" TEST: POST follow"),
|
||||
{ok, 200, _, Body} = api_test_runner:client_request(post, Path, Token, <<"{}">>),
|
||||
Resp = jsx:decode(list_to_binary(Body), [return_maps]),
|
||||
?assertEqual(CalId, maps:get(<<"calendar_id">>, Resp)),
|
||||
?assertEqual(true, maps:get(<<"following">>, Resp)),
|
||||
ct:pal(" OK").
|
||||
|
||||
test_follow_idempotent(Token, Path, CalId) ->
|
||||
ct:pal(" TEST: POST follow idempotent"),
|
||||
{ok, 200, _, Body} = api_test_runner:client_request(post, Path, Token, <<"{}">>),
|
||||
Resp = jsx:decode(list_to_binary(Body), [return_maps]),
|
||||
?assertEqual(CalId, maps:get(<<"calendar_id">>, Resp)),
|
||||
?assertEqual(true, maps:get(<<"following">>, Resp)),
|
||||
ct:pal(" OK").
|
||||
|
||||
test_get_calendar_following(Token, CalId, Expected) ->
|
||||
ct:pal(" TEST: GET calendar following=~p", [Expected]),
|
||||
Cal = api_test_runner:client_get(<<"/v1/calendars/", CalId/binary>>, Token),
|
||||
?assertEqual(Expected, maps:get(<<"following">>, Cal)),
|
||||
ct:pal(" OK").
|
||||
|
||||
test_list_following(Token, CalId) ->
|
||||
ct:pal(" TEST: GET /v1/user/following"),
|
||||
List = api_test_runner:client_get(<<"/v1/user/following">>, Token),
|
||||
?assert(is_list(List)),
|
||||
Match = [C || C <- List, maps:get(<<"id">>, C) =:= CalId],
|
||||
?assertMatch([_], Match),
|
||||
[C] = Match,
|
||||
?assertEqual(true, maps:get(<<"following">>, C)),
|
||||
ct:pal(" OK").
|
||||
|
||||
test_own_forbidden(Token, Path) ->
|
||||
ct:pal(" TEST: follow own calendar forbidden"),
|
||||
Resp = api_test_runner:client_request(post, Path, Token, <<"{}">>),
|
||||
?assertMatch({ok, 403, _, _}, Resp),
|
||||
ct:pal(" OK").
|
||||
|
||||
test_unfollow(Token, Path, CalId) ->
|
||||
ct:pal(" TEST: DELETE unfollow"),
|
||||
Resp = api_test_runner:client_delete(Path, Token),
|
||||
?assertEqual(CalId, maps:get(<<"calendar_id">>, Resp)),
|
||||
?assertEqual(false, maps:get(<<"following">>, Resp)),
|
||||
ct:pal(" OK").
|
||||
|
||||
test_unfollow_idempotent(Token, Path, CalId) ->
|
||||
ct:pal(" TEST: DELETE unfollow idempotent"),
|
||||
Resp = api_test_runner:client_delete(Path, Token),
|
||||
?assertEqual(CalId, maps:get(<<"calendar_id">>, Resp)),
|
||||
?assertEqual(false, maps:get(<<"following">>, Resp)),
|
||||
ct:pal(" OK").
|
||||
|
||||
test_list_following_empty(Token) ->
|
||||
ct:pal(" TEST: GET following empty"),
|
||||
List = api_test_runner:client_get(<<"/v1/user/following">>, Token),
|
||||
?assertEqual([], List),
|
||||
ct:pal(" OK").
|
||||
|
||||
test_unauthorized(Path) ->
|
||||
ct:pal(" TEST: unauthorized"),
|
||||
Resp = api_test_runner:client_request(post, Path, <<>>, <<"{}">>),
|
||||
?assertMatch({ok, 401, _, _}, Resp),
|
||||
ct:pal(" OK").
|
||||
Regular → Executable
+5
-1
@@ -27,7 +27,11 @@ test() ->
|
||||
ParticipantToken = api_test_runner:register_and_login(ParticipantEmail, <<"pass">>),
|
||||
|
||||
% Создаём календарь и событие
|
||||
CalId = api_test_runner:create_calendar(OwnerToken, #{title => <<"MyBookTest">>}),
|
||||
CalId = api_test_runner:create_calendar(OwnerToken, #{
|
||||
title => <<"MyBookTest">>,
|
||||
type => <<"commercial">>,
|
||||
confirmation => <<"manual">>
|
||||
}),
|
||||
#{<<"id">> := EventId} = api_test_runner:client_post(
|
||||
<<"/v1/calendars/", CalId/binary, "/events">>, OwnerToken,
|
||||
#{title => <<"Event for my booking">>,
|
||||
|
||||
Regular → Executable
+2
-1
@@ -27,7 +27,8 @@ test() ->
|
||||
ParticipantToken = api_test_runner:register_and_login(ParticipantEmail, <<"pass">>),
|
||||
|
||||
% Создаём календарь и событие
|
||||
CalId = api_test_runner:create_calendar(OwnerToken, #{title => <<"MyRevTest">>}),
|
||||
CalId = api_test_runner:create_calendar(OwnerToken, #{
|
||||
title => <<"MyRevTest">>, type => <<"commercial">>, confirmation => <<"manual">>}),
|
||||
#{<<"id">> := EventId} = api_test_runner:client_post(
|
||||
<<"/v1/calendars/", CalId/binary, "/events">>, OwnerToken,
|
||||
#{title => <<"Event for my review">>,
|
||||
|
||||
Executable
+60
@@ -0,0 +1,60 @@
|
||||
%%%-------------------------------------------------------------------
|
||||
%%% @doc API-тесты forgot/reset password.
|
||||
%%% @end
|
||||
%%%-------------------------------------------------------------------
|
||||
-module(user_password_reset_tests).
|
||||
-include_lib("eunit/include/eunit.hrl").
|
||||
-export([test/0]).
|
||||
|
||||
-spec test() -> ok.
|
||||
test() ->
|
||||
ct:pal("=== User Password Reset Tests ==="),
|
||||
AdminToken = api_test_runner:get_admin_token(),
|
||||
Email = api_test_runner:unique_email(<<"pwreset">>),
|
||||
OldPass = <<"OldPass123">>,
|
||||
NewPass = <<"NewPass456">>,
|
||||
|
||||
%% register + verify
|
||||
{ok, 201, _, RegBody} = api_test_runner:client_request(post, <<"/v1/register">>, <<>>,
|
||||
jsx:encode(#{email => Email, password => OldPass})),
|
||||
#{<<"user">> := #{<<"id">> := UserId}} =
|
||||
jsx:decode(list_to_binary(RegBody), [return_maps]),
|
||||
#{<<"token">> := VerifyToken} = api_test_runner:admin_get(
|
||||
<<"/v1/admin/users/", UserId/binary, "/verification-token">>, AdminToken),
|
||||
{ok, 200, _, _} = api_test_runner:client_request(post, <<"/v1/verify">>, <<>>,
|
||||
jsx:encode(#{<<"token">> => VerifyToken})),
|
||||
|
||||
%% forgot always 200 (known + unknown)
|
||||
{ok, 200, _, _} = api_test_runner:client_request(post, <<"/v1/forgot-password">>, <<>>,
|
||||
jsx:encode(#{email => Email})),
|
||||
{ok, 200, _, _} = api_test_runner:client_request(post, <<"/v1/forgot-password">>, <<>>,
|
||||
jsx:encode(#{email => <<"nobody-reset@test.local">>})),
|
||||
|
||||
%% admin token + reset
|
||||
#{<<"token">> := ResetToken} = api_test_runner:admin_get(
|
||||
<<"/v1/admin/users/", UserId/binary, "/password-reset-token">>, AdminToken),
|
||||
{ok, 200, _, _} = api_test_runner:client_request(post, <<"/v1/reset-password">>, <<>>,
|
||||
jsx:encode(#{<<"token">> => ResetToken, <<"password">> => NewPass})),
|
||||
|
||||
%% old password fails, new works
|
||||
{ok, 401, _, _} = api_test_runner:client_request(post, <<"/v1/login">>, <<>>,
|
||||
jsx:encode(#{email => Email, password => OldPass})),
|
||||
{ok, 200, _, _} = api_test_runner:client_request(post, <<"/v1/login">>, <<>>,
|
||||
jsx:encode(#{email => Email, password => NewPass})),
|
||||
|
||||
%% token reuse → 404
|
||||
{ok, 404, _, _} = api_test_runner:client_request(post, <<"/v1/reset-password">>, <<>>,
|
||||
jsx:encode(#{<<"token">> => ResetToken, <<"password">> => <<"AnotherPass9">>})),
|
||||
|
||||
%% short password → 400
|
||||
#{<<"token">> := ResetToken2} = api_test_runner:admin_get(
|
||||
<<"/v1/admin/users/", UserId/binary, "/password-reset-token">>, AdminToken),
|
||||
{ok, 400, _, _} = api_test_runner:client_request(post, <<"/v1/reset-password">>, <<>>,
|
||||
jsx:encode(#{<<"token">> => ResetToken2, <<"password">> => <<"short">>})),
|
||||
|
||||
%% invalid token → 404
|
||||
{ok, 404, _, _} = api_test_runner:client_request(post, <<"/v1/reset-password">>, <<>>,
|
||||
jsx:encode(#{<<"token">> => <<"nope">>, <<"password">> => NewPass})),
|
||||
|
||||
ct:pal("=== All user password reset tests passed ==="),
|
||||
ok.
|
||||
Regular → Executable
+2
-1
@@ -34,7 +34,8 @@ test() ->
|
||||
StrangerToken = api_test_runner:register_and_login(StrangerEmail, <<"pass">>),
|
||||
|
||||
% Создаём календарь, событие, бронирование и отзыв
|
||||
CalId = api_test_runner:create_calendar(OwnerToken, #{title => <<"RevById">>}),
|
||||
CalId = api_test_runner:create_calendar(OwnerToken, #{
|
||||
title => <<"RevById">>, type => <<"commercial">>, confirmation => <<"manual">>}),
|
||||
#{<<"id">> := EventId} = api_test_runner:client_post(
|
||||
<<"/v1/calendars/", CalId/binary, "/events">>, OwnerToken,
|
||||
#{title => <<"Event for review">>,
|
||||
|
||||
Regular → Executable
+2
-1
@@ -22,7 +22,8 @@ test() ->
|
||||
OtherEmail = api_test_runner:unique_email(<<"rvother">>),
|
||||
OtherToken = api_test_runner:register_and_login(OtherEmail, <<"pass">>),
|
||||
|
||||
CalId = api_test_runner:create_calendar(OwnerToken, #{title => <<"VoteCal">>}),
|
||||
CalId = api_test_runner:create_calendar(OwnerToken, #{
|
||||
title => <<"VoteCal">>, type => <<"commercial">>, confirmation => <<"manual">>}),
|
||||
#{<<"id">> := EventId} = api_test_runner:client_post(
|
||||
<<"/v1/calendars/", CalId/binary, "/events">>, OwnerToken,
|
||||
#{title => <<"Event for votes">>,
|
||||
|
||||
Regular → Executable
+5
-1
@@ -31,7 +31,11 @@ test() ->
|
||||
StrangerToken = api_test_runner:register_and_login(StrangerEmail, <<"pass">>),
|
||||
|
||||
% Создаём календарь и событие
|
||||
CalId = api_test_runner:create_calendar(OwnerToken, #{title => <<"ReviewTest">>}),
|
||||
CalId = api_test_runner:create_calendar(OwnerToken, #{
|
||||
title => <<"ReviewTest">>,
|
||||
type => <<"commercial">>,
|
||||
confirmation => <<"manual">>
|
||||
}),
|
||||
#{<<"id">> := EventId} = api_test_runner:client_post(
|
||||
<<"/v1/calendars/", CalId/binary, "/events">>, OwnerToken,
|
||||
#{title => <<"Event for review">>,
|
||||
|
||||
@@ -31,18 +31,21 @@ test() ->
|
||||
StrangerEmail = api_test_runner:unique_email(<<"stranger">>),
|
||||
StrangerToken = api_test_runner:register_and_login(StrangerEmail, <<"pass">>),
|
||||
|
||||
PrimaryMsg = api_test_runner:unique_ticket_message(<<"Something broke">>),
|
||||
DedupeMsg = api_test_runner:unique_ticket_message(<<"Dedupe me">>),
|
||||
|
||||
% Создаём тикет
|
||||
#{<<"id">> := TicketId} = api_test_runner:client_post(<<"/v1/tickets">>, Token,
|
||||
#{error_message => <<"Something broke">>, stacktrace => <<"line 42">>}),
|
||||
#{error_message => PrimaryMsg, stacktrace => <<"line 42">>}),
|
||||
|
||||
test_create_ticket(Token),
|
||||
test_create_ticket_dedupe(Token),
|
||||
test_create_ticket(Token, api_test_runner:unique_ticket_message(<<"Test bug">>)),
|
||||
test_create_ticket_dedupe(Token, DedupeMsg),
|
||||
test_create_ticket_manual(Token),
|
||||
test_create_ticket_missing_fields(Token),
|
||||
test_create_ticket_unauthorized(),
|
||||
test_list_tickets(Token, TicketId),
|
||||
test_list_tickets_unauthorized(),
|
||||
test_get_ticket(Token, TicketId),
|
||||
test_get_ticket(Token, TicketId, PrimaryMsg),
|
||||
test_get_ticket_forbidden(StrangerToken, TicketId),
|
||||
test_get_ticket_not_found(Token),
|
||||
test_get_ticket_unauthorized(TicketId),
|
||||
@@ -54,12 +57,12 @@ test() ->
|
||||
%%%===================================================================
|
||||
|
||||
%% @doc Успешное создание тикета: 201 Created.
|
||||
-spec test_create_ticket(binary()) -> ok.
|
||||
test_create_ticket(Token) ->
|
||||
-spec test_create_ticket(binary(), binary()) -> ok.
|
||||
test_create_ticket(Token, ErrorMessage) ->
|
||||
ct:pal(" TEST: Create a ticket"),
|
||||
Resp = api_test_runner:client_request(post, <<"/v1/tickets">>, Token,
|
||||
jsx:encode(#{
|
||||
error_message => <<"Test bug">>,
|
||||
error_message => ErrorMessage,
|
||||
stacktrace => <<"trace">>,
|
||||
source => <<"frontend">>,
|
||||
context => #{route => <<"/test">>, build => <<"dev">>}
|
||||
@@ -75,11 +78,11 @@ test_create_ticket(Token) ->
|
||||
ct:pal(" OK: ticket ~s created", [Id]).
|
||||
|
||||
%% @doc Повторный POST с тем же сообщением увеличивает count.
|
||||
-spec test_create_ticket_dedupe(binary()) -> ok.
|
||||
test_create_ticket_dedupe(Token) ->
|
||||
-spec test_create_ticket_dedupe(binary(), binary()) -> ok.
|
||||
test_create_ticket_dedupe(Token, ErrorMessage) ->
|
||||
ct:pal(" TEST: Dedupe ticket by hash"),
|
||||
Payload = jsx:encode(#{
|
||||
error_message => <<"Dedupe me">>,
|
||||
error_message => ErrorMessage,
|
||||
stacktrace => <<"same stack">>,
|
||||
source => <<"frontend">>
|
||||
}),
|
||||
@@ -97,7 +100,7 @@ test_create_ticket_manual(Token) ->
|
||||
ct:pal(" TEST: Create manual ticket"),
|
||||
Resp = api_test_runner:client_request(post, <<"/v1/tickets">>, Token,
|
||||
jsx:encode(#{
|
||||
error_message => <<"Button does nothing">>,
|
||||
error_message => api_test_runner:unique_ticket_message(<<"Button does nothing">>),
|
||||
source => <<"manual">>,
|
||||
context => #{steps => <<"1. Open calendar\n2. Click share">>}
|
||||
})),
|
||||
@@ -146,13 +149,13 @@ test_list_tickets_unauthorized() ->
|
||||
ct:pal(" OK: got 401").
|
||||
|
||||
%% @doc GET /v1/tickets/:id – получение своего тикета.
|
||||
-spec test_get_ticket(binary(), binary()) -> ok.
|
||||
test_get_ticket(Token, TicketId) ->
|
||||
-spec test_get_ticket(binary(), binary(), binary()) -> ok.
|
||||
test_get_ticket(Token, TicketId, ExpectedMessage) ->
|
||||
ct:pal(" TEST: Get my ticket by ID"),
|
||||
Path = <<"/v1/tickets/", TicketId/binary>>,
|
||||
Ticket = api_test_runner:client_get(Path, Token),
|
||||
?assertEqual(TicketId, maps:get(<<"id">>, Ticket)),
|
||||
?assertEqual(<<"Something broke">>, maps:get(<<"error_message">>, Ticket)),
|
||||
?assertEqual(ExpectedMessage, maps:get(<<"error_message">>, Ticket)),
|
||||
ct:pal(" OK: got my ticket").
|
||||
|
||||
%% @doc GET /v1/tickets/:id – попытка доступа к чужому тикету (403).
|
||||
|
||||
@@ -47,11 +47,27 @@ test() ->
|
||||
#{<<"token">> := AuthToken} = jsx:decode(list_to_binary(LoginBody), [return_maps]),
|
||||
?assert(is_binary(AuthToken)),
|
||||
|
||||
% 6. Повторное использование того же токена – ошибка 404
|
||||
% 6. После активации — дефолтный приватный personal-календарь
|
||||
Calendars = api_test_runner:client_get(<<"/v1/calendars">>, AuthToken),
|
||||
?assertEqual(1, length(Calendars)),
|
||||
[DefaultCal] = Calendars,
|
||||
?assertEqual(<<"personal">>, maps:get(<<"type">>, DefaultCal)),
|
||||
?assertEqual(<<>>, maps:get(<<"short_name">>, DefaultCal)),
|
||||
ExpectedTitle = case string:split(Email, <<"@">>) of
|
||||
[Local, _] when byte_size(Local) > 0 -> Local;
|
||||
_ -> <<"Мой календарь">>
|
||||
end,
|
||||
?assertEqual(ExpectedTitle, maps:get(<<"title">>, DefaultCal)),
|
||||
|
||||
% 7. Повторное использование того же токена – ошибка 404
|
||||
{ok, 404, _, _} = api_test_runner:client_request(post, <<"/v1/verify">>, <<>>,
|
||||
jsx:encode(#{<<"token">> => Token})),
|
||||
|
||||
% 7. Невалидный токен – ошибка 404
|
||||
% 7. Повторное использование того же токена – ошибка 404
|
||||
{ok, 404, _, _} = api_test_runner:client_request(post, <<"/v1/verify">>, <<>>,
|
||||
jsx:encode(#{<<"token">> => Token})),
|
||||
|
||||
% 8. Невалидный токен – ошибка 404
|
||||
{ok, 404, _, _} = api_test_runner:client_request(post, <<"/v1/verify">>, <<>>,
|
||||
jsx:encode(#{<<"token">> => <<"invalid_token">>})),
|
||||
|
||||
|
||||
Regular → Executable
+8
@@ -26,6 +26,7 @@
|
||||
all() ->
|
||||
[
|
||||
user_test_verification,
|
||||
user_test_password_reset,
|
||||
user_test_register,
|
||||
user_test_login,
|
||||
user_test_user_me,
|
||||
@@ -40,6 +41,7 @@ all() ->
|
||||
user_test_reviews,
|
||||
user_test_review_by_id,
|
||||
user_test_review_vote,
|
||||
user_test_calendar_follow,
|
||||
user_test_my_reviews,
|
||||
user_test_search,
|
||||
user_test_refresh,
|
||||
@@ -98,6 +100,9 @@ end_per_suite(Config) ->
|
||||
user_test_verification(_Config) ->
|
||||
user_verification_tests:test().
|
||||
|
||||
user_test_password_reset(_Config) ->
|
||||
user_password_reset_tests:test().
|
||||
|
||||
user_test_register(_Config) ->
|
||||
user_register_tests:test().
|
||||
|
||||
@@ -140,6 +145,9 @@ user_test_review_by_id(_Config) ->
|
||||
user_test_review_vote(_Config) ->
|
||||
user_review_vote_tests:test().
|
||||
|
||||
user_test_calendar_follow(_Config) ->
|
||||
user_calendar_follow_tests:test().
|
||||
|
||||
user_test_my_reviews(_Config) ->
|
||||
user_my_reviews_tests:test().
|
||||
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
-module(admin_handler_user_password_reset_token_tests).
|
||||
-include_lib("eunit/include/eunit.hrl").
|
||||
-include("records.hrl").
|
||||
|
||||
-define(TABLES, [admin, admin_audit, user, password_reset, ticket]).
|
||||
-define(ADMIN_ID, <<"adm_pwreset_1">>).
|
||||
|
||||
setup() ->
|
||||
eh_test_support:start_mnesia(),
|
||||
eh_test_support:ensure_tables(?TABLES),
|
||||
eh_test_support:ensure_jwt(),
|
||||
eh_test_support:seed_admin(#{id => ?ADMIN_ID}),
|
||||
ok.
|
||||
|
||||
cleanup(_) ->
|
||||
eh_test_support:unload_cowboy(),
|
||||
eh_test_support:delete_tables(?TABLES),
|
||||
eh_test_support:stop_mnesia(),
|
||||
ok.
|
||||
|
||||
admin_user_password_reset_token_test_() ->
|
||||
{foreach, fun setup/0, fun cleanup/1, [
|
||||
{"GET – create token", {timeout, 60, fun test_create/0}},
|
||||
{"GET – reuse valid token", {timeout, 60, fun test_reuse/0}},
|
||||
{"GET – user not found", {timeout, 60, fun test_not_found/0}},
|
||||
{"GET – unauthorized", {timeout, 60, fun test_unauthorized/0}}
|
||||
]}.
|
||||
|
||||
seed_user() ->
|
||||
User = eh_test_support:make_user(#{
|
||||
id => <<"u_pwreset">>,
|
||||
email => <<"pwreset@test.local">>,
|
||||
nickname => <<"pwreset">>,
|
||||
status => active
|
||||
}),
|
||||
ok = mnesia:dirty_write(User),
|
||||
User.
|
||||
|
||||
test_create() ->
|
||||
User = seed_user(),
|
||||
Id = User#user.id,
|
||||
{Status, _, Body} = eh_test_support:call(admin_handler_user_password_reset_token, #{
|
||||
method => <<"GET">>,
|
||||
path => <<"/v1/admin/users/", Id/binary, "/password-reset-token">>,
|
||||
bindings => #{id => Id},
|
||||
auth => ?ADMIN_ID
|
||||
}),
|
||||
?assertEqual(200, Status),
|
||||
Result = jsx:decode(Body, [return_maps]),
|
||||
?assert(is_binary(maps:get(<<"token">>, Result))),
|
||||
?assert(is_binary(maps:get(<<"expires_at">>, Result))).
|
||||
|
||||
test_reuse() ->
|
||||
User = seed_user(),
|
||||
Id = User#user.id,
|
||||
{ok, Token1, _} = core_password_reset:create_token(Id),
|
||||
{Status, _, Body} = eh_test_support:call(admin_handler_user_password_reset_token, #{
|
||||
method => <<"GET">>,
|
||||
path => <<"/v1/admin/users/", Id/binary, "/password-reset-token">>,
|
||||
bindings => #{id => Id},
|
||||
auth => ?ADMIN_ID
|
||||
}),
|
||||
?assertEqual(200, Status),
|
||||
Result = jsx:decode(Body, [return_maps]),
|
||||
?assertEqual(Token1, maps:get(<<"token">>, Result)).
|
||||
|
||||
test_not_found() ->
|
||||
{Status, _, _} = eh_test_support:call(admin_handler_user_password_reset_token, #{
|
||||
method => <<"GET">>,
|
||||
path => <<"/v1/admin/users/missing/password-reset-token">>,
|
||||
bindings => #{id => <<"missing">>},
|
||||
auth => ?ADMIN_ID
|
||||
}),
|
||||
?assertEqual(404, Status).
|
||||
|
||||
test_unauthorized() ->
|
||||
{Status, _, _} = eh_test_support:call(admin_handler_user_password_reset_token, #{
|
||||
method => <<"GET">>,
|
||||
path => <<"/v1/admin/users/x/password-reset-token">>,
|
||||
bindings => #{id => <<"x">>},
|
||||
auth => none
|
||||
}),
|
||||
?assertEqual(401, Status).
|
||||
Regular → Executable
+13
-14
@@ -2,7 +2,7 @@
|
||||
-include_lib("eunit/include/eunit.hrl").
|
||||
-include("records.hrl").
|
||||
|
||||
-define(TABLES, [user, calendar, event, booking]).
|
||||
-define(TABLES, [user, calendar, event, booking, admin, subscription]).
|
||||
|
||||
setup() ->
|
||||
eh_test_support:start_mnesia(),
|
||||
@@ -19,7 +19,7 @@ booking_integration_test_() ->
|
||||
fun setup/0,
|
||||
fun cleanup/1,
|
||||
[
|
||||
{"Booking create stays pending", fun test_pending_booking_flow/0},
|
||||
{"Booking auto confirms", fun test_auto_booking_flow/0},
|
||||
{"Full booking flow with manual confirmation", fun test_manual_booking_flow/0},
|
||||
{"Capacity management test", fun test_capacity_management/0},
|
||||
{"Multiple bookings test", fun test_multiple_bookings/0}
|
||||
@@ -39,24 +39,25 @@ create_user() ->
|
||||
mnesia:dirty_write(User),
|
||||
UserId.
|
||||
|
||||
create_commercial(OwnerId, Confirmation) ->
|
||||
{ok, _} = core_subscription:create(OwnerId, monthly, true),
|
||||
{ok, Calendar} = core_calendar:create(OwnerId, <<"Cal">>, <<"">>, Confirmation, commercial),
|
||||
Calendar.
|
||||
|
||||
add_days(DateTime, Days) ->
|
||||
Sec = calendar:datetime_to_gregorian_seconds(DateTime) + Days * 86400,
|
||||
calendar:gregorian_seconds_to_datetime(Sec).
|
||||
|
||||
test_pending_booking_flow() ->
|
||||
test_auto_booking_flow() ->
|
||||
OwnerId = create_user(),
|
||||
ParticipantId = create_user(),
|
||||
|
||||
{ok, Calendar} = core_calendar:create(OwnerId, <<"Auto">>, <<"">>, auto),
|
||||
Calendar = create_commercial(OwnerId, auto),
|
||||
|
||||
StartTime = eh_test_support:future_start(),
|
||||
{ok, Event} = core_event:create(Calendar#calendar.id, <<"Event">>, StartTime, 60),
|
||||
|
||||
{ok, Booking} = logic_booking:create_booking(ParticipantId, Event#event.id),
|
||||
?assertEqual(pending, Booking#booking.status),
|
||||
|
||||
{ok, Stored} = core_booking:get_by_id(Booking#booking.id),
|
||||
?assertEqual(pending, Stored#booking.status),
|
||||
?assertEqual(confirmed, Booking#booking.status),
|
||||
|
||||
{ok, EventBookings} = logic_booking:list_event_bookings(Event#event.id),
|
||||
?assertEqual(1, length(EventBookings)),
|
||||
@@ -67,8 +68,7 @@ test_pending_booking_flow() ->
|
||||
test_manual_booking_flow() ->
|
||||
OwnerId = create_user(),
|
||||
ParticipantId = create_user(),
|
||||
|
||||
{ok, Calendar} = core_calendar:create(OwnerId, <<"Manual">>, <<"">>, manual),
|
||||
Calendar = create_commercial(OwnerId, manual),
|
||||
|
||||
StartTime = eh_test_support:future_start(),
|
||||
{ok, Event} = core_event:create(Calendar#calendar.id, <<"Event">>, StartTime, 60),
|
||||
@@ -88,7 +88,7 @@ test_capacity_management() ->
|
||||
Participant2Id = create_user(),
|
||||
Participant3Id = create_user(),
|
||||
|
||||
{ok, Calendar} = core_calendar:create(OwnerId, <<"Test">>, <<"">>, manual),
|
||||
Calendar = create_commercial(OwnerId, manual),
|
||||
|
||||
StartTime = eh_test_support:future_start(),
|
||||
{ok, Event} = core_event:create(Calendar#calendar.id, <<"Event">>, StartTime, 60),
|
||||
@@ -108,8 +108,7 @@ test_capacity_management() ->
|
||||
test_multiple_bookings() ->
|
||||
OwnerId = create_user(),
|
||||
ParticipantId = create_user(),
|
||||
|
||||
{ok, Calendar} = core_calendar:create(OwnerId, <<"Test">>, <<"">>, manual),
|
||||
Calendar = create_commercial(OwnerId, manual),
|
||||
|
||||
Base = eh_test_support:future_start(),
|
||||
StartTime1 = Base,
|
||||
|
||||
Executable
+49
@@ -0,0 +1,49 @@
|
||||
-module(core_calendar_follow_tests).
|
||||
-include_lib("eunit/include/eunit.hrl").
|
||||
-include("records.hrl").
|
||||
|
||||
-define(TABLES, [calendar_follow]).
|
||||
|
||||
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.
|
||||
|
||||
core_calendar_follow_test_() ->
|
||||
{foreach, fun setup/0, fun cleanup/1, [
|
||||
{"follow and is_following", fun test_follow/0},
|
||||
{"follow idempotent", fun test_follow_idempotent/0},
|
||||
{"unfollow", fun test_unfollow/0},
|
||||
{"list_by_user", fun test_list_by_user/0}
|
||||
]}.
|
||||
|
||||
test_follow() ->
|
||||
{ok, F} = core_calendar_follow:follow(<<"cal1">>, <<"u1">>),
|
||||
?assertEqual(<<"cal1">>, F#calendar_follow.calendar_id),
|
||||
?assertEqual(<<"u1">>, F#calendar_follow.user_id),
|
||||
?assert(core_calendar_follow:is_following(<<"u1">>, <<"cal1">>)),
|
||||
?assertNot(core_calendar_follow:is_following(<<"u2">>, <<"cal1">>)).
|
||||
|
||||
test_follow_idempotent() ->
|
||||
{ok, F1} = core_calendar_follow:follow(<<"cal1">>, <<"u1">>),
|
||||
{ok, F2} = core_calendar_follow:follow(<<"cal1">>, <<"u1">>),
|
||||
?assertEqual(F1#calendar_follow.id, F2#calendar_follow.id),
|
||||
?assertEqual(1, length(core_calendar_follow:list_by_user(<<"u1">>))).
|
||||
|
||||
test_unfollow() ->
|
||||
{ok, _} = core_calendar_follow:follow(<<"cal1">>, <<"u1">>),
|
||||
ok = core_calendar_follow:unfollow(<<"cal1">>, <<"u1">>),
|
||||
?assertNot(core_calendar_follow:is_following(<<"u1">>, <<"cal1">>)),
|
||||
ok = core_calendar_follow:unfollow(<<"cal1">>, <<"u1">>).
|
||||
|
||||
test_list_by_user() ->
|
||||
{ok, _} = core_calendar_follow:follow(<<"cal1">>, <<"u1">>),
|
||||
{ok, _} = core_calendar_follow:follow(<<"cal2">>, <<"u1">>),
|
||||
{ok, _} = core_calendar_follow:follow(<<"cal1">>, <<"u2">>),
|
||||
?assertEqual(2, length(core_calendar_follow:list_by_user(<<"u1">>))),
|
||||
?assertEqual(2, length(core_calendar_follow:list_by_calendar(<<"cal1">>))).
|
||||
@@ -27,6 +27,10 @@ core_subscription_test_() ->
|
||||
[
|
||||
{"Create trial subscription", fun test_create_trial/0},
|
||||
{"Create paid subscription", fun test_create_paid/0},
|
||||
{"Create monthly duration", fun test_create_monthly_duration/0},
|
||||
{"Create quarterly duration", fun test_create_quarterly_duration/0},
|
||||
{"Create biannual duration", fun test_create_biannual_duration/0},
|
||||
{"Create annual duration", fun test_create_annual_duration/0},
|
||||
{"Get active by user", fun test_get_active_by_user/0},
|
||||
{"List by user", fun test_list_by_user/0},
|
||||
{"Update status", fun test_update_status/0},
|
||||
@@ -51,6 +55,35 @@ test_create_paid() ->
|
||||
?assertEqual(true, Sub#subscription.trial_used),
|
||||
?assertEqual(active, Sub#subscription.status).
|
||||
|
||||
%% Длительность не зависит от trial_used — только от Plan (месяц ≈ 30 дней).
|
||||
%% add_months считает по календарным дням от полуночи даты старта.
|
||||
assert_plan_duration(Sub, Months) ->
|
||||
{{Y1, M1, D1}, _} = Sub#subscription.started_at,
|
||||
{{Y2, M2, D2}, Time2} = Sub#subscription.expires_at,
|
||||
StartDays = calendar:date_to_gregorian_days({Y1, M1, D1}),
|
||||
EndDays = calendar:date_to_gregorian_days({Y2, M2, D2}),
|
||||
?assertEqual(Months * 30, EndDays - StartDays),
|
||||
?assertEqual({0, 0, 0}, Time2).
|
||||
|
||||
test_create_monthly_duration() ->
|
||||
%% Даже при trial_used=false длительность = 1 месяц, не «trial 30 дней» отдельно
|
||||
{ok, Sub} = core_subscription:create(<<"u-m">>, monthly, false),
|
||||
?assertEqual(false, Sub#subscription.trial_used),
|
||||
assert_plan_duration(Sub, 1).
|
||||
|
||||
test_create_quarterly_duration() ->
|
||||
{ok, Sub} = core_subscription:create(<<"u-q">>, quarterly, false),
|
||||
?assertEqual(false, Sub#subscription.trial_used),
|
||||
assert_plan_duration(Sub, 3).
|
||||
|
||||
test_create_biannual_duration() ->
|
||||
{ok, Sub} = core_subscription:create(<<"u-b">>, biannual, true),
|
||||
assert_plan_duration(Sub, 6).
|
||||
|
||||
test_create_annual_duration() ->
|
||||
{ok, Sub} = core_subscription:create(<<"u-a">>, annual, false),
|
||||
assert_plan_duration(Sub, 12).
|
||||
|
||||
test_get_active_by_user() ->
|
||||
UserId = <<"user123">>,
|
||||
{ok, Sub1} = core_subscription:create(UserId, trial, false),
|
||||
|
||||
Regular → Executable
+9
-1
@@ -126,8 +126,14 @@ table_opts(calendar) ->
|
||||
[{ram_copies, [node()]}, {attributes, record_info(fields, calendar)}];
|
||||
table_opts(calendar_share) ->
|
||||
[{ram_copies, [node()]}, {attributes, record_info(fields, calendar_share)}];
|
||||
table_opts(calendar_follow) ->
|
||||
[{ram_copies, [node()]}, {attributes, record_info(fields, calendar_follow)}];
|
||||
table_opts(calendar_specialist) ->
|
||||
[{ram_copies, [node()]}, {attributes, record_info(fields, 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) ->
|
||||
@@ -162,6 +168,8 @@ table_opts(session) ->
|
||||
[{ram_copies, [node()]}, {attributes, record_info(fields, session)}];
|
||||
table_opts(verification) ->
|
||||
[{ram_copies, [node()]}, {attributes, record_info(fields, verification)}];
|
||||
table_opts(password_reset) ->
|
||||
[{ram_copies, [node()]}, {attributes, record_info(fields, password_reset)}];
|
||||
table_opts(admin_session) ->
|
||||
[{ram_copies, [node()]}, {attributes, record_info(fields, admin_session)}];
|
||||
table_opts(auth_session) ->
|
||||
|
||||
Regular → Executable
+135
-9
@@ -2,7 +2,7 @@
|
||||
-include_lib("eunit/include/eunit.hrl").
|
||||
-include("records.hrl").
|
||||
|
||||
-define(TABLES, [user, calendar, event, booking]).
|
||||
-define(TABLES, [user, calendar, event, booking, admin, subscription, calendar_specialist]).
|
||||
|
||||
setup() ->
|
||||
eh_test_support:start_mnesia(),
|
||||
@@ -19,16 +19,26 @@ logic_booking_test_() ->
|
||||
fun setup/0,
|
||||
fun cleanup/1,
|
||||
[
|
||||
{"Create booking returns pending", fun test_create_booking_pending/0},
|
||||
{"Create booking auto confirms", fun test_create_booking_auto/0},
|
||||
{"Create booking manual pending", fun test_create_booking_pending/0},
|
||||
{"Create booking personal denied", fun test_booking_personal_denied/0},
|
||||
{"Create duplicate booking", fun test_create_duplicate_booking/0},
|
||||
{"Create booking for missing event", fun test_booking_missing_event/0},
|
||||
{"Create booking when event is full", fun test_booking_event_full/0},
|
||||
{"Pending bookings do not fill capacity", fun test_pending_does_not_fill/0},
|
||||
{"Pending bookings fill capacity", fun test_pending_fills_capacity/0},
|
||||
{"Confirm booking", fun test_confirm_booking/0},
|
||||
{"Confirm booking as booker denied", fun test_confirm_booker_denied/0},
|
||||
{"Confirm booking as stranger denied", fun test_confirm_stranger_denied/0},
|
||||
{"Confirm booking as specialist", fun test_confirm_booking_specialist/0},
|
||||
{"Confirm booking as admin", fun test_confirm_booking_admin/0},
|
||||
{"Decline booking by owner", fun test_decline_booking/0},
|
||||
{"Decline booking as booker denied", fun test_decline_booker_denied/0},
|
||||
{"Confirm non-pending booking denied", fun test_confirm_non_pending/0},
|
||||
{"Cancel booking by participant", fun test_cancel_booking/0},
|
||||
{"Cancel booking access denied", fun test_cancel_access_denied/0},
|
||||
{"List event bookings", fun test_list_event_bookings/0},
|
||||
{"List event bookings as owner", fun test_list_event_bookings_owner/0},
|
||||
{"List event bookings non-owner denied", fun test_list_event_bookings_non_owner/0},
|
||||
{"List user bookings", fun test_list_user_bookings/0}
|
||||
]}.
|
||||
|
||||
@@ -47,8 +57,22 @@ create_test_user(Role) ->
|
||||
mnesia:dirty_write(User),
|
||||
UserId.
|
||||
|
||||
create_test_admin() ->
|
||||
AdminId = base64:encode(crypto:strong_rand_bytes(16), #{mode => urlsafe, padding => false}),
|
||||
Admin = eh_test_support:seed_admin(#{id => AdminId, email => <<AdminId/binary, "@admin.test">>}),
|
||||
Admin#admin.id.
|
||||
|
||||
ensure_subscription(OwnerId) ->
|
||||
{ok, _} = core_subscription:create(OwnerId, monthly, true),
|
||||
ok.
|
||||
|
||||
create_test_calendar(OwnerId, Confirmation) ->
|
||||
{ok, Calendar} = core_calendar:create(OwnerId, <<"Test Calendar">>, <<"">>, Confirmation),
|
||||
ensure_subscription(OwnerId),
|
||||
{ok, Calendar} = core_calendar:create(OwnerId, <<"Test Calendar">>, <<"">>, Confirmation, commercial),
|
||||
Calendar#calendar.id.
|
||||
|
||||
create_personal_calendar(OwnerId) ->
|
||||
{ok, Calendar} = core_calendar:create(OwnerId, <<"Personal">>, <<"">>, manual, personal),
|
||||
Calendar#calendar.id.
|
||||
|
||||
create_test_event(CalendarId) ->
|
||||
@@ -63,18 +87,34 @@ create_test_event_with_capacity(CalendarId, Capacity) ->
|
||||
Updated#event.id.
|
||||
|
||||
%% Тесты
|
||||
test_create_booking_pending() ->
|
||||
test_create_booking_auto() ->
|
||||
OwnerId = create_test_user(user),
|
||||
ParticipantId = create_test_user(user),
|
||||
CalendarId = create_test_calendar(OwnerId, auto),
|
||||
EventId = create_test_event(CalendarId),
|
||||
|
||||
{ok, Booking} = logic_booking:create_booking(ParticipantId, EventId),
|
||||
?assertEqual(confirmed, Booking#booking.status).
|
||||
|
||||
test_create_booking_pending() ->
|
||||
OwnerId = create_test_user(user),
|
||||
ParticipantId = create_test_user(user),
|
||||
CalendarId = create_test_calendar(OwnerId, manual),
|
||||
EventId = create_test_event(CalendarId),
|
||||
|
||||
{ok, Booking} = logic_booking:create_booking(ParticipantId, EventId),
|
||||
?assertEqual(pending, Booking#booking.status),
|
||||
|
||||
{ok, Stored} = core_booking:get_by_id(Booking#booking.id),
|
||||
?assertEqual(pending, Stored#booking.status).
|
||||
|
||||
test_booking_personal_denied() ->
|
||||
OwnerId = create_test_user(user),
|
||||
ParticipantId = create_test_user(user),
|
||||
CalendarId = create_personal_calendar(OwnerId),
|
||||
EventId = create_test_event(CalendarId),
|
||||
{error, personal_calendar} = logic_booking:create_booking(ParticipantId, EventId).
|
||||
|
||||
test_create_duplicate_booking() ->
|
||||
OwnerId = create_test_user(user),
|
||||
ParticipantId = create_test_user(user),
|
||||
@@ -96,10 +136,10 @@ test_booking_event_full() ->
|
||||
EventId = create_test_event_with_capacity(CalendarId, 1),
|
||||
|
||||
{ok, B1} = logic_booking:create_booking(Participant1Id, EventId),
|
||||
{ok, _} = logic_booking:confirm_booking(Participant1Id, B1#booking.id, confirm),
|
||||
{ok, _} = logic_booking:confirm_booking(OwnerId, B1#booking.id, confirm),
|
||||
{error, full} = logic_booking:create_booking(Participant2Id, EventId).
|
||||
|
||||
test_pending_does_not_fill() ->
|
||||
test_pending_fills_capacity() ->
|
||||
OwnerId = create_test_user(user),
|
||||
Participant1Id = create_test_user(user),
|
||||
Participant2Id = create_test_user(user),
|
||||
@@ -107,8 +147,7 @@ test_pending_does_not_fill() ->
|
||||
EventId = create_test_event_with_capacity(CalendarId, 1),
|
||||
|
||||
{ok, _} = logic_booking:create_booking(Participant1Id, EventId),
|
||||
{ok, B2} = logic_booking:create_booking(Participant2Id, EventId),
|
||||
?assertEqual(pending, B2#booking.status).
|
||||
{error, full} = logic_booking:create_booking(Participant2Id, EventId).
|
||||
|
||||
test_confirm_booking() ->
|
||||
OwnerId = create_test_user(user),
|
||||
@@ -120,6 +159,68 @@ test_confirm_booking() ->
|
||||
{ok, Confirmed} = logic_booking:confirm_booking(OwnerId, Booking#booking.id, confirm),
|
||||
?assertEqual(confirmed, Confirmed#booking.status).
|
||||
|
||||
test_confirm_booker_denied() ->
|
||||
OwnerId = create_test_user(user),
|
||||
ParticipantId = create_test_user(user),
|
||||
CalendarId = create_test_calendar(OwnerId, manual),
|
||||
EventId = create_test_event(CalendarId),
|
||||
|
||||
{ok, Booking} = logic_booking:create_booking(ParticipantId, EventId),
|
||||
{error, access_denied} = logic_booking:confirm_booking(ParticipantId, Booking#booking.id, confirm).
|
||||
|
||||
test_confirm_stranger_denied() ->
|
||||
OwnerId = create_test_user(user),
|
||||
ParticipantId = create_test_user(user),
|
||||
StrangerId = create_test_user(user),
|
||||
CalendarId = create_test_calendar(OwnerId, manual),
|
||||
EventId = create_test_event(CalendarId),
|
||||
|
||||
{ok, Booking} = logic_booking:create_booking(ParticipantId, EventId),
|
||||
{error, access_denied} = logic_booking:confirm_booking(StrangerId, Booking#booking.id, confirm).
|
||||
|
||||
test_confirm_booking_specialist() ->
|
||||
OwnerId = create_test_user(user),
|
||||
SpecId = create_test_user(user),
|
||||
ParticipantId = create_test_user(user),
|
||||
CalendarId = create_test_calendar(OwnerId, manual),
|
||||
{ok, _} = core_calendar_specialist:create(CalendarId, SpecId, <<"Spec">>, []),
|
||||
EventId = create_test_event(CalendarId),
|
||||
{ok, _} = core_event:update(EventId, [{specialist_id, SpecId}]),
|
||||
|
||||
{ok, Booking} = logic_booking:create_booking(ParticipantId, EventId),
|
||||
{ok, Confirmed} = logic_booking:confirm_booking(SpecId, Booking#booking.id, confirm),
|
||||
?assertEqual(confirmed, Confirmed#booking.status).
|
||||
|
||||
test_confirm_booking_admin() ->
|
||||
OwnerId = create_test_user(user),
|
||||
ParticipantId = create_test_user(user),
|
||||
AdminId = create_test_admin(),
|
||||
CalendarId = create_test_calendar(OwnerId, manual),
|
||||
EventId = create_test_event(CalendarId),
|
||||
|
||||
{ok, Booking} = logic_booking:create_booking(ParticipantId, EventId),
|
||||
{ok, Confirmed} = logic_booking:confirm_booking(AdminId, Booking#booking.id, confirm),
|
||||
?assertEqual(confirmed, Confirmed#booking.status).
|
||||
|
||||
test_decline_booking() ->
|
||||
OwnerId = create_test_user(user),
|
||||
ParticipantId = create_test_user(user),
|
||||
CalendarId = create_test_calendar(OwnerId, manual),
|
||||
EventId = create_test_event(CalendarId),
|
||||
|
||||
{ok, Booking} = logic_booking:create_booking(ParticipantId, EventId),
|
||||
{ok, Declined} = logic_booking:confirm_booking(OwnerId, Booking#booking.id, decline),
|
||||
?assertEqual(cancelled, Declined#booking.status).
|
||||
|
||||
test_decline_booker_denied() ->
|
||||
OwnerId = create_test_user(user),
|
||||
ParticipantId = create_test_user(user),
|
||||
CalendarId = create_test_calendar(OwnerId, manual),
|
||||
EventId = create_test_event(CalendarId),
|
||||
|
||||
{ok, Booking} = logic_booking:create_booking(ParticipantId, EventId),
|
||||
{error, access_denied} = logic_booking:confirm_booking(ParticipantId, Booking#booking.id, decline).
|
||||
|
||||
test_confirm_non_pending() ->
|
||||
OwnerId = create_test_user(user),
|
||||
ParticipantId = create_test_user(user),
|
||||
@@ -163,6 +264,31 @@ test_list_event_bookings() ->
|
||||
{ok, Bookings} = logic_booking:list_event_bookings(EventId),
|
||||
?assertEqual(2, length(Bookings)).
|
||||
|
||||
test_list_event_bookings_owner() ->
|
||||
OwnerId = create_test_user(user),
|
||||
Participant1Id = create_test_user(user),
|
||||
Participant2Id = create_test_user(user),
|
||||
CalendarId = create_test_calendar(OwnerId, manual),
|
||||
EventId = create_test_event(CalendarId),
|
||||
|
||||
{ok, _} = logic_booking:create_booking(Participant1Id, EventId),
|
||||
{ok, _} = logic_booking:create_booking(Participant2Id, EventId),
|
||||
|
||||
{ok, Bookings} = logic_booking:list_event_bookings(OwnerId, EventId),
|
||||
?assertEqual(2, length(Bookings)).
|
||||
|
||||
test_list_event_bookings_non_owner() ->
|
||||
OwnerId = create_test_user(user),
|
||||
ParticipantId = create_test_user(user),
|
||||
StrangerId = create_test_user(user),
|
||||
CalendarId = create_test_calendar(OwnerId, manual),
|
||||
EventId = create_test_event(CalendarId),
|
||||
|
||||
{ok, _} = logic_booking:create_booking(ParticipantId, EventId),
|
||||
|
||||
{error, access_denied} = logic_booking:list_event_bookings(StrangerId, EventId),
|
||||
{error, access_denied} = logic_booking:list_event_bookings(ParticipantId, EventId).
|
||||
|
||||
test_list_user_bookings() ->
|
||||
OwnerId = create_test_user(user),
|
||||
ParticipantId = create_test_user(user),
|
||||
|
||||
Regular → Executable
+22
-1
@@ -12,9 +12,14 @@ setup() ->
|
||||
{attributes, record_info(fields, calendar)},
|
||||
{ram_copies, [node()]}
|
||||
]),
|
||||
mnesia:create_table(subscription, [
|
||||
{attributes, record_info(fields, subscription)},
|
||||
{ram_copies, [node()]}
|
||||
]),
|
||||
ok.
|
||||
|
||||
cleanup(_) ->
|
||||
mnesia:delete_table(subscription),
|
||||
mnesia:delete_table(calendar),
|
||||
mnesia:delete_table(user),
|
||||
mnesia:stop(),
|
||||
@@ -30,7 +35,8 @@ logic_calendar_test_() ->
|
||||
{"List calendars test", fun test_list_calendars/0},
|
||||
{"Update calendar test", fun test_update_calendar/0},
|
||||
{"Delete calendar test", fun test_delete_calendar/0},
|
||||
{"Access control test", fun test_access_control/0}
|
||||
{"Access control test", fun test_access_control/0},
|
||||
{"Ensure default calendar test", fun test_ensure_default_calendar/0}
|
||||
]}.
|
||||
|
||||
create_test_user() ->
|
||||
@@ -41,6 +47,7 @@ create_test_user() ->
|
||||
password_hash = <<"hash">>,
|
||||
role = user,
|
||||
status = active,
|
||||
nickname = <<>>,
|
||||
created_at = calendar:universal_time(),
|
||||
updated_at = calendar:universal_time()
|
||||
},
|
||||
@@ -122,3 +129,17 @@ test_access_control() ->
|
||||
{ok, Frozen} = core_calendar:update(CommercialCalendar#calendar.id, [{status, frozen}]),
|
||||
?assertNot(logic_calendar:can_access(OtherId, Frozen)),
|
||||
?assertNot(logic_calendar:can_access(OwnerId, Frozen)).
|
||||
|
||||
test_ensure_default_calendar() ->
|
||||
UserId = create_test_user(),
|
||||
ok = logic_calendar:ensure_default_calendar(UserId),
|
||||
{ok, Calendars} = logic_calendar:list_calendars(UserId),
|
||||
?assertEqual(1, length(Calendars)),
|
||||
Cal = hd(Calendars),
|
||||
?assertEqual(personal, Cal#calendar.type),
|
||||
?assertEqual(<<>>, Cal#calendar.short_name),
|
||||
?assertEqual(active, Cal#calendar.status),
|
||||
?assert(byte_size(Cal#calendar.title) > 0),
|
||||
ok = logic_calendar:ensure_default_calendar(UserId),
|
||||
{ok, Calendars2} = logic_calendar:list_calendars(UserId),
|
||||
?assertEqual(1, length(Calendars2)).
|
||||
Executable
+82
@@ -0,0 +1,82 @@
|
||||
-module(logic_password_reset_tests).
|
||||
-include_lib("eunit/include/eunit.hrl").
|
||||
-include("records.hrl").
|
||||
|
||||
-define(TABLES, [user, password_reset, auth_session]).
|
||||
|
||||
setup() ->
|
||||
eh_test_support:start_mnesia(),
|
||||
eh_test_support:ensure_tables(?TABLES),
|
||||
catch ets:new(eventhub_counters, [named_table, public, set, {write_concurrency, true}]),
|
||||
ok.
|
||||
|
||||
cleanup(_) ->
|
||||
catch ets:delete(eventhub_counters),
|
||||
eh_test_support:delete_tables(?TABLES),
|
||||
eh_test_support:stop_mnesia(),
|
||||
ok.
|
||||
|
||||
logic_password_reset_test_() ->
|
||||
{foreach, fun setup/0, fun cleanup/1, [
|
||||
{"request_reset – active sends token", {timeout, 60, fun test_request_active/0}},
|
||||
{"request_reset – pending silent", {timeout, 60, fun test_request_pending/0}},
|
||||
{"reset_password – happy path + revoke sessions", {timeout, 60, fun test_reset_ok/0}},
|
||||
{"reset_password – expired", {timeout, 60, fun test_reset_expired/0}},
|
||||
{"reset_password – short password", {timeout, 60, fun test_short_password/0}}
|
||||
]}.
|
||||
|
||||
seed_active() ->
|
||||
{ok, Hash} = logic_auth:hash_password(<<"OldPass123">>),
|
||||
User = eh_test_support:make_user(#{
|
||||
id => <<"u_active_pw">>,
|
||||
email => <<"active-pw@test.local">>,
|
||||
status => active,
|
||||
password_hash => Hash
|
||||
}),
|
||||
ok = mnesia:dirty_write(User),
|
||||
User.
|
||||
|
||||
seed_pending() ->
|
||||
User = eh_test_support:make_user(#{
|
||||
id => <<"u_pending_pw">>,
|
||||
email => <<"pending-pw@test.local">>,
|
||||
status => pending
|
||||
}),
|
||||
ok = mnesia:dirty_write(User),
|
||||
User.
|
||||
|
||||
test_request_active() ->
|
||||
User = seed_active(),
|
||||
?assertEqual({ok, sent}, logic_password_reset:request_reset(User#user.email)),
|
||||
Matches = mnesia:dirty_match_object(#password_reset{user_id = User#user.id, _ = '_'}),
|
||||
?assertMatch([_], Matches).
|
||||
|
||||
test_request_pending() ->
|
||||
User = seed_pending(),
|
||||
?assertEqual({ok, sent}, logic_password_reset:request_reset(User#user.email)),
|
||||
Matches = mnesia:dirty_match_object(#password_reset{user_id = User#user.id, _ = '_'}),
|
||||
?assertEqual([], Matches).
|
||||
|
||||
test_reset_ok() ->
|
||||
User = seed_active(),
|
||||
{ok, Session} = core_auth_session:create(User#user.id, user, <<"web">>),
|
||||
{ok, Token, _} = core_password_reset:create_token(User#user.id),
|
||||
?assertEqual(ok, logic_password_reset:reset_password(Token, <<"NewPass456">>)),
|
||||
{ok, Updated} = core_user:get_by_id(User#user.id),
|
||||
?assertMatch({ok, true}, logic_auth:verify_password(<<"NewPass456">>, Updated#user.password_hash)),
|
||||
{ok, Sess2} = core_auth_session:get(Session#auth_session.session_id),
|
||||
?assertEqual(true, Sess2#auth_session.revoked),
|
||||
?assertEqual({error, not_found}, core_password_reset:verify_token(Token)).
|
||||
|
||||
test_reset_expired() ->
|
||||
User = seed_active(),
|
||||
Past = calendar:gregorian_seconds_to_datetime(
|
||||
calendar:datetime_to_gregorian_seconds(calendar:universal_time()) - 10),
|
||||
Token = <<"expired_tok">>,
|
||||
mnesia:dirty_write(#password_reset{token = Token, user_id = User#user.id, expires_at = Past}),
|
||||
?assertEqual({error, expired}, logic_password_reset:reset_password(Token, <<"NewPass456">>)).
|
||||
|
||||
test_short_password() ->
|
||||
User = seed_active(),
|
||||
{ok, Token, _} = core_password_reset:create_token(User#user.id),
|
||||
?assertEqual({error, invalid_password}, logic_password_reset:reset_password(Token, <<"short">>)).
|
||||
Regular → Executable
+11
@@ -21,6 +21,7 @@ logic_review_test_() ->
|
||||
[
|
||||
{"Create review for event", fun test_create_event_review/0},
|
||||
{"Create review for calendar", fun test_create_calendar_review/0},
|
||||
{"Cannot review calendar without booking", fun test_cannot_review_calendar_without_booking/0},
|
||||
{"Cannot review without booking", fun test_cannot_review_without_booking/0},
|
||||
{"Cannot review twice", fun test_cannot_review_twice/0},
|
||||
{"Update own review", fun test_update_own_review/0},
|
||||
@@ -74,10 +75,20 @@ test_create_calendar_review() ->
|
||||
OwnerId = create_test_user(),
|
||||
ReviewerId = create_test_user(),
|
||||
CalendarId = create_test_calendar(OwnerId),
|
||||
EventId = create_test_event(CalendarId),
|
||||
create_booking(ReviewerId, EventId),
|
||||
|
||||
{ok, Review} = logic_review:create_review(ReviewerId, calendar, CalendarId, 4, <<"Nice">>),
|
||||
?assertEqual(4, Review#review.rating).
|
||||
|
||||
test_cannot_review_calendar_without_booking() ->
|
||||
OwnerId = create_test_user(),
|
||||
UserId = create_test_user(),
|
||||
CalendarId = create_test_calendar(OwnerId),
|
||||
_EventId = create_test_event(CalendarId),
|
||||
|
||||
{error, cannot_review} = logic_review:create_review(UserId, calendar, CalendarId, 4, <<"Nope">>).
|
||||
|
||||
test_cannot_review_without_booking() ->
|
||||
OwnerId = create_test_user(),
|
||||
UserId = create_test_user(),
|
||||
|
||||
Regular → Executable
+41
-2
@@ -2,7 +2,7 @@
|
||||
-include_lib("eunit/include/eunit.hrl").
|
||||
-include("records.hrl").
|
||||
|
||||
-define(TABLES, [user, calendar, event]).
|
||||
-define(TABLES, [user, calendar, event, subscription]).
|
||||
|
||||
setup() ->
|
||||
eh_test_support:start_mnesia(),
|
||||
@@ -29,7 +29,9 @@ logic_search_test_() ->
|
||||
{"Pagination", fun test_pagination/0},
|
||||
{"Sorting", fun test_sorting/0},
|
||||
{"Access control in search", fun test_access_control/0},
|
||||
{"Empty search results", fun test_empty_search/0}
|
||||
{"Empty search results", fun test_empty_search/0},
|
||||
{"Discovery tops without filters", fun test_discovery_returns_tops/0},
|
||||
{"Query switches to filtered search", fun test_discovery_with_q_uses_filter/0}
|
||||
]}.
|
||||
|
||||
%% Вспомогательные функции
|
||||
@@ -48,6 +50,12 @@ create_test_user(Role) ->
|
||||
UserId.
|
||||
|
||||
create_test_calendar(OwnerId, Type, Tags) ->
|
||||
case Type of
|
||||
commercial ->
|
||||
_ = core_subscription:create(OwnerId, monthly, true);
|
||||
_ ->
|
||||
ok
|
||||
end,
|
||||
{ok, Calendar} = core_calendar:create(OwnerId, <<"Test Calendar">>, <<"Description">>, manual),
|
||||
core_calendar:update(Calendar#calendar.id, [{type, Type}, {tags, Tags}]),
|
||||
{ok, Updated} = core_calendar:get_by_id(Calendar#calendar.id),
|
||||
@@ -239,3 +247,34 @@ test_empty_search() ->
|
||||
{Total, Results} = events_from(logic_search:search(<<"event">>, <<"nonexistent">>, OwnerId, #{})),
|
||||
?assertEqual(0, Total),
|
||||
?assertEqual([], Results).
|
||||
|
||||
test_discovery_returns_tops() ->
|
||||
OwnerId = create_test_user(user),
|
||||
ViewerId = create_test_user(user),
|
||||
CalendarId = create_test_calendar(OwnerId, commercial, []),
|
||||
StartTime = eh_test_support:future_start(),
|
||||
LowId = create_test_event(CalendarId, <<"Low Rated">>, <<"">>, StartTime, [], undefined),
|
||||
HighId = create_test_event(CalendarId, <<"High Rated">>, <<"">>, StartTime, [], undefined),
|
||||
{ok, _} = core_event:update(LowId, [{rating_avg, 1.0}, {rating_count, 1}]),
|
||||
{ok, _} = core_event:update(HighId, [{rating_avg, 5.0}, {rating_count, 10}]),
|
||||
stats_tops:init_tables(),
|
||||
stats_tops:rebuild(),
|
||||
|
||||
{Total, [First | _]} = events_from(logic_search:search(<<"event">>, undefined, ViewerId, #{})),
|
||||
?assertEqual(2, Total),
|
||||
?assertMatch(#{title := <<"High Rated">>}, First).
|
||||
|
||||
test_discovery_with_q_uses_filter() ->
|
||||
OwnerId = create_test_user(user),
|
||||
CalendarId = create_test_calendar(OwnerId, personal, []),
|
||||
StartTime = eh_test_support:future_start(),
|
||||
LowId = create_test_event(CalendarId, <<"Alpha">>, <<"">>, StartTime, [], undefined),
|
||||
HighId = create_test_event(CalendarId, <<"Beta">>, <<"">>, StartTime, [], undefined),
|
||||
{ok, _} = core_event:update(LowId, [{rating_avg, 1.0}]),
|
||||
{ok, _} = core_event:update(HighId, [{rating_avg, 5.0}]),
|
||||
stats_tops:init_tables(),
|
||||
stats_tops:rebuild(),
|
||||
|
||||
{Total, Results} = events_from(logic_search:search(<<"event">>, <<"Alpha">>, OwnerId, #{})),
|
||||
?assertEqual(1, Total),
|
||||
?assertMatch([#{title := <<"Alpha">>}], Results).
|
||||
|
||||
Executable
+94
@@ -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">>)).
|
||||
@@ -11,6 +11,10 @@ logic_subscription_test_() ->
|
||||
{"Start trial duplicate", fun test_start_trial_duplicate/0},
|
||||
{"Activate subscription (no trial)", fun test_activate_subscription_no_trial/0},
|
||||
{"Activate subscription (after trial)", fun test_activate_subscription_after_trial/0},
|
||||
{"Activate quarterly duration", fun test_activate_quarterly_duration/0},
|
||||
{"Activate monthly duration", fun test_activate_monthly_duration/0},
|
||||
{"Activate biannual duration", fun test_activate_biannual_duration/0},
|
||||
{"Activate annual duration", fun test_activate_annual_duration/0},
|
||||
{"Check subscription - free", fun test_check_free/0},
|
||||
{"Check subscription - trial", fun test_check_trial/0},
|
||||
{"Check subscription - paid", fun test_check_paid/0},
|
||||
@@ -76,6 +80,38 @@ test_activate_subscription_after_trial() ->
|
||||
?assertEqual(monthly, Sub#subscription.plan),
|
||||
?assertEqual(true, Sub#subscription.trial_used).
|
||||
|
||||
%% Длительность при активации (в т.ч. первой платной с trial_used=false) из Plan.
|
||||
assert_plan_duration(Sub, Months) ->
|
||||
{{Y1, M1, D1}, _} = Sub#subscription.started_at,
|
||||
{{Y2, M2, D2}, Time2} = Sub#subscription.expires_at,
|
||||
StartDays = calendar:date_to_gregorian_days({Y1, M1, D1}),
|
||||
EndDays = calendar:date_to_gregorian_days({Y2, M2, D2}),
|
||||
?assertEqual(Months * 30, EndDays - StartDays),
|
||||
?assertEqual({0, 0, 0}, Time2).
|
||||
|
||||
test_activate_monthly_duration() ->
|
||||
UserId = create_test_user(),
|
||||
{ok, Sub} = logic_subscription:activate_subscription(UserId, monthly, #{card => "4242"}),
|
||||
?assertEqual(false, Sub#subscription.trial_used),
|
||||
assert_plan_duration(Sub, 1).
|
||||
|
||||
test_activate_quarterly_duration() ->
|
||||
UserId = create_test_user(),
|
||||
{ok, Sub} = logic_subscription:activate_subscription(UserId, quarterly, #{card => "4242"}),
|
||||
?assertEqual(quarterly, Sub#subscription.plan),
|
||||
?assertEqual(false, Sub#subscription.trial_used),
|
||||
assert_plan_duration(Sub, 3).
|
||||
|
||||
test_activate_biannual_duration() ->
|
||||
UserId = create_test_user(),
|
||||
{ok, Sub} = logic_subscription:activate_subscription(UserId, biannual, #{card => "4242"}),
|
||||
assert_plan_duration(Sub, 6).
|
||||
|
||||
test_activate_annual_duration() ->
|
||||
UserId = create_test_user(),
|
||||
{ok, Sub} = logic_subscription:activate_subscription(UserId, annual, #{card => "4242"}),
|
||||
assert_plan_duration(Sub, 12).
|
||||
|
||||
test_check_free() ->
|
||||
UserId = create_test_user(),
|
||||
{ok, free, free} = logic_subscription:check_user_subscription(UserId).
|
||||
|
||||
Regular → Executable
+4
-1
@@ -10,7 +10,10 @@
|
||||
"20260716230000_ticket_source_and_hash_index",
|
||||
"20260717180000_stats_counters",
|
||||
"20260717190000_admin_stats_indexes",
|
||||
"20260719210000_review_vote"
|
||||
"20260719210000_review_vote",
|
||||
"20260720210000_calendar_follow",
|
||||
"20260722150000_calendar_specialist_id",
|
||||
"20260722190000_specialist_invite"
|
||||
]).
|
||||
|
||||
setup() ->
|
||||
|
||||
Regular → Executable
+4
-1
@@ -45,7 +45,10 @@ test_event_rating_top() ->
|
||||
ok = mnesia:dirty_write(make_event(<<"e_low">>, 2.0, Now)),
|
||||
ok = mnesia:dirty_write(make_event(<<"e_high">>, 5.0, Now)),
|
||||
ok = mnesia:dirty_write(make_event(<<"e_mid">>, 3.5, Now)),
|
||||
wait_top_event(<<"e_high">>, 30),
|
||||
wait_until(fun() ->
|
||||
[E#event.id || E <- core_event:get_top_events_by_rating(2)]
|
||||
=:= [<<"e_high">>, <<"e_mid">>]
|
||||
end, 40),
|
||||
Top = core_event:get_top_events_by_rating(2),
|
||||
Ids = [E#event.id || E <- Top],
|
||||
?assertEqual([<<"e_high">>, <<"e_mid">>], Ids).
|
||||
|
||||
Reference in New Issue
Block a user