feat: user API голосования за отзывы (like/dislike). Refs EventHub/EventHubBack#47
CI / test (push) Failing after 45m52s
CI / deploy-ift (push) Has been skipped
CI / e2e-ift (push) Has been skipped
CI / deploy-stage (push) Has been skipped
CI / e2e-stage (push) Has been skipped

This commit is contained in:
2026-07-19 21:31:29 +03:00
parent 0c8a87df75
commit 5b638de450
17 changed files with 3539 additions and 2762 deletions
+9 -2
View File
@@ -111,8 +111,15 @@ update(Id, Updates) ->
delete(Id) ->
F = fun() ->
case mnesia:read(review, Id) of
[] -> {error, not_found};
[Review] -> mnesia:delete_object(Review), {ok, deleted}
[] ->
{error, not_found};
[Review] ->
Votes = mnesia:match_object(#review_vote{review_id = Id, _ = '_'}),
lists:foreach(fun(#review_vote{id = VoteId}) ->
mnesia:delete({review_vote, VoteId})
end, Votes),
mnesia:delete_object(Review),
{ok, deleted}
end
end,
case mnesia:transaction(F) of
+156
View File
@@ -0,0 +1,156 @@
%%%-------------------------------------------------------------------
%%% @doc Хранение голосов пользователей за отзывы (like/dislike).
%%% Счётчики на #review{} обновляются в той же транзакции.
%%% @end
%%%-------------------------------------------------------------------
-module(core_review_vote).
-include("records.hrl").
-export([upsert/3, delete/2, get_by_user_and_review/2, list_by_user/1,
list_by_review/1, delete_by_review/1]).
%%%-------------------------------------------------------------------
%%% @doc Поставить или сменить голос. Идемпотентно при том же value.
%%% @end
%%%-------------------------------------------------------------------
-spec upsert(ReviewId :: binary(), UserId :: binary(), Value :: like | dislike) ->
{ok, #review{}, like | dislike} | {error, term()}.
upsert(ReviewId, UserId, Value) when Value =:= like; Value =:= dislike ->
Now = calendar:universal_time(),
F = fun() ->
case mnesia:read(review, ReviewId) of
[] ->
{error, not_found};
[#review{status = Status}] when Status =/= visible ->
{error, not_found};
[#review{user_id = AuthorId}] when AuthorId =:= UserId ->
{error, own_review};
[Review] ->
case find_vote(ReviewId, UserId) of
[] ->
Vote = #review_vote{
id = infra_utils:generate_id(16),
review_id = ReviewId,
user_id = UserId,
value = Value,
created_at = Now,
updated_at = Now
},
mnesia:write(Vote),
Updated = bump(Review, Value, 1),
mnesia:write(Updated),
{ok, Updated, Value};
[#review_vote{value = Value} = Vote] ->
%% тот же голос — идемпотентно
mnesia:write(Vote#review_vote{updated_at = Now}),
{ok, Review, Value};
[#review_vote{value = OldValue} = Vote] ->
UpdatedVote = Vote#review_vote{value = Value, updated_at = Now},
mnesia:write(UpdatedVote),
Updated0 = bump(Review, OldValue, -1),
Updated1 = bump(Updated0, Value, 1),
mnesia:write(Updated1),
{ok, Updated1, Value}
end
end
end,
case mnesia:transaction(F) of
{atomic, Result} -> Result;
{aborted, Reason} -> {error, Reason}
end.
%%%-------------------------------------------------------------------
%%% @doc Снять голос. Идемпотентно, если голоса нет.
%%% @end
%%%-------------------------------------------------------------------
-spec delete(ReviewId :: binary(), UserId :: binary()) ->
{ok, #review{}, null} | {error, term()}.
delete(ReviewId, UserId) ->
F = fun() ->
case mnesia:read(review, ReviewId) of
[] ->
{error, not_found};
[#review{status = Status}] when Status =/= visible ->
{error, not_found};
[#review{user_id = AuthorId}] when AuthorId =:= UserId ->
{error, own_review};
[Review] ->
case find_vote(ReviewId, UserId) of
[] ->
{ok, Review, null};
[#review_vote{value = Value, id = VoteId}] ->
mnesia:delete({review_vote, VoteId}),
Updated = bump(Review, Value, -1),
mnesia:write(Updated),
{ok, Updated, null}
end
end
end,
case mnesia:transaction(F) of
{atomic, Result} -> Result;
{aborted, Reason} -> {error, Reason}
end.
%%%-------------------------------------------------------------------
%%% @doc Голос пользователя за конкретный отзыв.
%%% @end
%%%-------------------------------------------------------------------
-spec get_by_user_and_review(UserId :: binary(), ReviewId :: binary()) ->
{ok, #review_vote{}} | {error, not_found}.
get_by_user_and_review(UserId, ReviewId) ->
case mnesia:dirty_match_object(
#review_vote{review_id = ReviewId, user_id = UserId, _ = '_'}) of
[] -> {error, not_found};
[Vote] -> {ok, Vote};
[Vote | _] -> {ok, Vote}
end.
%%%-------------------------------------------------------------------
%%% @doc Все голоса пользователя.
%%% @end
%%%-------------------------------------------------------------------
-spec list_by_user(UserId :: binary()) -> [#review_vote{}].
list_by_user(UserId) ->
mnesia:dirty_match_object(#review_vote{user_id = UserId, _ = '_'}).
%%%-------------------------------------------------------------------
%%% @doc Все голоса за отзыв.
%%% @end
%%%-------------------------------------------------------------------
-spec list_by_review(ReviewId :: binary()) -> [#review_vote{}].
list_by_review(ReviewId) ->
mnesia:dirty_match_object(#review_vote{review_id = ReviewId, _ = '_'}).
%%%-------------------------------------------------------------------
%%% @doc Удалить все голоса отзыва (при физическом удалении review).
%%% Вызывать внутри транзакции или отдельно.
%%% @end
%%%-------------------------------------------------------------------
-spec delete_by_review(ReviewId :: binary()) -> ok.
delete_by_review(ReviewId) ->
F = fun() ->
lists:foreach(fun(#review_vote{id = Id}) ->
mnesia:delete({review_vote, Id})
end, list_by_review_tx(ReviewId)),
ok
end,
case mnesia:transaction(F) of
{atomic, ok} -> ok;
{aborted, Reason} -> error({delete_votes_failed, Reason})
end.
%%%===================================================================
%%% Внутренние
%%%===================================================================
find_vote(ReviewId, UserId) ->
mnesia:match_object(#review_vote{review_id = ReviewId, user_id = UserId, _ = '_'}).
list_by_review_tx(ReviewId) ->
mnesia:match_object(#review_vote{review_id = ReviewId, _ = '_'}).
-spec bump(#review{}, like | dislike, integer()) -> #review{}.
bump(#review{likes = L} = R, like, Delta) ->
R#review{likes = max(0, L + Delta), updated_at = calendar:universal_time()};
bump(#review{dislikes = D} = R, dislike, Delta) ->
R#review{dislikes = max(0, D + Delta), updated_at = calendar:universal_time()}.