diff --git a/src/handlers/admin/admin_handler_reviews.erl b/src/handlers/admin/admin_handler_reviews.erl index 0884deb..00f6dd9 100644 --- a/src/handlers/admin/admin_handler_reviews.erl +++ b/src/handlers/admin/admin_handler_reviews.erl @@ -1,7 +1,7 @@ %%%------------------------------------------------------------------- %%% @doc Административный обработчик отзывов. %%% GET – список отзывов с пагинацией, фильтрацией и сортировкой. -%%% PATCH – массовое обновление статусов отзывов. +%%% PATCH – массовое обновление статусов отзывов с единой причиной. %%% @end %%%------------------------------------------------------------------- -module(admin_handler_reviews). @@ -43,14 +43,31 @@ trails() -> #{ % PATCH bulk update path => <<"/v1/admin/reviews">>, method => <<"PATCH">>, - description => <<"Bulk update review statuses">>, + description => <<"Bulk update review statuses with a single reason">>, tags => [<<"Reviews">>], requestBody => #{ 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 => #{ - 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} -> try {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), - case logic_review:bulk_update_status(Operations) of + case logic_review:bulk_update_status(Operations, Reason) of {ok, UpdatedCount} -> admin_utils:log_admin_action(AdminId, <<"bulk_update_reviews">>, <<"review">>, UpdatedCount, Req2), handler_utils:send_json(Req2, 200, #{updated_count => UpdatedCount}); - {error, Reason} -> - handler_utils:send_error(Req2, 400, Reason) + {error, ReasonErr} -> + handler_utils:send_error(Req2, 400, ReasonErr) end catch + error:{badmatch, _} -> + handler_utils:send_error(Req1, 400, <<"Missing 'reason' field">>); _:_ -> handler_utils:send_error(Req1, 400, <<"Invalid JSON body">>) end; {error, Code, Msg, Req1} -> diff --git a/src/logic/logic_review.erl b/src/logic/logic_review.erl index bfda17d..211f634 100644 --- a/src/logic/logic_review.erl +++ b/src/logic/logic_review.erl @@ -4,7 +4,7 @@ -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]). -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]). %% Создание отзыва @@ -233,27 +233,48 @@ apply_filters(Reviews, [_ | Rest]) -> %%%------------------------------------------------------------------- %%% @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 %%%------------------------------------------------------------------- -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() -> - lists:foreach(fun do_update_status/1, Operations) + lists:foreach(fun(Op) -> do_update_status(Op, Reason) end, Operations) end, case mnesia:transaction(Fun) of {atomic, ok} -> {ok, length(Operations)}; - {aborted, Reason} -> - {error, Reason} + {aborted, ReasonErr} -> + {error, ReasonErr} 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 - {ok, Review} -> - IdReview = Review#review{id = Id}, - UpdatedReview = Review#review{status = NewStatus}, - core_review:update(IdReview, UpdatedReview); + {ok, _Review} -> + Updates = [{status, NewStatus}, {reason, Reason}], + case core_review:update(Id, Updates) of + {ok, _} -> ok; + {error, _} -> mnesia:abort(<<"review_update_failed">>) + end; not_found -> mnesia:abort(<<"review_not_found">>) end. diff --git a/test/api/admins/admin_reviews_tests.erl b/test/api/admins/admin_reviews_tests.erl index 5b49c11..a90b50d 100644 --- a/test/api/admins/admin_reviews_tests.erl +++ b/test/api/admins/admin_reviews_tests.erl @@ -67,6 +67,7 @@ test() -> % Сначала меняем статусы, чтобы потом проверить фильтрацию по статусам test_bulk_update_status(Token, Review1Id, Review2Id), + test_bulk_update_missing_reason(Token, Review1Id), test_filter_by_status(Token), 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], 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) -> ct:pal(" TEST: Bulk update review statuses"), - Body = [ - #{<<"id">> => Review1Id, <<"status">> => <<"visible">>}, - #{<<"id">> => Review2Id, <<"status">> => <<"hidden">>} - ], - #{<<"updated_count">> := Count} = api_test_runner:admin_patch( - <<"/v1/admin/reviews">>, Token, Body), - ?assertEqual(2, Count), - ct:pal(" OK: updated ~p reviews", [Count]). + BodyMap = #{ + <<"operations">> => [ + #{<<"id">> => Review1Id, <<"status">> => <<"visible">>}, + #{<<"id">> => Review2Id, <<"status">> => <<"hidden">>} + ], + <<"reason">> => <<"Moderation action">> + }, + 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 – фильтрация по статусу. test_filter_by_status(Token) ->