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
+5 -2
View File
@@ -92,6 +92,7 @@ review_schema() ->
reason => #{type => string, nullable => true},
likes => #{type => integer},
dislikes => #{type => integer},
my_vote => #{type => string, enum => [<<"like">>, <<"dislike">>, null], nullable => true},
created_at => #{type => string, format => <<"date-time">>},
updated_at => #{type => string, format => <<"date-time">>}
}
@@ -128,7 +129,8 @@ get_review(Req) ->
ReviewId = cowboy_req:binding(id, Req1),
case logic_review:get_review(UserId, ReviewId) of
{ok, Review} ->
handler_utils:send_json(Req1, 200, handler_utils:review_to_json(Review));
MyVote = logic_review:my_vote(UserId, ReviewId),
handler_utils:send_json(Req1, 200, handler_utils:review_to_json(Review, MyVote));
{error, access_denied} ->
handler_utils:send_error(Req1, 403, <<"Access denied">>);
{error, not_found} ->
@@ -154,7 +156,8 @@ update_review(Req) ->
{ok, _} ->
case core_review:get_by_id(ReviewId) of
{ok, Updated} ->
handler_utils:send_json(Req2, 200, handler_utils:review_to_json(Updated));
MyVote = logic_review:my_vote(UserId, ReviewId),
handler_utils:send_json(Req2, 200, handler_utils:review_to_json(Updated, MyVote));
_ ->
handler_utils:send_error(Req2, 500, <<"Failed to retrieve updated review">>)
end;
+169
View File
@@ -0,0 +1,169 @@
%%%-------------------------------------------------------------------
%%% @doc Голосование за отзыв (клиентский API).
%%%
%%% PUT /v1/reviews/:id/vote — поставить / сменить like|dislike
%%% DELETE /v1/reviews/:id/vote — снять голос
%%% @end
%%%-------------------------------------------------------------------
-module(handler_review_vote).
-behaviour(cowboy_handler).
-export([init/2]).
-export([trails/0]).
-include("records.hrl").
%%% cowboy_handler callback
-spec init(cowboy_req:req(), any()) -> {ok, cowboy_req:req(), any()}.
init(Req, Opts) ->
handle(Req, Opts).
%%% Swagger metadata
-spec trails() -> [map()].
trails() ->
BaseParams = [
#{
name => <<"id">>,
in => <<"path">>,
description => <<"Review ID">>,
required => true,
schema => #{type => string}
}
],
VoteResponse = #{
type => object,
properties => #{
review_id => #{type => string},
my_vote => #{type => string, enum => [<<"like">>, <<"dislike">>, null], nullable => true},
likes => #{type => integer, minimum => 0},
dislikes => #{type => integer, minimum => 0}
}
},
[
#{
path => <<"/v1/reviews/:id/vote">>,
method => <<"PUT">>,
description => <<"Put or change vote (like/dislike) on a review">>,
tags => [<<"Reviews">>],
parameters => BaseParams,
requestBody => #{
required => true,
content => #{<<"application/json">> => #{schema => #{
type => object,
required => [<<"value">>],
properties => #{
value => #{type => string, enum => [<<"like">>, <<"dislike">>]}
}
}}}
},
responses => #{
200 => #{
description => <<"Vote applied">>,
content => #{<<"application/json">> => #{schema => VoteResponse}}
},
400 => #{description => <<"Invalid body">>},
401 => #{description => <<"Unauthorized">>},
403 => #{description => <<"Cannot vote on own review">>},
404 => #{description => <<"Review not found or unavailable">>}
}
},
#{
path => <<"/v1/reviews/:id/vote">>,
method => <<"DELETE">>,
description => <<"Remove vote from a review">>,
tags => [<<"Reviews">>],
parameters => BaseParams,
responses => #{
200 => #{
description => <<"Vote removed">>,
content => #{<<"application/json">> => #{schema => VoteResponse}}
},
401 => #{description => <<"Unauthorized">>},
403 => #{description => <<"Cannot vote on own review">>},
404 => #{description => <<"Review not found or unavailable">>}
}
}
].
%%%===================================================================
%%% HTTP
%%%===================================================================
-spec handle(cowboy_req:req(), any()) -> {ok, cowboy_req:req(), any()}.
handle(Req, _Opts) ->
case cowboy_req:method(Req) of
<<"PUT">> -> put_vote(Req);
<<"DELETE">> -> delete_vote(Req);
_ -> handler_utils:send_error(Req, 405, <<"Method not allowed">>)
end.
-spec put_vote(cowboy_req:req()) -> {ok, cowboy_req:req(), any()}.
put_vote(Req) ->
case handler_utils:auth_user(Req) of
{ok, UserId, Req1} ->
ReviewId = cowboy_req:binding(id, Req1),
{ok, Body, Req2} = cowboy_req:read_body(Req1),
try jsx:decode(Body, [return_maps]) of
#{<<"value">> := ValueBin} ->
case parse_value(ValueBin) of
undefined ->
handler_utils:send_error(Req2, 400, <<"Invalid value: like or dislike required">>);
Value ->
case logic_review:put_vote(UserId, ReviewId, Value) of
{ok, Review, MyVote} ->
handler_utils:send_json(Req2, 200, vote_response(Review, MyVote));
{error, not_found} ->
handler_utils:send_error(Req2, 404, <<"Review not found">>);
{error, own_review} ->
handler_utils:send_error(Req2, 403, <<"Cannot vote on own review">>);
{error, _} ->
handler_utils:send_error(Req2, 500, <<"Internal server error">>)
end
end;
_ ->
handler_utils:send_error(Req2, 400, <<"Missing required field: value">>)
catch
_:_ -> handler_utils:send_error(Req2, 400, <<"Invalid JSON format">>)
end;
{error, Code, Message, Req1} ->
handler_utils:send_error(Req1, Code, Message)
end.
-spec delete_vote(cowboy_req:req()) -> {ok, cowboy_req:req(), any()}.
delete_vote(Req) ->
case handler_utils:auth_user(Req) of
{ok, UserId, Req1} ->
ReviewId = cowboy_req:binding(id, Req1),
case logic_review:delete_vote(UserId, ReviewId) of
{ok, Review, null} ->
handler_utils:send_json(Req1, 200, vote_response(Review, null));
{error, not_found} ->
handler_utils:send_error(Req1, 404, <<"Review not found">>);
{error, own_review} ->
handler_utils:send_error(Req1, 403, <<"Cannot vote on own review">>);
{error, _} ->
handler_utils:send_error(Req1, 500, <<"Internal server error">>)
end;
{error, Code, Message, Req1} ->
handler_utils:send_error(Req1, Code, Message)
end.
%%%===================================================================
%%% Helpers
%%%===================================================================
parse_value(<<"like">>) -> like;
parse_value(<<"dislike">>) -> dislike;
parse_value(_) -> undefined.
vote_response(#review{id = Id, likes = Likes, dislikes = Dislikes}, MyVote) ->
#{
review_id => Id,
my_vote => vote_json(MyVote),
likes => Likes,
dislikes => Dislikes
}.
vote_json(like) -> <<"like">>;
vote_json(dislike) -> <<"dislike">>;
vote_json(null) -> null.
+5 -2
View File
@@ -96,6 +96,7 @@ review_schema() ->
reason => #{type => string, nullable => true},
likes => #{type => integer},
dislikes => #{type => integer},
my_vote => #{type => string, enum => [<<"like">>, <<"dislike">>, null], nullable => true},
created_at => #{type => string, format => <<"date-time">>},
updated_at => #{type => string, format => <<"date-time">>}
}
@@ -130,7 +131,7 @@ create_review(Req) ->
TargetType = parse_target_type(TargetTypeBin),
case logic_review:create_review(UserId, TargetType, TargetId, Rating, Comment) of
{ok, Review} ->
Response = handler_utils:review_to_json(Review),
Response = handler_utils:review_to_json(Review, null),
handler_utils:send_json(Req2, 201, Response);
{error, already_reviewed} ->
handler_utils:send_error(Req2, 409, <<"Already reviewed">>);
@@ -171,7 +172,9 @@ list_reviews(Req) ->
TargetType = parse_target_type(TargetTypeBin),
case logic_review:list_reviews(UserId, TargetType, TargetId) of
{ok, Reviews} ->
Response = [handler_utils:review_to_json(R) || R <- Reviews],
Votes = logic_review:my_votes_map(UserId),
Response = [handler_utils:review_to_json(R, maps:get(R#review.id, Votes, null))
|| R <- Reviews],
handler_utils:send_json(Req1, 200, Response);
{error, _} ->
handler_utils:send_error(Req1, 500, <<"Internal server error">>)
+3 -1
View File
@@ -52,6 +52,7 @@ review_schema() ->
reason => #{type => string, nullable => true},
likes => #{type => integer},
dislikes => #{type => integer},
my_vote => #{type => string, enum => [<<"like">>, <<"dislike">>, null], nullable => true},
created_at => #{type => string, format => <<"date-time">>},
updated_at => #{type => string, format => <<"date-time">>}
}
@@ -76,7 +77,8 @@ list_user_reviews(Req) ->
{ok, UserId, Req1} ->
case logic_review:list_user_reviews(UserId) of
{ok, Reviews} ->
Response = [handler_utils:review_to_json(R) || R <- Reviews],
%% Свои отзывы: голос за свой отзыв невозможен → my_vote обычно null
Response = [handler_utils:review_to_json(R, null) || R <- Reviews],
handler_utils:send_json(Req1, 200, Response);
{error, _} ->
handler_utils:send_error(Req1, 500, <<"Internal server error">>)
+12 -1
View File
@@ -21,6 +21,7 @@
event_to_json/1,
user_to_json/1,
review_to_json/1,
review_to_json/2,
report_to_json/1,
ticket_to_json/1,
calendar_to_json/1,
@@ -359,9 +360,14 @@ user_to_json(User) ->
updated_at => datetime_to_iso8601(User#user.updated_at)
}.
%% @doc Преобразует #review{} в JSON-карту.
%% @doc Преобразует #review{} в JSON-карту (без my_vote).
-spec review_to_json(#review{}) -> map().
review_to_json(Review) ->
review_to_json(Review, null).
%% @doc Преобразует #review{} в JSON с голосом текущего пользователя.
-spec review_to_json(#review{}, like | dislike | null) -> map().
review_to_json(Review, MyVote) ->
#{
id => Review#review.id,
user_id => Review#review.user_id,
@@ -373,10 +379,15 @@ review_to_json(Review) ->
reason => Review#review.reason,
likes => Review#review.likes,
dislikes => Review#review.dislikes,
my_vote => my_vote_json(MyVote),
created_at => datetime_to_iso8601(Review#review.created_at),
updated_at => datetime_to_iso8601(Review#review.updated_at)
}.
my_vote_json(like) -> <<"like">>;
my_vote_json(dislike) -> <<"dislike">>;
my_vote_json(null) -> null.
%% @doc Преобразует #report{} в JSON-карту.
-spec report_to_json(#report{}) -> map().
report_to_json(Report) ->