Добавить автомодерацию: политики, порог жалоб, settings/hits API и тесты.
CI / test (push) Failing after 37m9s
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

Refs EventHub/EventHubBack#37
Refs EventHub/EventHubBack#38
Refs EventHub/EventHubBack#39
Refs EventHub/EventHubBack#40
This commit is contained in:
2026-07-17 18:09:31 +03:00
parent 4fa802702f
commit e9d1a13900
27 changed files with 1779 additions and 85 deletions
@@ -0,0 +1,162 @@
%%%-------------------------------------------------------------------
%%% Admin: настройки автомодерации и очередь hits.
%%%-------------------------------------------------------------------
-module(admin_handler_automod).
-behaviour(cowboy_handler).
-export([init/2, trails/0]).
-include("records.hrl").
init(Req, _Opts) ->
Path = cowboy_req:path(Req),
Method = cowboy_req:method(Req),
case {Method, Path} of
{<<"GET">>, <<"/v1/admin/automod/settings">>} -> get_settings(Req);
{<<"PUT">>, <<"/v1/admin/automod/settings">>} -> put_settings(Req);
{<<"GET">>, <<"/v1/admin/automod/hits">>} -> list_hits(Req);
{<<"PUT">>, _} ->
case cowboy_req:binding(id, Req) of
undefined -> handler_utils:send_error(Req, 404, <<"Not found">>);
Id -> resolve_hit(Req, Id)
end;
_ ->
handler_utils:send_error(Req, 405, <<"Method not allowed">>)
end.
trails() ->
[
#{path => <<"/v1/admin/automod/settings">>,
method => <<"GET">>,
description => <<"Get automoderation settings">>,
tags => [<<"Automoderation">>],
responses => #{200 => #{description => <<"Settings">>}}},
#{path => <<"/v1/admin/automod/settings">>,
method => <<"PUT">>,
description => <<"Update automoderation settings">>,
tags => [<<"Automoderation">>],
responses => #{200 => #{description => <<"Updated">>}, 403 => #{description => <<"Forbidden">>}}},
#{path => <<"/v1/admin/automod/hits">>,
method => <<"GET">>,
description => <<"List automoderation hits">>,
tags => [<<"Automoderation">>],
responses => #{200 => #{description => <<"Hits">>}}},
#{path => <<"/v1/admin/automod/hits/{id}">>,
method => <<"PUT">>,
description => <<"Approve or reject an automod hit">>,
tags => [<<"Automoderation">>],
responses => #{200 => #{description => <<"Resolved">>}, 404 => #{description => <<"Not found">>}}}
].
get_settings(Req) ->
case handler_utils:auth_admin(Req) of
{ok, AdminId, Req1} ->
case admin_utils:check_role(AdminId, [superadmin, admin, moderator]) of
true ->
{ok, S} = logic_automoderation:get_settings(),
handler_utils:send_json(Req1, 200, logic_automoderation:settings_to_json(S));
false ->
handler_utils:send_error(Req1, 403, <<"Access denied">>)
end;
{error, Code, Msg, Req1} ->
handler_utils:send_error(Req1, Code, Msg)
end.
put_settings(Req) ->
case handler_utils:auth_admin(Req) of
{ok, AdminId, Req1} ->
{ok, Body, Req2} = cowboy_req:read_body(Req1),
try jsx:decode(Body, [return_maps]) of
Map when is_map(Map) ->
Updates = parse_settings(Map),
case logic_automoderation:update_settings(AdminId, Updates) of
{ok, S} ->
admin_utils:log_admin_action(AdminId, <<"update">>, <<"automod_settings">>,
<<"default">>, <<>>, Req2),
handler_utils:send_json(Req2, 200, logic_automoderation:settings_to_json(S));
{error, access_denied} ->
handler_utils:send_error(Req2, 403, <<"Access denied">>);
{error, {invalid_field, _}} ->
handler_utils:send_error(Req2, 400, <<"Invalid settings">>);
{error, _} ->
handler_utils:send_error(Req2, 500, <<"Internal server error">>)
end;
_ ->
handler_utils:send_error(Req2, 400, <<"Invalid JSON">>)
catch
_:_ -> handler_utils:send_error(Req1, 400, <<"Invalid JSON format">>)
end;
{error, Code, Msg, Req1} ->
handler_utils:send_error(Req1, Code, Msg)
end.
list_hits(Req) ->
case handler_utils:auth_admin(Req) of
{ok, AdminId, Req1} ->
Qs = cowboy_req:parse_qs(Req1),
Filters = lists:filtermap(fun
({<<"status">>, <<"open">>}) -> {true, {status, open}};
({<<"status">>, <<"approved">>}) -> {true, {status, approved}};
({<<"status">>, <<"rejected">>}) -> {true, {status, rejected}};
({<<"trigger">>, <<"keyword">>}) -> {true, {trigger, keyword}};
({<<"trigger">>, <<"report_threshold">>}) -> {true, {trigger, report_threshold}};
(_) -> false
end, Qs),
case logic_automoderation:list_hits(AdminId, Filters) of
{ok, Hits} ->
Sorted = lists:reverse(lists:keysort(#automod_hit.created_at, Hits)),
handler_utils:send_json(Req1, 200, [logic_automoderation:hit_to_json(H) || H <- Sorted]);
{error, access_denied} ->
handler_utils:send_error(Req1, 403, <<"Access denied">>)
end;
{error, Code, Msg, Req1} ->
handler_utils:send_error(Req1, Code, Msg)
end.
resolve_hit(Req, Id) ->
case handler_utils:auth_admin(Req) of
{ok, AdminId, Req1} ->
{ok, Body, Req2} = cowboy_req:read_body(Req1),
try jsx:decode(Body, [return_maps]) of
#{<<"status">> := StatusBin} ->
Decision = case StatusBin of
<<"approved">> -> approved;
<<"rejected">> -> rejected;
_ -> invalid
end,
case Decision of
invalid ->
handler_utils:send_error(Req2, 400, <<"status must be approved or rejected">>);
_ ->
case logic_automoderation:resolve_hit(AdminId, Id, Decision) of
{ok, Hit} ->
admin_utils:log_admin_action(AdminId, StatusBin, <<"automod_hit">>, Id, <<>>, Req2),
handler_utils:send_json(Req2, 200, logic_automoderation:hit_to_json(Hit));
{error, not_found} ->
handler_utils:send_error(Req2, 404, <<"Hit not found">>);
{error, already_resolved} ->
handler_utils:send_error(Req2, 409, <<"Already resolved">>);
{error, access_denied} ->
handler_utils:send_error(Req2, 403, <<"Access denied">>);
{error, _} ->
handler_utils:send_error(Req2, 500, <<"Internal server error">>)
end
end;
_ ->
handler_utils:send_error(Req2, 400, <<"Missing status">>)
catch
_:_ -> handler_utils:send_error(Req1, 400, <<"Invalid JSON format">>)
end;
{error, Code, Msg, Req1} ->
handler_utils:send_error(Req1, Code, Msg)
end.
parse_settings(Map) ->
lists:filtermap(fun
({<<"keyword_action">>, <<"reject">>}) -> {true, {keyword_action, reject}};
({<<"keyword_action">>, <<"flag">>}) -> {true, {keyword_action, flag}};
({<<"keyword_action">>, <<"censor">>}) -> {true, {keyword_action, censor}};
({<<"match_mode">>, <<"word_boundary">>}) -> {true, {match_mode, word_boundary}};
({<<"match_mode">>, <<"substring">>}) -> {true, {match_mode, substring}};
({<<"report_threshold">>, V}) when is_integer(V) ->
{true, {report_threshold, V}};
(_) -> false
end, maps:to_list(Map)).