%%%------------------------------------------------------------------- %%% @doc Административный обработчик бан-слов. %%% GET – список всех слов с пагинацией, фильтрацией и сортировкой. %%% POST – добавить новое слово. %%% PUT – редактировать существующее слово. %%% DELETE – удалить слово по :word. %%% POST /batch – загрузить список слов (добавляются только новые). %%% @end %%%------------------------------------------------------------------- -module(admin_handler_banned_words). -behaviour(cowboy_handler). -export([init/2]). -export([trails/0]). -include("records.hrl"). -spec init(cowboy_req:req(), any()) -> {ok, cowboy_req:req(), any()}. init(Req, _Opts) -> case cowboy_req:method(Req) of <<"GET">> -> list_words(Req); <<"POST">> -> case cowboy_req:path(Req) of <<"/v1/admin/banned-words/batch">> -> batch_add_words(Req); _ -> add_word(Req) end; <<"PUT">> -> update_word(Req); <<"DELETE">> -> delete_word(Req); _ -> handler_utils:send_error(Req, 405, <<"Method not allowed">>) end. %%% Swagger metadata -spec trails() -> [map()]. trails() -> [ % GET list #{ path => <<"/v1/admin/banned-words">>, method => <<"GET">>, description => <<"List all banned words (admin)">>, tags => [<<"Banned Words">>], parameters => [ #{name => <<"q">>, in => <<"query">>, schema => #{type => string}, description => <<"Search by word">>}, #{name => <<"sort">>, in => <<"query">>, schema => #{type => string, enum => [<<"word">>, <<"added_at">>]}, description => <<"Sort field">>}, #{name => <<"order">>, in => <<"query">>, schema => #{type => string, enum => [<<"asc">>, <<"desc">>]}, description => <<"Sort direction">>}, #{name => <<"limit">>, in => <<"query">>, schema => #{type => integer}, description => <<"Page size">>}, #{name => <<"offset">>, in => <<"query">>, schema => #{type => integer}, description => <<"Offset">>} ], responses => #{ 200 => #{ description => <<"Array of banned words">>, content => #{<<"application/json">> => #{schema => #{ type => array, items => banned_word_schema() }}} } } }, % POST add #{ path => <<"/v1/admin/banned-words">>, method => <<"POST">>, description => <<"Add a new banned word">>, tags => [<<"Banned Words">>], requestBody => #{ required => true, content => #{<<"application/json">> => #{schema => #{ type => object, required => [<<"word">>], properties => #{word => #{type => string}} }}} }, responses => #{ 201 => #{description => <<"Word added">>}, 409 => #{description => <<"Word already exists">>} } }, % PUT update #{ path => <<"/v1/admin/banned-words/{word}">>, method => <<"PUT">>, description => <<"Update an existing banned word">>, tags => [<<"Banned Words">>], parameters => [ #{name => <<"word">>, in => <<"path">>, description => <<"Current word">>, required => true, schema => #{type => string}} ], requestBody => #{ required => true, content => #{<<"application/json">> => #{schema => #{ type => object, required => [<<"word">>], properties => #{word => #{type => string}} }}} }, responses => #{ 200 => #{description => <<"Word updated">>}, 404 => #{description => <<"Word not found">>}, 409 => #{description => <<"New word already exists">>} } }, % DELETE by word #{ path => <<"/v1/admin/banned-words/{word}">>, method => <<"DELETE">>, description => <<"Remove a banned word">>, tags => [<<"Banned Words">>], parameters => [ #{name => <<"word">>, in => <<"path">>, description => <<"The word to remove">>, required => true, schema => #{type => string}} ], responses => #{ 200 => #{description => <<"Word removed">>}, 404 => #{description => <<"Word not found">>} } }, % POST batch #{ path => <<"/v1/admin/banned-words/batch">>, method => <<"POST">>, description => <<"Batch add banned words (only new words are added)">>, tags => [<<"Banned Words">>], requestBody => #{ required => true, content => #{<<"application/json">> => #{schema => #{ type => object, required => [<<"words">>], properties => #{words => #{type => array, items => #{type => string}}} }}} }, responses => #{ 200 => #{description => <<"Batch operation completed">>}, 400 => #{description => <<"Invalid request body">>} } } ]. banned_word_schema() -> #{ type => object, properties => #{ id => #{type => string}, word => #{type => string}, added_by => #{type => string, nullable => true}, added_at => #{type => string, format => <<"date-time">>, nullable => true} } }. %%%=================================================================== %%% HTTP-методы %%%=================================================================== list_words(Req) -> case handler_utils:auth_admin(Req) of {ok, _AdminId, Req1} -> Qs = cowboy_req:parse_qs(Req1), Q = proplists:get_value(<<"q">>, Qs), SortBy = proplists:get_value(<<"sort">>, Qs, <<"word">>), SortOrder = proplists:get_value(<<"order">>, Qs, <<"asc">>), Pagination = handler_utils:parse_pagination_params(Req1), AllWords = core_banned_words:list_banned_words(), % Фильтрация Filtered = case Q of undefined -> AllWords; _ -> [W || W <- AllWords, string:str(string:lowercase(binary_to_list(W#banned_word.word)), string:lowercase(binary_to_list(Q))) > 0] end, % Сортировка Sorted = case {SortBy, SortOrder} of {<<"word">>, <<"asc">>} -> lists:keysort(#banned_word.word, Filtered); {<<"word">>, <<"desc">>} -> lists:reverse(lists:keysort(#banned_word.word, Filtered)); {<<"added_at">>, <<"asc">>} -> lists:keysort(#banned_word.added_at, Filtered); {<<"added_at">>, <<"desc">>} -> lists:reverse(lists:keysort(#banned_word.added_at, Filtered)); _ -> Filtered end, Total = length(Sorted), Page = lists:sublist(Sorted, maps:get(offset, Pagination) + 1, maps:get(limit, Pagination)), Json = [word_to_map(W) || W <- Page], ExtraHeaders = handler_utils:pagination_headers(Pagination, Total), handler_utils:send_json(Req1, 200, Json, ExtraHeaders); {error, Code, Msg, Req1} -> handler_utils:send_error(Req1, Code, Msg) end. add_word(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 #{<<"word">> := Word} -> case core_banned_words:add_banned_word(Word, AdminId) of {ok, _} -> admin_utils:log_admin_action(AdminId, <<"add">>, <<"banned_word">>, Word, Req2), handler_utils:send_json(Req2, 201, #{status => <<"added">>}); {error, already_exists} -> handler_utils:send_error(Req2, 409, <<"Word already exists">>); {error, _} -> handler_utils:send_error(Req2, 500, <<"Internal server error">>) end; _ -> handler_utils:send_error(Req2, 400, <<"Missing 'word' field">>) catch _:_ -> handler_utils:send_error(Req1, 400, <<"Invalid JSON">>) end; {error, Code, Msg, Req1} -> handler_utils:send_error(Req1, Code, Msg) end. update_word(Req) -> case handler_utils:auth_admin(Req) of {ok, AdminId, Req1} -> OldWord = cowboy_req:binding(word, Req1), {ok, Body, Req2} = cowboy_req:read_body(Req1), try jsx:decode(Body, [return_maps]) of #{<<"word">> := NewWord} -> case core_banned_words:update_banned_word(OldWord, NewWord) of {ok, _Updated} -> admin_utils:log_admin_action(AdminId, <<"update">>, <<"banned_word">>, #{old_word => OldWord, new_word => NewWord}, Req2), handler_utils:send_json(Req2, 200, #{status => <<"updated">>}); {error, not_found} -> handler_utils:send_error(Req2, 404, <<"Word not found">>); {error, already_exists} -> handler_utils:send_error(Req2, 409, <<"New word already exists">>); {error, _} -> handler_utils:send_error(Req2, 500, <<"Internal server error">>) end; _ -> handler_utils:send_error(Req2, 400, <<"Missing 'word' field">>) catch _:_ -> handler_utils:send_error(Req1, 400, <<"Invalid JSON">>) end; {error, Code, Msg, Req1} -> handler_utils:send_error(Req1, Code, Msg) end. delete_word(Req) -> case handler_utils:auth_admin(Req) of {ok, AdminId, Req1} -> Word = cowboy_req:binding(word, Req1), case core_banned_words:remove_banned_word(Word) of {ok, _} -> admin_utils:log_admin_action(AdminId, <<"delete">>, <<"banned_word">>, Word, Req1), handler_utils:send_json(Req1, 200, #{status => <<"deleted">>}); {error, not_found} -> handler_utils:send_error(Req1, 404, <<"Word not found">>); {error, _} -> handler_utils:send_error(Req1, 500, <<"Internal server error">>) end; {error, Code, Msg, Req1} -> handler_utils:send_error(Req1, Code, Msg) end. batch_add_words(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 #{<<"words">> := Words} when is_list(Words) -> ValidWords = [W || W <- Words, is_binary(W)], InvalidCount = length(Words) - length(ValidWords), Results = lists:map( fun(Word) -> case core_banned_words:add_banned_word(Word, AdminId) of {ok, _} -> #{word => Word, status => <<"added">>}; {error, already_exists} -> #{word => Word, status => <<"skipped">>}; {error, _} -> #{word => Word, status => <<"error">>} end end, ValidWords ), Added = length([R || R <- Results, maps:get(status, R) =:= <<"added">>]), Skipped = length([R || R <- Results, maps:get(status, R) =:= <<"skipped">>]), admin_utils:log_admin_action(AdminId, <<"batch_add">>, <<"banned_words">>, #{count => length(Words), added => Added, skipped => Skipped}, Req2), handler_utils:send_json(Req2, 200, #{ results => Results, added => Added, skipped => Skipped, invalid_count => InvalidCount }); _ -> handler_utils:send_error(Req2, 400, <<"Missing 'words' array">>) catch _:_ -> handler_utils:send_error(Req1, 400, <<"Invalid JSON">>) end; {error, Code, Msg, Req1} -> handler_utils:send_error(Req1, Code, Msg) end. %%%=================================================================== %%% Вспомогательные функции %%%=================================================================== %% @private Преобразование записи banned_word в JSON-совместимую карту. word_to_map(W) -> #{ id => W#banned_word.id, word => W#banned_word.word, added_by => W#banned_word.added_by, added_at => handler_utils:datetime_to_iso8601(W#banned_word.added_at) }.