feat: calendar follow API (POST/DELETE follow, GET following). Refs EventHub/EventHubFront#9
This commit is contained in:
Regular → Executable
+8
@@ -103,6 +103,14 @@
|
|||||||
rights :: read | write | admin
|
rights :: read | write | admin
|
||||||
}).
|
}).
|
||||||
|
|
||||||
|
%% Follow чужого календаря (не путать с платной subscription / calendar_share)
|
||||||
|
-record(calendar_follow, {
|
||||||
|
id :: binary(),
|
||||||
|
calendar_id :: binary(),
|
||||||
|
user_id :: binary(),
|
||||||
|
created_at :: calendar:datetime()
|
||||||
|
}).
|
||||||
|
|
||||||
%% ------------------- Специалисты календаря ---------------------------
|
%% ------------------- Специалисты календаря ---------------------------
|
||||||
-record(calendar_specialist, {
|
-record(calendar_specialist, {
|
||||||
calendar_id :: binary(),
|
calendar_id :: binary(),
|
||||||
|
|||||||
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, _ = '_'}).
|
||||||
Regular → Executable
+2
@@ -90,9 +90,11 @@ start_http() ->
|
|||||||
{"/v1/user/me", handler_user_me, []},
|
{"/v1/user/me", handler_user_me, []},
|
||||||
{"/v1/user/bookings", handler_user_bookings, []},
|
{"/v1/user/bookings", handler_user_bookings, []},
|
||||||
{"/v1/user/reviews", handler_user_reviews, []},
|
{"/v1/user/reviews", handler_user_reviews, []},
|
||||||
|
{"/v1/user/following", handler_user_following, []},
|
||||||
{"/v1/search", handler_search, []},
|
{"/v1/search", handler_search, []},
|
||||||
{"/v1/calendars", handler_calendars, []},
|
{"/v1/calendars", handler_calendars, []},
|
||||||
{"/v1/calendars/:id", handler_calendar_by_id, []},
|
{"/v1/calendars/:id", handler_calendar_by_id, []},
|
||||||
|
{"/v1/calendars/:id/follow", handler_calendar_follow, []},
|
||||||
{"/v1/calendars/:calendar_id/view", handler_calendar_view, []},
|
{"/v1/calendars/:calendar_id/view", handler_calendar_view, []},
|
||||||
{"/v1/calendars/:calendar_id/events", handler_events, []},
|
{"/v1/calendars/:calendar_id/events", handler_events, []},
|
||||||
{"/v1/events/:id", handler_event_by_id, []},
|
{"/v1/events/:id", handler_event_by_id, []},
|
||||||
|
|||||||
Regular → Executable
+3
-1
@@ -114,7 +114,9 @@ get_calendar(Req) ->
|
|||||||
CalendarId = cowboy_req:binding(id, Req1),
|
CalendarId = cowboy_req:binding(id, Req1),
|
||||||
case logic_calendar:get_calendar(UserId, CalendarId) of
|
case logic_calendar:get_calendar(UserId, CalendarId) of
|
||||||
{ok, Calendar} ->
|
{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} ->
|
{error, access_denied} ->
|
||||||
handler_utils:send_error(Req1, 403, <<"Access denied">>);
|
handler_utils:send_error(Req1, 403, <<"Access denied">>);
|
||||||
{error, not_found} ->
|
{error, not_found} ->
|
||||||
|
|||||||
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.
|
||||||
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.
|
||||||
Regular → Executable
+2
-1
@@ -14,7 +14,7 @@
|
|||||||
|
|
||||||
-define(TABLES, [
|
-define(TABLES, [
|
||||||
user, session, verification, admin, admin_session, auth_session,
|
user, session, verification, admin, admin_session, auth_session,
|
||||||
calendar, calendar_share, calendar_specialist,
|
calendar, calendar_share, calendar_follow, calendar_specialist,
|
||||||
event, recurrence_exception,
|
event, recurrence_exception,
|
||||||
booking,
|
booking,
|
||||||
review, review_vote, report, banned_word, automod_settings, automod_hit,
|
review, review_vote, report, banned_word, automod_settings, automod_hit,
|
||||||
@@ -317,6 +317,7 @@ table_opts(user) -> [{disc_copies, [node()]}, {attributes, record_info(fields, u
|
|||||||
table_opts(admin) -> [{disc_copies, [node()]}, {attributes, record_info(fields, admin)}];
|
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) -> [{disc_copies, [node()]}, {attributes, record_info(fields, calendar)}];
|
||||||
table_opts(calendar_share) -> [{disc_copies, [node()]}, {attributes, record_info(fields, calendar_share)}];
|
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(calendar_specialist) -> [{disc_copies, [node()]}, {attributes, record_info(fields, calendar_specialist)}];
|
||||||
table_opts(event) -> [{disc_copies, [node()]}, {attributes, record_info(fields, event)}];
|
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(recurrence_exception) -> [{disc_copies, [node()]}, {attributes, record_info(fields, recurrence_exception)}];
|
||||||
|
|||||||
Regular → Executable
+2
-1
@@ -24,7 +24,8 @@
|
|||||||
'20260716230000_ticket_source_and_hash_index',
|
'20260716230000_ticket_source_and_hash_index',
|
||||||
'20260717180000_stats_counters',
|
'20260717180000_stats_counters',
|
||||||
'20260717190000_admin_stats_indexes',
|
'20260717190000_admin_stats_indexes',
|
||||||
'20260719210000_review_vote'
|
'20260719210000_review_vote',
|
||||||
|
'20260720210000_calendar_follow'
|
||||||
]).
|
]).
|
||||||
|
|
||||||
%% ------------------------------
|
%% ------------------------------
|
||||||
|
|||||||
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}.
|
||||||
+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.
|
||||||
Regular → Executable
+2
@@ -72,6 +72,7 @@ user() ->
|
|||||||
handler_booking_by_id,
|
handler_booking_by_id,
|
||||||
handler_bookings,
|
handler_bookings,
|
||||||
handler_calendar_by_id,
|
handler_calendar_by_id,
|
||||||
|
handler_calendar_follow,
|
||||||
handler_calendar_view,
|
handler_calendar_view,
|
||||||
handler_calendars,
|
handler_calendars,
|
||||||
handler_event_by_id,
|
handler_event_by_id,
|
||||||
@@ -86,6 +87,7 @@ user() ->
|
|||||||
handler_ticket_by_id,
|
handler_ticket_by_id,
|
||||||
handler_tickets,
|
handler_tickets,
|
||||||
handler_user_bookings,
|
handler_user_bookings,
|
||||||
|
handler_user_following,
|
||||||
handler_user_me,
|
handler_user_me,
|
||||||
handler_user_reviews
|
handler_user_reviews
|
||||||
],
|
],
|
||||||
|
|||||||
+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
+4
@@ -40,6 +40,7 @@ all() ->
|
|||||||
user_test_reviews,
|
user_test_reviews,
|
||||||
user_test_review_by_id,
|
user_test_review_by_id,
|
||||||
user_test_review_vote,
|
user_test_review_vote,
|
||||||
|
user_test_calendar_follow,
|
||||||
user_test_my_reviews,
|
user_test_my_reviews,
|
||||||
user_test_search,
|
user_test_search,
|
||||||
user_test_refresh,
|
user_test_refresh,
|
||||||
@@ -140,6 +141,9 @@ user_test_review_by_id(_Config) ->
|
|||||||
user_test_review_vote(_Config) ->
|
user_test_review_vote(_Config) ->
|
||||||
user_review_vote_tests:test().
|
user_review_vote_tests:test().
|
||||||
|
|
||||||
|
user_test_calendar_follow(_Config) ->
|
||||||
|
user_calendar_follow_tests:test().
|
||||||
|
|
||||||
user_test_my_reviews(_Config) ->
|
user_test_my_reviews(_Config) ->
|
||||||
user_my_reviews_tests:test().
|
user_my_reviews_tests:test().
|
||||||
|
|
||||||
|
|||||||
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">>))).
|
||||||
Regular → Executable
+2
@@ -126,6 +126,8 @@ table_opts(calendar) ->
|
|||||||
[{ram_copies, [node()]}, {attributes, record_info(fields, calendar)}];
|
[{ram_copies, [node()]}, {attributes, record_info(fields, calendar)}];
|
||||||
table_opts(calendar_share) ->
|
table_opts(calendar_share) ->
|
||||||
[{ram_copies, [node()]}, {attributes, record_info(fields, 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) ->
|
table_opts(calendar_specialist) ->
|
||||||
[{ram_copies, [node()]}, {attributes, record_info(fields, calendar_specialist)}];
|
[{ram_copies, [node()]}, {attributes, record_info(fields, calendar_specialist)}];
|
||||||
table_opts(event) ->
|
table_opts(event) ->
|
||||||
|
|||||||
Regular → Executable
+2
-1
@@ -10,7 +10,8 @@
|
|||||||
"20260716230000_ticket_source_and_hash_index",
|
"20260716230000_ticket_source_and_hash_index",
|
||||||
"20260717180000_stats_counters",
|
"20260717180000_stats_counters",
|
||||||
"20260717190000_admin_stats_indexes",
|
"20260717190000_admin_stats_indexes",
|
||||||
"20260719210000_review_vote"
|
"20260719210000_review_vote",
|
||||||
|
"20260720210000_calendar_follow"
|
||||||
]).
|
]).
|
||||||
|
|
||||||
setup() ->
|
setup() ->
|
||||||
|
|||||||
Reference in New Issue
Block a user