Добавить автомодерацию: политики, порог жалоб, 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)).
+2
View File
@@ -136,6 +136,8 @@ create_calendar(Req) ->
handler_utils:send_json(Req2, 201, Response);
{error, user_inactive} ->
handler_utils:send_error(Req2, 403, <<"User account is not active">>);
{error, {content_banned, _Words}} ->
handler_utils:send_error(Req2, 400, <<"Content contains banned words">>);
{error, _} ->
handler_utils:send_error(Req2, 500, <<"Internal server error">>)
end;
+2
View File
@@ -186,6 +186,8 @@ update_event(Req) ->
handler_utils:send_error(Req2, 404, <<"Event not found">>);
{error, event_in_past} ->
handler_utils:send_error(Req2, 400, <<"Event cannot be in the past">>);
{error, {content_banned, _}} ->
handler_utils:send_error(Req2, 400, <<"Content contains banned words">>);
{error, _} ->
handler_utils:send_error(Req2, 500, <<"Internal server error">>)
end;
+21 -9
View File
@@ -171,11 +171,12 @@ create_event(Req) ->
case handler_utils:parse_datetime(StartTimeStr) of
{ok, StartTime} ->
Location = parse_location(maps:get(<<"location">>, Decoded, undefined)),
case maps:get(<<"recurrence">>, Decoded, undefined) of
Description = maps:get(<<"description">>, Decoded, <<>>),
case maps:get(<<"recurrence">>, Decoded, undefined) of
undefined ->
case logic_event:create_event(UserId, CalendarId, Title, StartTime, Duration) of
case logic_event:create_event(UserId, CalendarId, Title, StartTime, Duration, Description) of
{ok, Event} ->
update_event_fields(Event#event.id, Location, Decoded),
update_event_fields(UserId, Event#event.id, Location, Decoded),
{ok, UpdatedEvent} = core_event:get_by_id(Event#event.id),
Response = handler_utils:event_to_json(UpdatedEvent),
handler_utils:send_json(Req2, 201, Response);
@@ -185,13 +186,15 @@ create_event(Req) ->
handler_utils:send_error(Req2, 404, <<"Calendar not found">>);
{error, event_in_past} ->
handler_utils:send_error(Req2, 400, <<"Event cannot be in the past">>);
{error, {content_banned, _}} ->
handler_utils:send_error(Req2, 400, <<"Content contains banned words">>);
{error, _} ->
handler_utils:send_error(Req2, 500, <<"Internal server error">>)
end;
RRule ->
case logic_event:create_recurring_event(UserId, CalendarId, Title, StartTime, Duration, RRule) of
case logic_event:create_recurring_event(UserId, CalendarId, Title, StartTime, Duration, RRule, Description) of
{ok, Event} ->
update_event_fields(Event#event.id, Location, Decoded),
update_event_fields(UserId, Event#event.id, Location, Decoded),
{ok, UpdatedEvent} = core_event:get_by_id(Event#event.id),
Response = handler_utils:event_to_json(UpdatedEvent),
handler_utils:send_json(Req2, 201, Response);
@@ -203,6 +206,8 @@ create_event(Req) ->
handler_utils:send_error(Req2, 404, <<"Calendar not found">>);
{error, event_in_past} ->
handler_utils:send_error(Req2, 400, <<"Event cannot be in the past">>);
{error, {content_banned, _}} ->
handler_utils:send_error(Req2, 400, <<"Content contains banned words">>);
{error, _} ->
handler_utils:send_error(Req2, 500, <<"Internal server error">>)
end
@@ -257,14 +262,21 @@ list_events(Req) ->
%%% Вспомогательные функции
%%%===================================================================
update_event_fields(EventId, Location, Decoded) ->
update_event_fields(UserId, EventId, Location, Decoded) ->
Updates = [],
Updates1 = case Location of undefined -> Updates; _ -> [{location, Location} | Updates] end,
Updates2 = case maps:get(<<"capacity">>, Decoded, undefined) of undefined -> Updates1; Cap -> [{capacity, Cap} | Updates1] end,
Updates3 = case maps:get(<<"tags">>, Decoded, undefined) of undefined -> Updates2; Tags -> [{tags, Tags} | Updates2] end,
Updates4 = case maps:get(<<"description">>, Decoded, undefined) of undefined -> Updates3; Desc -> [{description, Desc} | Updates3] end,
Updates5 = case maps:get(<<"online_link">>, Decoded, undefined) of undefined -> Updates4; Link -> [{online_link, Link} | Updates4] end,
if Updates5 /= [] -> core_event:update(EventId, Updates5); true -> ok end.
%% description already applied in create_event/6 when present
Updates4 = case maps:get(<<"online_link">>, Decoded, undefined) of undefined -> Updates3; Link -> [{online_link, Link} | Updates3] end,
case Updates4 of
[] -> ok;
_ ->
case logic_event:update_event(UserId, EventId, Updates4) of
{ok, _} -> ok;
_ -> ok
end
end.
parse_location(undefined) -> undefined;
parse_location(LocationMap) when is_map(LocationMap) ->
+15 -10
View File
@@ -33,7 +33,7 @@ trails() ->
type => object,
required => [<<"target_type">>, <<"target_id">>, <<"reason">>],
properties => #{
target_type => #{type => string, enum => [<<"event">>, <<"calendar">>]},
target_type => #{type => string, enum => [<<"event">>, <<"calendar">>, <<"review">>]},
target_id => #{type => string},
reason => #{type => string}
}
@@ -106,14 +106,19 @@ create_report(Req) ->
case Decoded of
#{<<"target_type">> := TargetTypeBin, <<"target_id">> := TargetId, <<"reason">> := Reason} ->
TargetType = parse_target_type(TargetTypeBin),
case logic_moderation:create_report(UserId, TargetType, TargetId, Reason) of
{ok, Report} ->
Response = handler_utils:report_to_json(Report),
handler_utils:send_json(Req2, 201, Response);
{error, target_not_found} ->
handler_utils:send_error(Req2, 404, <<"Target not found">>);
{error, _} ->
handler_utils:send_error(Req2, 500, <<"Internal server error">>)
case TargetType of
undefined ->
handler_utils:send_error(Req2, 400, <<"Invalid target_type">>);
_ ->
case logic_moderation:create_report(UserId, TargetType, TargetId, Reason) of
{ok, Report} ->
Response = handler_utils:report_to_json(Report),
handler_utils:send_json(Req2, 201, Response);
{error, target_not_found} ->
handler_utils:send_error(Req2, 404, <<"Target not found">>);
{error, _} ->
handler_utils:send_error(Req2, 500, <<"Internal server error">>)
end
end;
_ ->
handler_utils:send_error(Req2, 400, <<"Missing required fields">>)
@@ -161,7 +166,7 @@ list_reports(Req) ->
%%%===================================================================
%% @private Преобразует бинарное имя типа в атом.
%% Поддерживаются только 'event' и 'calendar'.
%% Поддерживаются 'event', 'calendar', 'review'.
-spec parse_target_type(binary()) -> event | calendar | review | undefined.
parse_target_type(<<"event">>) -> event;
parse_target_type(<<"calendar">>) -> calendar;
+2
View File
@@ -138,6 +138,8 @@ create_review(Req) ->
handler_utils:send_error(Req2, 403, <<"Cannot review this target">>);
{error, target_not_found} ->
handler_utils:send_error(Req2, 404, <<"Target not found">>);
{error, {content_banned, _}} ->
handler_utils:send_error(Req2, 400, <<"Content contains banned words">>);
{error, _} ->
handler_utils:send_error(Req2, 500, <<"Internal server error">>)
end;