При массовом изменении статуса отзывов причина теперь обязательное поле #22

This commit is contained in:
2026-05-26 22:22:15 +03:00
parent 19b3d0dba0
commit ac5382fab4
3 changed files with 85 additions and 29 deletions
+27 -8
View File
@@ -1,7 +1,7 @@
%%%------------------------------------------------------------------- %%%-------------------------------------------------------------------
%%% @doc Административный обработчик отзывов. %%% @doc Административный обработчик отзывов.
%%% GET – список отзывов с пагинацией, фильтрацией и сортировкой. %%% GET – список отзывов с пагинацией, фильтрацией и сортировкой.
%%% PATCH – массовое обновление статусов отзывов. %%% PATCH – массовое обновление статусов отзывов с единой причиной.
%%% @end %%% @end
%%%------------------------------------------------------------------- %%%-------------------------------------------------------------------
-module(admin_handler_reviews). -module(admin_handler_reviews).
@@ -43,14 +43,31 @@ trails() ->
#{ % PATCH bulk update #{ % PATCH bulk update
path => <<"/v1/admin/reviews">>, path => <<"/v1/admin/reviews">>,
method => <<"PATCH">>, method => <<"PATCH">>,
description => <<"Bulk update review statuses">>, description => <<"Bulk update review statuses with a single reason">>,
tags => [<<"Reviews">>], tags => [<<"Reviews">>],
requestBody => #{ requestBody => #{
required => true, required => true,
content => #{<<"application/json">> => #{schema => #{ type => array, items => #{ type => object, properties => #{ id => #{type => string}, status => #{type => string, enum => [<<"visible">>, <<"hidden">>, <<"deleted">>]} } }}} } content => #{<<"application/json">> => #{schema => #{
type => object,
required => [<<"operations">>, <<"reason">>],
properties => #{
<<"operations">> => #{
type => array,
items => #{
type => object,
properties => #{
<<"id">> => #{type => string},
<<"status">> => #{type => string, enum => [<<"visible">>, <<"hidden">>, <<"deleted">>]}
}
}
},
<<"reason">> => #{type => string, description => <<"Reason applied to all updates">>}
}
}}}
}, },
responses => #{ responses => #{
200 => #{description => <<"Number of updated reviews">>} 200 => #{description => <<"Number of updated reviews">>},
400 => #{description => <<"Invalid request body or missing reason">>}
} }
} }
]. ].
@@ -111,16 +128,18 @@ bulk_update_reviews(Req) ->
{ok, AdminId, Req1} -> {ok, AdminId, Req1} ->
try try
{ok, Body, Req2} = cowboy_req:read_body(Req1), {ok, Body, Req2} = cowboy_req:read_body(Req1),
Operations = jsx:decode(Body, [return_maps]), #{<<"operations">> := Operations, <<"reason">> := Reason} = jsx:decode(Body, [return_maps]),
true = is_list(Operations), true = is_list(Operations),
case logic_review:bulk_update_status(Operations) of case logic_review:bulk_update_status(Operations, Reason) of
{ok, UpdatedCount} -> {ok, UpdatedCount} ->
admin_utils:log_admin_action(AdminId, <<"bulk_update_reviews">>, <<"review">>, UpdatedCount, Req2), admin_utils:log_admin_action(AdminId, <<"bulk_update_reviews">>, <<"review">>, UpdatedCount, Req2),
handler_utils:send_json(Req2, 200, #{updated_count => UpdatedCount}); handler_utils:send_json(Req2, 200, #{updated_count => UpdatedCount});
{error, Reason} -> {error, ReasonErr} ->
handler_utils:send_error(Req2, 400, Reason) handler_utils:send_error(Req2, 400, ReasonErr)
end end
catch catch
error:{badmatch, _} ->
handler_utils:send_error(Req1, 400, <<"Missing 'reason' field">>);
_:_ -> handler_utils:send_error(Req1, 400, <<"Invalid JSON body">>) _:_ -> handler_utils:send_error(Req1, 400, <<"Invalid JSON body">>)
end; end;
{error, Code, Msg, Req1} -> {error, Code, Msg, Req1} ->
+33 -12
View File
@@ -4,7 +4,7 @@
-export([create_review/5, get_review/2, list_reviews/3, list_user_reviews/1, -export([create_review/5, get_review/2, list_reviews/3, list_user_reviews/1,
update_review/3, delete_review/2, hide_review/2, hide_review/3, unhide_review/2, unhide_review/3]). update_review/3, delete_review/2, hide_review/2, hide_review/3, unhide_review/2, unhide_review/3]).
-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/1]). -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]).
%% Создание отзыва %% Создание отзыва
@@ -233,27 +233,48 @@ apply_filters(Reviews, [_ | Rest]) ->
%%%------------------------------------------------------------------- %%%-------------------------------------------------------------------
%%% @doc Массово обновить статусы отзывов. %%% @doc Массово обновить статусы отзывов.
%%% Operations: [#{id => ReviewId, status => visible | hidden}, ...] %%%
%%% Все изменения выполняются в одной Mnesia-транзакции. %%% Принимает список операций и одну причину, которая будет
%%% записана во все изменённые отзывы. Все изменения выполняются
%%% в одной Mnesia‑транзакции.
%%%
%%% Параметры:
%%% Operations :: [#{ id := binary(), status := binary() }]
%%% Каждый элемент обязан содержать ключи `id` и `status`.
%%% Допустимые значения `status`: `<<"visible">>`, `<<"hidden">>`,
%%% `<<"deleted">>`.
%%% Reason :: binary()
%%% Причина, которая будет записана в поле `reason` каждого
%%% обновлённого отзыва.
%%%
%%% Возвращает:
%%% {ok, UpdatedCount :: non_neg_integer()} в случае успеха,
%%% {error, Reason :: binary()} при ошибке.
%%% @end %%% @end
%%%------------------------------------------------------------------- %%%-------------------------------------------------------------------
bulk_update_status(Operations) when is_list(Operations) -> -spec bulk_update_status(Operations :: [#{binary() => binary()}], Reason :: binary()) ->
{ok, non_neg_integer()} | {error, binary()}.
bulk_update_status(Operations, Reason) when is_list(Operations) ->
Fun = fun() -> Fun = fun() ->
lists:foreach(fun do_update_status/1, Operations) lists:foreach(fun(Op) -> do_update_status(Op, Reason) end, Operations)
end, end,
case mnesia:transaction(Fun) of case mnesia:transaction(Fun) of
{atomic, ok} -> {atomic, ok} ->
{ok, length(Operations)}; {ok, length(Operations)};
{aborted, Reason} -> {aborted, ReasonErr} ->
{error, Reason} {error, ReasonErr}
end. end.
do_update_status(#{<<"id">> := Id, <<"status">> := NewStatus}) -> %% @private Выполняет обновление одного отзыва внутри транзакции.
-spec do_update_status(map(), binary()) -> ok | no_return().
do_update_status(#{<<"id">> := Id, <<"status">> := NewStatus}, Reason) ->
case core_review:get_by_id(Id) of case core_review:get_by_id(Id) of
{ok, Review} -> {ok, _Review} ->
IdReview = Review#review{id = Id}, Updates = [{status, NewStatus}, {reason, Reason}],
UpdatedReview = Review#review{status = NewStatus}, case core_review:update(Id, Updates) of
core_review:update(IdReview, UpdatedReview); {ok, _} -> ok;
{error, _} -> mnesia:abort(<<"review_update_failed">>)
end;
not_found -> not_found ->
mnesia:abort(<<"review_not_found">>) mnesia:abort(<<"review_not_found">>)
end. end.
+25 -9
View File
@@ -67,6 +67,7 @@ test() ->
% Сначала меняем статусы, чтобы потом проверить фильтрацию по статусам % Сначала меняем статусы, чтобы потом проверить фильтрацию по статусам
test_bulk_update_status(Token, Review1Id, Review2Id), test_bulk_update_status(Token, Review1Id, Review2Id),
test_bulk_update_missing_reason(Token, Review1Id),
test_filter_by_status(Token), test_filter_by_status(Token),
test_filter_combined_status_target(Token, EventId), test_filter_combined_status_target(Token, EventId),
@@ -118,17 +119,32 @@ test_filter_by_user(Token, UserId) ->
[?assertEqual(UserId, maps:get(<<"user_id">>, R)) || R <- Reviews], [?assertEqual(UserId, maps:get(<<"user_id">>, R)) || R <- Reviews],
ct:pal(" OK: ~p reviews by user", [length(Reviews)]). ct:pal(" OK: ~p reviews by user", [length(Reviews)]).
%% @doc PATCH /v1/admin/reviews – массовое обновление статусов. %% @doc PATCH /v1/admin/reviews – массовое обновление статусов с причиной.
test_bulk_update_status(Token, Review1Id, Review2Id) -> test_bulk_update_status(Token, Review1Id, Review2Id) ->
ct:pal(" TEST: Bulk update review statuses"), ct:pal(" TEST: Bulk update review statuses"),
Body = [ BodyMap = #{
#{<<"id">> => Review1Id, <<"status">> => <<"visible">>}, <<"operations">> => [
#{<<"id">> => Review2Id, <<"status">> => <<"hidden">>} #{<<"id">> => Review1Id, <<"status">> => <<"visible">>},
], #{<<"id">> => Review2Id, <<"status">> => <<"hidden">>}
#{<<"updated_count">> := Count} = api_test_runner:admin_patch( ],
<<"/v1/admin/reviews">>, Token, Body), <<"reason">> => <<"Moderation action">>
?assertEqual(2, Count), },
ct:pal(" OK: updated ~p reviews", [Count]). Body = jsx:encode(BodyMap),
{ok, 200, _, RespBody} = api_test_runner:admin_request(patch, <<"/v1/admin/reviews">>, Token, Body),
Result = jsx:decode(list_to_binary(RespBody), [return_maps]),
?assertEqual(2, maps:get(<<"updated_count">>, Result)),
ct:pal(" OK: updated ~p reviews", [maps:get(<<"updated_count">>, Result)]).
%% @doc PATCH /v1/admin/reviews отсутствует reason → 400.
test_bulk_update_missing_reason(Token, ReviewId) ->
ct:pal(" TEST: Bulk update missing reason (400)"),
BodyMap = #{
<<"operations">> => [#{<<"id">> => ReviewId, <<"status">> => <<"hidden">>}]
% reason отсутствует
},
Body = jsx:encode(BodyMap),
{ok, 400, _, _} = api_test_runner:admin_request(patch, <<"/v1/admin/reviews">>, Token, Body),
ct:pal(" OK: got 400").
%% @doc GET /v1/admin/reviews?status=visible фильтрация по статусу. %% @doc GET /v1/admin/reviews?status=visible фильтрация по статусу.
test_filter_by_status(Token) -> test_filter_by_status(Token) ->