feat: user API голосования за отзывы (like/dislike). Refs EventHub/EventHubBack#47
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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.
|
||||
@@ -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">>)
|
||||
|
||||
@@ -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">>)
|
||||
|
||||
@@ -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) ->
|
||||
|
||||
Reference in New Issue
Block a user