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
+10
View File
@@ -183,6 +183,16 @@
updated_at :: calendar:datetime() updated_at :: calendar:datetime()
}). }).
%% Голос пользователя за отзыв (уникальность review_id+user_id — в транзакции)
-record(review_vote, {
id :: binary(),
review_id :: binary(),
user_id :: binary(),
value :: like | dislike,
created_at :: calendar:datetime(),
updated_at :: calendar:datetime()
}).
%% ------------------- Жалобы и модерация ------------------------------ %% ------------------- Жалобы и модерация ------------------------------
-record(report, { -record(report, {
id :: binary(), id :: binary(),
+9 -2
View File
@@ -111,8 +111,15 @@ update(Id, Updates) ->
delete(Id) -> delete(Id) ->
F = fun() -> F = fun() ->
case mnesia:read(review, Id) of 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
end, end,
case mnesia:transaction(F) of 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()}.
+1
View File
@@ -102,6 +102,7 @@ start_http() ->
{"/v1/bookings/:id", handler_booking_by_id, []}, {"/v1/bookings/:id", handler_booking_by_id, []},
{"/v1/reviews", handler_reviews, []}, {"/v1/reviews", handler_reviews, []},
{"/v1/reviews/:id", handler_review_by_id, []}, {"/v1/reviews/:id", handler_review_by_id, []},
{"/v1/reviews/:id/vote", handler_review_vote, []},
{"/v1/reports", handler_reports, []}, {"/v1/reports", handler_reports, []},
{"/v1/tickets", handler_tickets, []}, {"/v1/tickets", handler_tickets, []},
{"/v1/tickets/:id", handler_ticket_by_id, []}, {"/v1/tickets/:id", handler_ticket_by_id, []},
+5 -2
View File
@@ -92,6 +92,7 @@ review_schema() ->
reason => #{type => string, nullable => true}, reason => #{type => string, nullable => true},
likes => #{type => integer}, likes => #{type => integer},
dislikes => #{type => integer}, dislikes => #{type => integer},
my_vote => #{type => string, enum => [<<"like">>, <<"dislike">>, null], nullable => true},
created_at => #{type => string, format => <<"date-time">>}, created_at => #{type => string, format => <<"date-time">>},
updated_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), ReviewId = cowboy_req:binding(id, Req1),
case logic_review:get_review(UserId, ReviewId) of case logic_review:get_review(UserId, ReviewId) of
{ok, Review} -> {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} -> {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} ->
@@ -154,7 +156,8 @@ update_review(Req) ->
{ok, _} -> {ok, _} ->
case core_review:get_by_id(ReviewId) of case core_review:get_by_id(ReviewId) of
{ok, Updated} -> {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">>) handler_utils:send_error(Req2, 500, <<"Failed to retrieve updated review">>)
end; 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}, reason => #{type => string, nullable => true},
likes => #{type => integer}, likes => #{type => integer},
dislikes => #{type => integer}, dislikes => #{type => integer},
my_vote => #{type => string, enum => [<<"like">>, <<"dislike">>, null], nullable => true},
created_at => #{type => string, format => <<"date-time">>}, created_at => #{type => string, format => <<"date-time">>},
updated_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), TargetType = parse_target_type(TargetTypeBin),
case logic_review:create_review(UserId, TargetType, TargetId, Rating, Comment) of case logic_review:create_review(UserId, TargetType, TargetId, Rating, Comment) of
{ok, Review} -> {ok, Review} ->
Response = handler_utils:review_to_json(Review), Response = handler_utils:review_to_json(Review, null),
handler_utils:send_json(Req2, 201, Response); handler_utils:send_json(Req2, 201, Response);
{error, already_reviewed} -> {error, already_reviewed} ->
handler_utils:send_error(Req2, 409, <<"Already reviewed">>); handler_utils:send_error(Req2, 409, <<"Already reviewed">>);
@@ -171,7 +172,9 @@ list_reviews(Req) ->
TargetType = parse_target_type(TargetTypeBin), TargetType = parse_target_type(TargetTypeBin),
case logic_review:list_reviews(UserId, TargetType, TargetId) of case logic_review:list_reviews(UserId, TargetType, TargetId) of
{ok, Reviews} -> {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); handler_utils:send_json(Req1, 200, Response);
{error, _} -> {error, _} ->
handler_utils:send_error(Req1, 500, <<"Internal server 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}, reason => #{type => string, nullable => true},
likes => #{type => integer}, likes => #{type => integer},
dislikes => #{type => integer}, dislikes => #{type => integer},
my_vote => #{type => string, enum => [<<"like">>, <<"dislike">>, null], nullable => true},
created_at => #{type => string, format => <<"date-time">>}, created_at => #{type => string, format => <<"date-time">>},
updated_at => #{type => string, format => <<"date-time">>} updated_at => #{type => string, format => <<"date-time">>}
} }
@@ -76,7 +77,8 @@ list_user_reviews(Req) ->
{ok, UserId, Req1} -> {ok, UserId, Req1} ->
case logic_review:list_user_reviews(UserId) of case logic_review:list_user_reviews(UserId) of
{ok, Reviews} -> {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); handler_utils:send_json(Req1, 200, Response);
{error, _} -> {error, _} ->
handler_utils:send_error(Req1, 500, <<"Internal server error">>) handler_utils:send_error(Req1, 500, <<"Internal server error">>)
+12 -1
View File
@@ -21,6 +21,7 @@
event_to_json/1, event_to_json/1,
user_to_json/1, user_to_json/1,
review_to_json/1, review_to_json/1,
review_to_json/2,
report_to_json/1, report_to_json/1,
ticket_to_json/1, ticket_to_json/1,
calendar_to_json/1, calendar_to_json/1,
@@ -359,9 +360,14 @@ user_to_json(User) ->
updated_at => datetime_to_iso8601(User#user.updated_at) updated_at => datetime_to_iso8601(User#user.updated_at)
}. }.
%% @doc Преобразует #review{} в JSON-карту. %% @doc Преобразует #review{} в JSON-карту (без my_vote).
-spec review_to_json(#review{}) -> map(). -spec review_to_json(#review{}) -> map().
review_to_json(Review) -> 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, id => Review#review.id,
user_id => Review#review.user_id, user_id => Review#review.user_id,
@@ -373,10 +379,15 @@ review_to_json(Review) ->
reason => Review#review.reason, reason => Review#review.reason,
likes => Review#review.likes, likes => Review#review.likes,
dislikes => Review#review.dislikes, dislikes => Review#review.dislikes,
my_vote => my_vote_json(MyVote),
created_at => datetime_to_iso8601(Review#review.created_at), created_at => datetime_to_iso8601(Review#review.created_at),
updated_at => datetime_to_iso8601(Review#review.updated_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-карту. %% @doc Преобразует #report{} в JSON-карту.
-spec report_to_json(#report{}) -> map(). -spec report_to_json(#report{}) -> map().
report_to_json(Report) -> report_to_json(Report) ->
+4 -1
View File
@@ -17,7 +17,7 @@
calendar, calendar_share, calendar_specialist, calendar, calendar_share, calendar_specialist,
event, recurrence_exception, event, recurrence_exception,
booking, booking,
review, report, banned_word, automod_settings, automod_hit, review, review_vote, report, banned_word, automod_settings, automod_hit,
ticket, subscription, ticket, subscription,
admin_audit, notification, admin_audit, notification,
stats_counter, stats_daily, node_metric, schema_migration stats_counter, stats_daily, node_metric, schema_migration
@@ -322,6 +322,7 @@ table_opts(event) -> [{disc_copies, [node()]}, {attributes, record_info(fields,
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)}];
table_opts(booking) -> [{disc_copies, [node()]}, {attributes, record_info(fields, booking)}]; table_opts(booking) -> [{disc_copies, [node()]}, {attributes, record_info(fields, booking)}];
table_opts(review) -> [{disc_copies, [node()]}, {attributes, record_info(fields, review)}]; table_opts(review) -> [{disc_copies, [node()]}, {attributes, record_info(fields, review)}];
table_opts(review_vote) -> [{disc_copies, [node()]}, {attributes, record_info(fields, review_vote)}];
table_opts(report) -> [{disc_copies, [node()]}, {attributes, record_info(fields, report)}]; table_opts(report) -> [{disc_copies, [node()]}, {attributes, record_info(fields, report)}];
table_opts(banned_word) -> [{disc_copies, [node()]}, {attributes, record_info(fields, banned_word)}]; table_opts(banned_word) -> [{disc_copies, [node()]}, {attributes, record_info(fields, banned_word)}];
table_opts(automod_settings) -> [{disc_copies, [node()]}, {attributes, record_info(fields, automod_settings)}]; table_opts(automod_settings) -> [{disc_copies, [node()]}, {attributes, record_info(fields, automod_settings)}];
@@ -355,6 +356,8 @@ create_indices() ->
mnesia:add_table_index(booking, event_id), mnesia:add_table_index(booking, event_id),
mnesia:add_table_index(booking, user_id), mnesia:add_table_index(booking, user_id),
mnesia:add_table_index(booking, status), mnesia:add_table_index(booking, status),
mnesia:add_table_index(review_vote, review_id),
mnesia:add_table_index(review_vote, user_id),
mnesia:add_table_index(calendar, owner_id), mnesia:add_table_index(calendar, owner_id),
mnesia:add_table_index(calendar, status), mnesia:add_table_index(calendar, status),
mnesia:add_table_index(calendar, short_name), mnesia:add_table_index(calendar, short_name),
+2 -1
View File
@@ -23,7 +23,8 @@
'20260504150000_test_migration', '20260504150000_test_migration',
'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'
]). ]).
%% ------------------------------ %% ------------------------------
+40
View File
@@ -6,6 +6,7 @@
-export([can_review/3, update_target_rating/2, can_moderate_review/2]). -export([can_review/3, update_target_rating/2, can_moderate_review/2]).
-export([list_admin_reviews/1, bulk_update_status/2]). -export([list_admin_reviews/1, bulk_update_status/2]).
-export([list_admin_reviews/2, get_review_admin/1, update_review_admin/2]). -export([list_admin_reviews/2, get_review_admin/1, update_review_admin/2]).
-export([put_vote/3, delete_vote/2, my_vote/2, my_votes_map/1]).
%% Создание отзыва %% Создание отзыва
create_review(UserId, TargetType, TargetId, Rating, Comment) -> create_review(UserId, TargetType, TargetId, Rating, Comment) ->
@@ -68,6 +69,45 @@ list_reviews(UserId, TargetType, TargetId) ->
list_user_reviews(UserId) -> list_user_reviews(UserId) ->
core_review:list_by_user(UserId). core_review:list_by_user(UserId).
%%%-------------------------------------------------------------------
%%% @doc Поставить / сменить голос (like | dislike).
%%% @end
%%%-------------------------------------------------------------------
-spec put_vote(UserId :: binary(), ReviewId :: binary(), Value :: like | dislike) ->
{ok, #review{}, like | dislike} | {error, term()}.
put_vote(UserId, ReviewId, Value) when Value =:= like; Value =:= dislike ->
core_review_vote:upsert(ReviewId, UserId, Value).
%%%-------------------------------------------------------------------
%%% @doc Снять голос с отзыва.
%%% @end
%%%-------------------------------------------------------------------
-spec delete_vote(UserId :: binary(), ReviewId :: binary()) ->
{ok, #review{}, null} | {error, term()}.
delete_vote(UserId, ReviewId) ->
core_review_vote:delete(ReviewId, UserId).
%%%-------------------------------------------------------------------
%%% @doc Голос текущего пользователя за отзыв (`null` если нет).
%%% @end
%%%-------------------------------------------------------------------
-spec my_vote(UserId :: binary(), ReviewId :: binary()) -> like | dislike | null.
my_vote(UserId, ReviewId) ->
case core_review_vote:get_by_user_and_review(UserId, ReviewId) of
{ok, #review_vote{value = Value}} -> Value;
{error, not_found} -> null
end.
%%%-------------------------------------------------------------------
%%% @doc Карта review_id => like | dislike для всех голосов пользователя.
%%% @end
%%%-------------------------------------------------------------------
-spec my_votes_map(UserId :: binary()) -> #{binary() => like | dislike}.
my_votes_map(UserId) ->
lists:foldl(fun(#review_vote{review_id = Rid, value = V}, Acc) ->
Acc#{Rid => V}
end, #{}, core_review_vote:list_by_user(UserId)).
%% Обновление отзыва (только автор) %% Обновление отзыва (только автор)
update_review(UserId, ReviewId, Updates) -> update_review(UserId, ReviewId, Updates) ->
io:format("Updating review ~p with ~p~n", [ReviewId, Updates]), io:format("Updating review ~p with ~p~n", [ReviewId, Updates]),
@@ -0,0 +1,37 @@
%% @doc Create review_vote table and indexes if missing.
-module('20260719210000_review_vote').
-export([up/0, down/0]).
-include("records.hrl").
up() ->
ensure_table(review_vote, record_info(fields, review_vote)),
ensure_index(review_vote, review_id),
ensure_index(review_vote, user_id),
ok.
down() ->
_ = mnesia:delete_table(review_vote),
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.
+172
View File
@@ -1722,6 +1722,15 @@
}, },
"dislikes": { "dislikes": {
"type": "integer" "type": "integer"
},
"my_vote": {
"type": "string",
"enum": [
"like",
"dislike"
],
"nullable": true,
"description": "Current user vote; null if none"
} }
} }
} }
@@ -1896,6 +1905,15 @@
}, },
"dislikes": { "dislikes": {
"type": "integer" "type": "integer"
},
"my_vote": {
"type": "string",
"enum": [
"like",
"dislike"
],
"nullable": true,
"description": "Current user vote; null if none"
} }
} }
} }
@@ -2687,6 +2705,15 @@
}, },
"dislikes": { "dislikes": {
"type": "integer" "type": "integer"
},
"my_vote": {
"type": "string",
"enum": [
"like",
"dislike"
],
"nullable": true,
"description": "Current user vote; null if none"
} }
} }
} }
@@ -2740,6 +2767,151 @@
"required": true "required": true
} }
} }
},
"/v1/reviews/:id/vote": {
"put": {
"description": "Put or change vote (like/dislike) on a review",
"tags": [
"Reviews"
],
"parameters": [
{
"in": "path",
"name": "id",
"description": "Review ID",
"schema": {
"type": "string"
},
"required": true
}
],
"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": {
"type": "object",
"properties": {
"review_id": {
"type": "string"
},
"my_vote": {
"type": "string",
"enum": [
"like",
"dislike"
],
"nullable": true
},
"likes": {
"type": "integer",
"minimum": 0
},
"dislikes": {
"type": "integer",
"minimum": 0
}
}
}
}
}
},
"400": {
"description": "Invalid body"
},
"401": {
"description": "Unauthorized"
},
"403": {
"description": "Cannot vote on own review"
},
"404": {
"description": "Review not found or unavailable"
}
}
},
"delete": {
"description": "Remove vote from a review",
"tags": [
"Reviews"
],
"parameters": [
{
"in": "path",
"name": "id",
"description": "Review ID",
"schema": {
"type": "string"
},
"required": true
}
],
"responses": {
"200": {
"description": "Vote removed",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"review_id": {
"type": "string"
},
"my_vote": {
"type": "string",
"enum": [
"like",
"dislike"
],
"nullable": true
},
"likes": {
"type": "integer",
"minimum": 0
},
"dislikes": {
"type": "integer",
"minimum": 0
}
}
}
}
}
},
"401": {
"description": "Unauthorized"
},
"403": {
"description": "Cannot vote on own review"
},
"404": {
"description": "Review not found or unavailable"
}
}
}
} }
}, },
"openapi": "3.0.3", "openapi": "3.0.3",
+1
View File
@@ -79,6 +79,7 @@ user() ->
handler_events, handler_events,
handler_reports, handler_reports,
handler_review_by_id, handler_review_by_id,
handler_review_vote,
handler_reviews, handler_reviews,
handler_search, handler_search,
handler_subscription, handler_subscription,
+157
View File
@@ -0,0 +1,157 @@
%%%-------------------------------------------------------------------
%%% @doc Тесты голосования за отзывы (like/dislike).
%%%
%%% PUT /v1/reviews/:id/vote
%%% DELETE /v1/reviews/:id/vote
%%% GET /v1/reviews/:id (my_vote)
%%% @end
%%%-------------------------------------------------------------------
-module(user_review_vote_tests).
-include_lib("eunit/include/eunit.hrl").
-export([test/0]).
-spec test() -> ok.
test() ->
ct:pal("=== User Review Vote Tests ==="),
OwnerToken = api_test_runner:get_user_token(),
AuthorEmail = api_test_runner:unique_email(<<"rvauthor">>),
AuthorToken = api_test_runner:register_and_login(AuthorEmail, <<"pass">>),
VoterEmail = api_test_runner:unique_email(<<"rvvoter">>),
VoterToken = api_test_runner:register_and_login(VoterEmail, <<"pass">>),
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">>}),
#{<<"id">> := EventId} = api_test_runner:client_post(
<<"/v1/calendars/", CalId/binary, "/events">>, OwnerToken,
#{title => <<"Event for votes">>,
start_time => api_test_runner:future_date_iso8601(),
duration => 60}),
#{<<"id">> := BookingId} = api_test_runner:client_post(
<<"/v1/events/", EventId/binary, "/bookings">>, AuthorToken, #{}),
api_test_runner:client_put(<<"/v1/bookings/", BookingId/binary>>, OwnerToken,
#{action => <<"confirm">>}),
#{<<"id">> := ReviewId} = api_test_runner:client_post(
<<"/v1/reviews">>, AuthorToken,
#{target_type => <<"event">>,
target_id => EventId,
rating => 5,
comment => <<"Nice event">>}),
VotePath = <<"/v1/reviews/", ReviewId/binary, "/vote">>,
test_put_like(VoterToken, VotePath),
test_put_like_idempotent(VoterToken, VotePath),
test_switch_to_dislike(VoterToken, VotePath),
test_my_vote_on_get(VoterToken, ReviewId),
test_my_vote_on_list(VoterToken, EventId, ReviewId),
test_delete_vote(VoterToken, VotePath),
test_delete_idempotent(VoterToken, VotePath),
test_own_review_forbidden(AuthorToken, VotePath),
test_unauthorized(VotePath),
test_not_found(VoterToken),
test_invalid_body(VoterToken, VotePath),
test_second_voter(OtherToken, VotePath),
ct:pal("=== All user review vote tests passed ==="),
ok.
%%%===================================================================
%%% Cases
%%%===================================================================
test_put_like(Token, Path) ->
ct:pal(" TEST: PUT like"),
Resp = api_test_runner:client_put(Path, Token, #{value => <<"like">>}),
?assertEqual(<<"like">>, maps:get(<<"my_vote">>, Resp)),
?assertEqual(1, maps:get(<<"likes">>, Resp)),
?assertEqual(0, maps:get(<<"dislikes">>, Resp)),
ct:pal(" OK").
test_put_like_idempotent(Token, Path) ->
ct:pal(" TEST: PUT like idempotent"),
Resp = api_test_runner:client_put(Path, Token, #{value => <<"like">>}),
?assertEqual(<<"like">>, maps:get(<<"my_vote">>, Resp)),
?assertEqual(1, maps:get(<<"likes">>, Resp)),
?assertEqual(0, maps:get(<<"dislikes">>, Resp)),
ct:pal(" OK").
test_switch_to_dislike(Token, Path) ->
ct:pal(" TEST: switch like -> dislike"),
Resp = api_test_runner:client_put(Path, Token, #{value => <<"dislike">>}),
?assertEqual(<<"dislike">>, maps:get(<<"my_vote">>, Resp)),
?assertEqual(0, maps:get(<<"likes">>, Resp)),
?assertEqual(1, maps:get(<<"dislikes">>, Resp)),
ct:pal(" OK").
test_my_vote_on_get(Token, ReviewId) ->
ct:pal(" TEST: GET review includes my_vote"),
Review = api_test_runner:client_get(<<"/v1/reviews/", ReviewId/binary>>, Token),
?assertEqual(<<"dislike">>, maps:get(<<"my_vote">>, Review)),
?assertEqual(1, maps:get(<<"dislikes">>, Review)),
ct:pal(" OK").
test_my_vote_on_list(Token, EventId, ReviewId) ->
ct:pal(" TEST: GET reviews list includes my_vote"),
Path = <<"/v1/reviews?target_type=event&target_id=", EventId/binary>>,
List = api_test_runner:client_get(Path, Token),
?assert(is_list(List)),
Match = [R || R <- List, maps:get(<<"id">>, R) =:= ReviewId],
?assertMatch([_], Match),
[R] = Match,
?assertEqual(<<"dislike">>, maps:get(<<"my_vote">>, R)),
ct:pal(" OK").
test_delete_vote(Token, Path) ->
ct:pal(" TEST: DELETE vote"),
Resp = api_test_runner:client_delete(Path, Token),
?assertEqual(null, maps:get(<<"my_vote">>, Resp)),
?assertEqual(0, maps:get(<<"likes">>, Resp)),
?assertEqual(0, maps:get(<<"dislikes">>, Resp)),
ct:pal(" OK").
test_delete_idempotent(Token, Path) ->
ct:pal(" TEST: DELETE vote idempotent"),
Resp = api_test_runner:client_delete(Path, Token),
?assertEqual(null, maps:get(<<"my_vote">>, Resp)),
?assertEqual(0, maps:get(<<"likes">>, Resp)),
?assertEqual(0, maps:get(<<"dislikes">>, Resp)),
ct:pal(" OK").
test_own_review_forbidden(Token, Path) ->
ct:pal(" TEST: vote on own review -> 403"),
Body = jsx:encode(#{value => <<"like">>}),
Resp = api_test_runner:client_request(put, Path, Token, Body),
?assertMatch({ok, 403, _, _}, Resp),
ct:pal(" OK").
test_unauthorized(Path) ->
ct:pal(" TEST: unauthorized -> 401"),
Body = jsx:encode(#{value => <<"like">>}),
Resp = api_test_runner:client_request(put, Path, <<>>, Body),
?assertMatch({ok, 401, _, _}, Resp),
ct:pal(" OK").
test_not_found(Token) ->
ct:pal(" TEST: vote missing review -> 404"),
Path = <<"/v1/reviews/missing-review-id/vote">>,
Body = jsx:encode(#{value => <<"like">>}),
Resp = api_test_runner:client_request(put, Path, Token, Body),
?assertMatch({ok, 404, _, _}, Resp),
ct:pal(" OK").
test_invalid_body(Token, Path) ->
ct:pal(" TEST: invalid value -> 400"),
Body = jsx:encode(#{value => <<"love">>}),
Resp = api_test_runner:client_request(put, Path, Token, Body),
?assertMatch({ok, 400, _, _}, Resp),
ct:pal(" OK").
test_second_voter(Token, Path) ->
ct:pal(" TEST: second voter like"),
Resp = api_test_runner:client_put(Path, Token, #{value => <<"like">>}),
?assertEqual(<<"like">>, maps:get(<<"my_vote">>, Resp)),
?assertEqual(1, maps:get(<<"likes">>, Resp)),
?assertEqual(0, maps:get(<<"dislikes">>, Resp)),
ct:pal(" OK").
+4
View File
@@ -39,6 +39,7 @@ all() ->
user_test_my_bookings, user_test_my_bookings,
user_test_reviews, user_test_reviews,
user_test_review_by_id, user_test_review_by_id,
user_test_review_vote,
user_test_my_reviews, user_test_my_reviews,
user_test_search, user_test_search,
user_test_refresh, user_test_refresh,
@@ -136,6 +137,9 @@ user_test_reviews(_Config) ->
user_test_review_by_id(_Config) -> user_test_review_by_id(_Config) ->
user_review_by_id_tests:test(). user_review_by_id_tests:test().
user_test_review_vote(_Config) ->
user_review_vote_tests:test().
user_test_my_reviews(_Config) -> user_test_my_reviews(_Config) ->
user_my_reviews_tests:test(). user_my_reviews_tests:test().