feat: user API голосования за отзывы (like/dislike). Refs EventHub/EventHubBack#47
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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()}.
|
||||
@@ -102,6 +102,7 @@ start_http() ->
|
||||
{"/v1/bookings/:id", handler_booking_by_id, []},
|
||||
{"/v1/reviews", handler_reviews, []},
|
||||
{"/v1/reviews/:id", handler_review_by_id, []},
|
||||
{"/v1/reviews/:id/vote", handler_review_vote, []},
|
||||
{"/v1/reports", handler_reports, []},
|
||||
{"/v1/tickets", handler_tickets, []},
|
||||
{"/v1/tickets/:id", handler_ticket_by_id, []},
|
||||
|
||||
@@ -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) ->
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
calendar, calendar_share, calendar_specialist,
|
||||
event, recurrence_exception,
|
||||
booking,
|
||||
review, report, banned_word, automod_settings, automod_hit,
|
||||
review, review_vote, report, banned_word, automod_settings, automod_hit,
|
||||
ticket, subscription,
|
||||
admin_audit, notification,
|
||||
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(booking) -> [{disc_copies, [node()]}, {attributes, record_info(fields, booking)}];
|
||||
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(banned_word) -> [{disc_copies, [node()]}, {attributes, record_info(fields, banned_word)}];
|
||||
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, user_id),
|
||||
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, status),
|
||||
mnesia:add_table_index(calendar, short_name),
|
||||
|
||||
@@ -23,7 +23,8 @@
|
||||
'20260504150000_test_migration',
|
||||
'20260716230000_ticket_source_and_hash_index',
|
||||
'20260717180000_stats_counters',
|
||||
'20260717190000_admin_stats_indexes'
|
||||
'20260717190000_admin_stats_indexes',
|
||||
'20260719210000_review_vote'
|
||||
]).
|
||||
|
||||
%% ------------------------------
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
-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/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) ->
|
||||
@@ -68,6 +69,45 @@ list_reviews(UserId, TargetType, TargetId) ->
|
||||
list_user_reviews(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) ->
|
||||
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.
|
||||
+2924
-2752
File diff suppressed because it is too large
Load Diff
@@ -79,6 +79,7 @@ user() ->
|
||||
handler_events,
|
||||
handler_reports,
|
||||
handler_review_by_id,
|
||||
handler_review_vote,
|
||||
handler_reviews,
|
||||
handler_search,
|
||||
handler_subscription,
|
||||
|
||||
Reference in New Issue
Block a user