Рефакторинг админских обработчиков - пагинация, сортировка, фильтрация. Часть 1

This commit is contained in:
2026-05-26 13:04:31 +03:00
parent 01dbc6d6cb
commit ed5b275efb
24 changed files with 1009 additions and 1126 deletions
+46 -61
View File
@@ -6,7 +6,6 @@
%%%------------------------------------------------------------------- %%%-------------------------------------------------------------------
-module(admin_handler_admins). -module(admin_handler_admins).
-behaviour(cowboy_handler). -behaviour(cowboy_handler).
-export([init/2]). -export([init/2]).
-export([trails/0]). -export([trails/0]).
@@ -18,20 +17,23 @@ init(Req, _Opts) ->
case cowboy_req:method(Req) of case cowboy_req:method(Req) of
<<"GET">> -> list_admins(Req); <<"GET">> -> list_admins(Req);
<<"POST">> -> create_admin(Req); <<"POST">> -> create_admin(Req);
_ -> handler_utils:send_error(Req, 405, <<"Method not allowed">>) _ -> handler_utils:send_error(Req, 405, <<"Method not allowed">>)
end. end.
%%% Swagger metadata %%% Swagger metadata
-spec trails() -> [map()]. -spec trails() -> [map()].
trails() -> trails() ->
ListGet = #{ ListGet = #{
path => <<"/v1/admin/admins">>, path => <<"/v1/admin/admins">>,
method => <<"GET">>, method => <<"GET">>,
description => <<"List all admins (superadmin only)">>, description => <<"List all admins (superadmin only)">>,
tags => [<<"Admins">>], tags => [<<"Admins">>],
parameters => [ parameters => [
#{name => <<"role">>, in => <<"query">>, schema => #{type => string}}, #{name => <<"role">>, in => <<"query">>, schema => #{type => string}},
#{name => <<"status">>, in => <<"query">>, schema => #{type => string}}, #{name => <<"status">>, in => <<"query">>, schema => #{type => string}},
#{name => <<"q">>, in => <<"query">>, schema => #{type => string}, description => <<"Search by email or nickname">>},
#{name => <<"sort">>, in => <<"query">>, schema => #{type => string, enum => [<<"email">>, <<"role">>, <<"created_at">>]}, description => <<"Sort field">>},
#{name => <<"order">>, in => <<"query">>, schema => #{type => string, enum => [<<"asc">>, <<"desc">>]}, description => <<"Sort order">>},
#{name => <<"limit">>, in => <<"query">>, schema => #{type => integer}}, #{name => <<"limit">>, in => <<"query">>, schema => #{type => integer}},
#{name => <<"offset">>, in => <<"query">>, schema => #{type => integer}} #{name => <<"offset">>, in => <<"query">>, schema => #{type => integer}}
], ],
@@ -39,22 +41,22 @@ trails() ->
200 => #{ 200 => #{
description => <<"Array of admins">>, description => <<"Array of admins">>,
content => #{<<"application/json">> => #{schema => #{ content => #{<<"application/json">> => #{schema => #{
type => array, type => array,
items => admin_schema() items => admin_schema()
}}} }}}
} }
} }
}, },
PostCreate = #{ PostCreate = #{
path => <<"/v1/admin/admins">>, path => <<"/v1/admin/admins">>,
method => <<"POST">>, method => <<"POST">>,
description => <<"Create a new admin (superadmin only)">>, description => <<"Create a new admin (superadmin only)">>,
tags => [<<"Admins">>], tags => [<<"Admins">>],
requestBody => #{ requestBody => #{
required => true, required => true,
content => #{<<"application/json">> => #{schema => #{ content => #{<<"application/json">> => #{schema => #{
type => object, type => object,
required => [<<"email">>, <<"password">>, <<"role">>], required => [<<"email">>, <<"password">>, <<"role">>],
properties => #{ properties => #{
email => #{type => string, format => <<"email">>}, email => #{type => string, format => <<"email">>},
password => #{type => string}, password => #{type => string},
@@ -74,25 +76,27 @@ trails() ->
-spec admin_schema() -> map(). -spec admin_schema() -> map().
admin_schema() -> admin_schema() ->
#{ #{
type => object, type => object,
properties => #{ properties => #{
id => #{type => string}, id => #{type => string},
email => #{type => string, format => <<"email">>}, email => #{type => string, format => <<"email">>},
role => #{type => string, enum => [<<"superadmin">>, <<"admin">>, <<"moderator">>, <<"support">>]}, role => #{type => string, enum => [<<"superadmin">>, <<"admin">>, <<"moderator">>, <<"support">>]},
status => #{type => string, enum => [<<"active">>, <<"blocked">>]}, status => #{type => string, enum => [<<"active">>, <<"blocked">>]},
nickname => #{type => string, nullable => true}, nickname => #{type => string, nullable => true},
avatar_url => #{type => string, nullable => true}, avatar_url => #{type => string, nullable => true},
timezone => #{type => string, nullable => true}, timezone => #{type => string, nullable => true},
language => #{type => string, nullable => true}, language => #{type => string, nullable => true},
phone => #{type => string, nullable => true}, phone => #{type => string, nullable => true},
preferences => #{type => object, nullable => true}, preferences => #{type => object, nullable => true},
last_login => #{type => string, format => <<"date-time">>}, last_login => #{type => string, format => <<"date-time">>},
created_at => #{type => string, format => <<"date-time">>}, created_at => #{type => string, format => <<"date-time">>},
updated_at => #{type => string, format => <<"date-time">>} updated_at => #{type => string, format => <<"date-time">>}
} }
}. }.
%%% Internal functions %%%===================================================================
%%% HTTP-методы
%%%===================================================================
%% @doc GET /v1/admin/admins – список администраторов. %% @doc GET /v1/admin/admins – список администраторов.
-spec list_admins(cowboy_req:req()) -> {ok, binary(), cowboy_req:req()}. -spec list_admins(cowboy_req:req()) -> {ok, binary(), cowboy_req:req()}.
@@ -102,8 +106,8 @@ list_admins(Req) ->
Filters = parse_admin_filters(Req1), Filters = parse_admin_filters(Req1),
Pagination = handler_utils:parse_pagination_params(Req1), Pagination = handler_utils:parse_pagination_params(Req1),
{ok, Total, Admins} = logic_admin:list_admins(Filters, Pagination), {ok, Total, Admins} = logic_admin:list_admins(Filters, Pagination),
Json = [admin_to_json(A) || A <- Admins], Json = [handler_utils:admin_to_json(A) || A <- Admins],
ExtraHeaders = pagination_headers(Pagination, Total), ExtraHeaders = handler_utils:pagination_headers(Pagination, Total),
handler_utils:send_json(Req1, 200, Json, ExtraHeaders); handler_utils:send_json(Req1, 200, Json, ExtraHeaders);
{error, Code, Msg, Req1} -> {error, Code, Msg, Req1} ->
handler_utils:send_error(Req1, Code, Msg) handler_utils:send_error(Req1, Code, Msg)
@@ -113,7 +117,7 @@ list_admins(Req) ->
-spec create_admin(cowboy_req:req()) -> {ok, binary(), cowboy_req:req()}. -spec create_admin(cowboy_req:req()) -> {ok, binary(), cowboy_req:req()}.
create_admin(Req) -> create_admin(Req) ->
case handler_utils:is_superadmin(Req) of case handler_utils:is_superadmin(Req) of
{ok, _AdminId, Req1} -> {ok, SuperAdminId, Req1} ->
{ok, Body, Req2} = cowboy_req:read_body(Req1), {ok, Body, Req2} = cowboy_req:read_body(Req1),
try jsx:decode(Body, [return_maps]) of try jsx:decode(Body, [return_maps]) of
#{<<"email">> := Email, <<"password">> := Password, <<"role">> := RoleBin} -> #{<<"email">> := Email, <<"password">> := Password, <<"role">> := RoleBin} ->
@@ -126,7 +130,8 @@ create_admin(Req) ->
_ -> _ ->
case logic_admin:create_admin(Email, Password, Role) of case logic_admin:create_admin(Email, Password, Role) of
{ok, Admin} -> {ok, Admin} ->
handler_utils:send_json(Req2, 201, admin_to_json(Admin)); admin_utils:log_admin_action(SuperAdminId, <<"create_admin">>, <<"admin">>, Email, Req2),
handler_utils:send_json(Req2, 201, handler_utils:admin_to_json(Admin));
{error, email_exists} -> {error, email_exists} ->
handler_utils:send_error(Req2, 409, <<"Email already exists">>); handler_utils:send_error(Req2, 409, <<"Email already exists">>);
{error, invalid_role} -> {error, invalid_role} ->
@@ -135,7 +140,8 @@ create_admin(Req) ->
handler_utils:send_error(Req2, 500, <<"Internal server error">>) handler_utils:send_error(Req2, 500, <<"Internal server error">>)
end end
end; end;
_ -> handler_utils:send_error(Req2, 400, <<"Missing fields">>) _ ->
handler_utils:send_error(Req2, 400, <<"Missing fields">>)
catch catch
_:_ -> handler_utils:send_error(Req1, 400, <<"Invalid JSON">>) _:_ -> handler_utils:send_error(Req1, 400, <<"Invalid JSON">>)
end; end;
@@ -143,37 +149,16 @@ create_admin(Req) ->
handler_utils:send_error(Req1, Code, Msg) handler_utils:send_error(Req1, Code, Msg)
end. end.
%%%===================================================================
%%% Вспомогательные функции
%%%===================================================================
%% @private Разбор query-параметров для фильтрации.
-spec parse_admin_filters(cowboy_req:req()) -> map(). -spec parse_admin_filters(cowboy_req:req()) -> map().
parse_admin_filters(Req) -> parse_admin_filters(Req) ->
Qs = cowboy_req:parse_qs(Req), Qs = cowboy_req:parse_qs(Req),
#{ #{
role => proplists:get_value(<<"role">>, Qs), role => proplists:get_value(<<"role">>, Qs),
status => proplists:get_value(<<"status">>, Qs) status => proplists:get_value(<<"status">>, Qs),
}. q => proplists:get_value(<<"q">>, Qs)
-spec admin_to_json(#admin{}) -> map().
admin_to_json(Admin) ->
#{
id => Admin#admin.id,
email => Admin#admin.email,
role => atom_to_binary(Admin#admin.role, utf8),
status => atom_to_binary(Admin#admin.status, utf8),
nickname => Admin#admin.nickname,
avatar_url => Admin#admin.avatar_url,
timezone => Admin#admin.timezone,
language => Admin#admin.language,
phone => Admin#admin.phone,
preferences => Admin#admin.preferences,
last_login => handler_utils:datetime_to_iso8601(Admin#admin.last_login),
created_at => handler_utils:datetime_to_iso8601(Admin#admin.created_at),
updated_at => handler_utils:datetime_to_iso8601(Admin#admin.updated_at)
}.
-spec pagination_headers(map(), non_neg_integer()) -> map().
pagination_headers(#{limit := Limit, offset := Offset}, Total) ->
RangeEnd = min(Offset + Limit - 1, Total - 1),
#{
<<"content-range">> => iolist_to_binary(io_lib:format("items ~B-~B/~B", [Offset, RangeEnd, Total])),
<<"x-total-count">> => integer_to_binary(Total),
<<"access-control-expose-headers">> => <<"Content-Range, X-Total-Count">>
}. }.
@@ -1,15 +1,14 @@
%%%------------------------------------------------------------------- %%%-------------------------------------------------------------------
%%% @doc Административный обработчик конкретного администратора. %%% @doc Административный обработчик конкретного администратора.
%%% GET /v1/admin/admins/:id – получить администратора %%% GET /v1/admin/admins/:id получить администратора
%%% PUT /v1/admin/admins/:id – обновить администратора %%% PUT /v1/admin/admins/:id обновить администратора
%%% DELETE /v1/admin/admins/:id – удалить администратора %%% DELETE /v1/admin/admins/:id удалить администратора
%%% %%%
%%% Все операции доступны только суперадмину. %%% Все операции доступны только суперадмину.
%%% @end %%% @end
%%%------------------------------------------------------------------- %%%-------------------------------------------------------------------
-module(admin_handler_admins_by_id). -module(admin_handler_admins_by_id).
-behaviour(cowboy_handler). -behaviour(cowboy_handler).
-export([init/2]). -export([init/2]).
-export([trails/0]). -export([trails/0]).
@@ -22,7 +21,7 @@ init(Req, _Opts) ->
<<"GET">> -> get_admin(Req); <<"GET">> -> get_admin(Req);
<<"PUT">> -> update_admin(Req); <<"PUT">> -> update_admin(Req);
<<"DELETE">> -> delete_admin(Req); <<"DELETE">> -> delete_admin(Req);
_ -> handler_utils:send_error(Req, 405, <<"Method not allowed">>) _ -> handler_utils:send_error(Req, 405, <<"Method not allowed">>)
end. end.
%%% Swagger metadata %%% Swagger metadata
@@ -30,42 +29,39 @@ init(Req, _Opts) ->
trails() -> trails() ->
IdParam = [#{name => <<"id">>, in => <<"path">>, required => true, schema => #{type => string}}], IdParam = [#{name => <<"id">>, in => <<"path">>, required => true, schema => #{type => string}}],
[ [
#{ % GET by id #{ % GET by id
path => <<"/v1/admin/admins/:id">>, path => <<"/v1/admin/admins/:id">>,
method => <<"GET">>, method => <<"GET">>,
description => <<"Get admin by ID (superadmin only)">>, description => <<"Get admin by ID (superadmin only)">>,
tags => [<<"Admins">>], tags => [<<"Admins">>],
parameters => IdParam, parameters => IdParam,
responses => #{ responses => #{
200 => #{ 200 => #{ description => <<"Admin details">>, content => #{<<"application/json">> => #{schema => admin_schema()}} },
description => <<"Admin details">>,
content => #{<<"application/json">> => #{schema => admin_schema()}}
},
404 => #{description => <<"Admin not found">>} 404 => #{description => <<"Admin not found">>}
} }
}, },
#{ % PUT update #{ % PUT update
path => <<"/v1/admin/admins/:id">>, path => <<"/v1/admin/admins/:id">>,
method => <<"PUT">>, method => <<"PUT">>,
description => <<"Update admin (superadmin only)">>, description => <<"Update admin (superadmin only)">>,
tags => [<<"Admins">>], tags => [<<"Admins">>],
parameters => IdParam, parameters => IdParam,
requestBody => #{ requestBody => #{
required => true, required => true,
content => #{<<"application/json">> => #{schema => admin_update_schema()}} content => #{<<"application/json">> => #{schema => admin_update_schema()}}
}, },
responses => #{ responses => #{
200 => #{description => <<"Admin updated">>}, 200 => #{description => <<"Admin updated">>},
404 => #{description => <<"Admin not found">>} 404 => #{description => <<"Admin not found">>}
} }
}, },
#{ % DELETE #{ % DELETE
path => <<"/v1/admin/admins/:id">>, path => <<"/v1/admin/admins/:id">>,
method => <<"DELETE">>, method => <<"DELETE">>,
description => <<"Delete admin (superadmin only)">>, description => <<"Delete admin (superadmin only)">>,
tags => [<<"Admins">>], tags => [<<"Admins">>],
parameters => IdParam, parameters => IdParam,
responses => #{ responses => #{
200 => #{description => <<"Admin deleted">>}, 200 => #{description => <<"Admin deleted">>},
404 => #{description => <<"Admin not found">>} 404 => #{description => <<"Admin not found">>}
} }
@@ -75,28 +71,28 @@ trails() ->
-spec admin_schema() -> map(). -spec admin_schema() -> map().
admin_schema() -> admin_schema() ->
#{ #{
type => object, type => object,
properties => #{ properties => #{
id => #{type => string}, id => #{type => string},
email => #{type => string}, email => #{type => string},
role => #{type => string, enum => [<<"superadmin">>, <<"admin">>, <<"moderator">>, <<"support">>]}, role => #{type => string, enum => [<<"superadmin">>, <<"admin">>, <<"moderator">>, <<"support">>]},
status => #{type => string, enum => [<<"active">>, <<"blocked">>]}, status => #{type => string, enum => [<<"active">>, <<"blocked">>]},
nickname => #{type => string, nullable => true}, nickname => #{type => string, nullable => true},
avatar_url => #{type => string, nullable => true}, avatar_url => #{type => string, nullable => true},
timezone => #{type => string, nullable => true}, timezone => #{type => string, nullable => true},
language => #{type => string, nullable => true}, language => #{type => string, nullable => true},
phone => #{type => string, nullable => true}, phone => #{type => string, nullable => true},
preferences => #{type => object, nullable => true}, preferences => #{type => object, nullable => true},
last_login => #{type => string, format => <<"date-time">>}, last_login => #{type => string, format => <<"date-time">>},
created_at => #{type => string, format => <<"date-time">>}, created_at => #{type => string, format => <<"date-time">>},
updated_at => #{type => string, format => <<"date-time">>} updated_at => #{type => string, format => <<"date-time">>}
} }
}. }.
-spec admin_update_schema() -> map(). -spec admin_update_schema() -> map().
admin_update_schema() -> admin_update_schema() ->
#{ #{
type => object, type => object,
properties => #{ properties => #{
nickname => #{type => string}, nickname => #{type => string},
avatar_url => #{type => string}, avatar_url => #{type => string},
@@ -117,7 +113,7 @@ get_admin(Req) ->
Id = cowboy_req:binding(id, Req1), Id = cowboy_req:binding(id, Req1),
case logic_admin:get_admin(Id) of case logic_admin:get_admin(Id) of
{ok, Admin} -> {ok, Admin} ->
handler_utils:send_json(Req1, 200, admin_to_json(Admin)); handler_utils:send_json(Req1, 200, handler_utils:admin_to_json(Admin));
{error, not_found} -> {error, not_found} ->
handler_utils:send_error(Req1, 404, <<"Admin not found">>); handler_utils:send_error(Req1, 404, <<"Admin not found">>);
{error, _} -> {error, _} ->
@@ -131,7 +127,7 @@ get_admin(Req) ->
-spec update_admin(cowboy_req:req()) -> {ok, binary(), cowboy_req:req()}. -spec update_admin(cowboy_req:req()) -> {ok, binary(), cowboy_req:req()}.
update_admin(Req) -> update_admin(Req) ->
case handler_utils:is_superadmin(Req) of case handler_utils:is_superadmin(Req) of
{ok, _AdminId, Req1} -> {ok, SuperAdminId, Req1} ->
Id = cowboy_req:binding(id, Req1), Id = cowboy_req:binding(id, Req1),
{ok, Body, Req2} = cowboy_req:read_body(Req1), {ok, Body, Req2} = cowboy_req:read_body(Req1),
try jsx:decode(Body, [return_maps]) of try jsx:decode(Body, [return_maps]) of
@@ -140,7 +136,8 @@ update_admin(Req) ->
Updates = convert_admin_fields(Updates0), Updates = convert_admin_fields(Updates0),
case logic_admin:update_admin(Id, Updates) of case logic_admin:update_admin(Id, Updates) of
{ok, Admin} -> {ok, Admin} ->
handler_utils:send_json(Req2, 200, admin_to_json(Admin)); admin_utils:log_admin_action(SuperAdminId, <<"update_admin">>, <<"admin">>, Id, Req2),
handler_utils:send_json(Req2, 200, handler_utils:admin_to_json(Admin));
{error, not_found} -> {error, not_found} ->
handler_utils:send_error(Req2, 404, <<"Admin not found">>); handler_utils:send_error(Req2, 404, <<"Admin not found">>);
{error, _} -> {error, _} ->
@@ -159,10 +156,11 @@ update_admin(Req) ->
-spec delete_admin(cowboy_req:req()) -> {ok, binary(), cowboy_req:req()}. -spec delete_admin(cowboy_req:req()) -> {ok, binary(), cowboy_req:req()}.
delete_admin(Req) -> delete_admin(Req) ->
case handler_utils:is_superadmin(Req) of case handler_utils:is_superadmin(Req) of
{ok, _AdminId, Req1} -> {ok, SuperAdminId, Req1} ->
Id = cowboy_req:binding(id, Req1), Id = cowboy_req:binding(id, Req1),
case logic_admin:delete_admin(Id) of case logic_admin:delete_admin(Id) of
{ok, _} -> {ok, _} ->
admin_utils:log_admin_action(SuperAdminId, <<"delete_admin">>, <<"admin">>, Id, Req1),
handler_utils:send_json(Req1, 200, #{status => <<"deleted">>}); handler_utils:send_json(Req1, 200, #{status => <<"deleted">>});
{error, not_found} -> {error, not_found} ->
handler_utils:send_error(Req1, 404, <<"Admin not found">>); handler_utils:send_error(Req1, 404, <<"Admin not found">>);
@@ -184,23 +182,4 @@ convert_admin_fields(Updates) ->
({<<"phone">>, V}) -> {phone, V}; ({<<"phone">>, V}) -> {phone, V};
({<<"preferences">>, V}) -> {preferences, V}; ({<<"preferences">>, V}) -> {preferences, V};
(Other) -> Other (Other) -> Other
end, Updates). end, Updates).
%% @private Преобразует запись администратора в JSON-совместимую карту.
-spec admin_to_json(#admin{}) -> map().
admin_to_json(Admin) ->
#{
id => Admin#admin.id,
email => Admin#admin.email,
role => atom_to_binary(Admin#admin.role, utf8),
status => atom_to_binary(Admin#admin.status, utf8),
nickname => Admin#admin.nickname,
avatar_url => Admin#admin.avatar_url,
timezone => Admin#admin.timezone,
language => Admin#admin.language,
phone => Admin#admin.phone,
preferences => Admin#admin.preferences,
last_login => handler_utils:datetime_to_iso8601(Admin#admin.last_login),
created_at => handler_utils:datetime_to_iso8601(Admin#admin.created_at),
updated_at => handler_utils:datetime_to_iso8601(Admin#admin.updated_at)
}.
+46 -66
View File
@@ -6,7 +6,6 @@
%%%------------------------------------------------------------------- %%%-------------------------------------------------------------------
-module(admin_handler_audit). -module(admin_handler_audit).
-behaviour(cowboy_handler). -behaviour(cowboy_handler).
-export([init/2]). -export([init/2]).
-export([trails/0]). -export([trails/0]).
@@ -17,7 +16,7 @@
init(Req, _Opts) -> init(Req, _Opts) ->
case cowboy_req:method(Req) of case cowboy_req:method(Req) of
<<"GET">> -> list_audit(Req); <<"GET">> -> list_audit(Req);
_ -> handler_utils:send_error(Req, 405, <<"Method not allowed">>) _ -> handler_utils:send_error(Req, 405, <<"Method not allowed">>)
end. end.
%%% Swagger metadata %%% Swagger metadata
@@ -25,25 +24,29 @@ init(Req, _Opts) ->
trails() -> trails() ->
[ [
#{ #{
path => <<"/v1/admin/audit">>, path => <<"/v1/admin/audit">>,
method => <<"GET">>, method => <<"GET">>,
description => <<"List audit records (superadmin only)">>, description => <<"List audit records (superadmin only)">>,
tags => [<<"Audit">>], tags => [<<"Audit">>],
parameters => [ parameters => [
#{name => <<"admin_id">>, in => <<"query">>, schema => #{type => string}, description => <<"Filter by admin ID">>}, #{name => <<"admin_id">>, in => <<"query">>, schema => #{type => string}, description => <<"Filter by admin ID">>},
#{name => <<"action">>, in => <<"query">>, schema => #{type => string}, description => <<"Filter by action">>}, #{name => <<"action">>, in => <<"query">>, schema => #{type => string}, description => <<"Filter by action">>},
#{name => <<"date_from">>, in => <<"query">>, schema => #{type => string, format => <<"date-time">>}, description => <<"Start timestamp (ISO8601)">>}, #{name => <<"date_from">>, in => <<"query">>, schema => #{type => string, format => <<"date-time">>}, description => <<"Start timestamp (ISO8601)">>},
#{name => <<"date_to">>, in => <<"query">>, schema => #{type => string, format => <<"date-time">>}, description => <<"End timestamp (ISO8601)">>}, #{name => <<"date_to">>, in => <<"query">>, schema => #{type => string, format => <<"date-time">>}, description => <<"End timestamp (ISO8601)">>},
#{name => <<"limit">>, in => <<"query">>, schema => #{type => integer}, description => <<"Page size">>}, #{name => <<"limit">>, in => <<"query">>, schema => #{type => integer}, description => <<"Page size">>},
#{name => <<"offset">>, in => <<"query">>, schema => #{type => integer}, description => <<"Offset">>} #{name => <<"offset">>, in => <<"query">>, schema => #{type => integer}, description => <<"Offset">>}
], ],
responses => #{ responses => #{
200 => #{ 200 => #{
description => <<"Array of audit records">>, description => <<"Array of audit records">>,
content => #{<<"application/json">> => #{schema => #{ content => #{
type => array, <<"application/json">> => #{
items => audit_schema() schema => #{
}}} type => array,
items => audit_schema()
}
}
}
} }
} }
} }
@@ -51,78 +54,55 @@ trails() ->
audit_schema() -> audit_schema() ->
#{ #{
type => object, type => object,
properties => #{ properties => #{
id => #{type => string}, id => #{type => string},
admin_id => #{type => string}, admin_id => #{type => string},
email => #{type => string, format => <<"email">>}, email => #{type => string, format => <<"email">>},
role => #{type => string}, role => #{type => string},
action => #{type => string}, action => #{type => string},
entity_type => #{type => string}, entity_type => #{type => string},
entity_id => #{type => string}, entity_id => #{type => string},
timestamp => #{type => string, format => <<"date-time">>}, timestamp => #{type => string, format => <<"date-time">>},
ip => #{type => string}, ip => #{type => string},
reason => #{type => string, nullable => true} reason => #{type => string, nullable => true}
} }
}. }.
%%% Internal functions %%%===================================================================
%%% HTTP-методы
%%%===================================================================
%% @doc Получить список записей аудита с пагинацией и фильтрацией. %% @doc GET /v1/admin/audit список записей аудита.
-spec list_audit(cowboy_req:req()) -> {ok, binary(), cowboy_req:req()}. -spec list_audit(cowboy_req:req()) -> {ok, binary(), cowboy_req:req()}.
list_audit(Req) -> list_audit(Req) ->
case handler_utils:is_superadmin(Req) of case handler_utils:is_superadmin(Req) of
{ok, _AdminId, Req1} -> {ok, SuperAdminId, Req1} ->
Filters = parse_audit_filters(Req1), Filters = parse_audit_filters(Req1),
Pagination = handler_utils:parse_pagination_params(Req1), Pagination = handler_utils:parse_pagination_params(Req1),
AllRecords = core_admin_audit:list(Filters), AllRecords = core_admin_audit:list(Filters),
Total = length(AllRecords), Total = length(AllRecords),
Page = lists:sublist(AllRecords, maps:get(offset, Pagination) + 1, maps:get(limit, Pagination)), Page = lists:sublist(AllRecords, maps:get(offset, Pagination) + 1, maps:get(limit, Pagination)),
Json = [audit_to_json(R) || R <- Page], Json = [handler_utils:audit_to_json(R) || R <- Page],
ExtraHeaders = pagination_headers(Pagination, Total), ExtraHeaders = handler_utils:pagination_headers(Pagination, Total),
% Аудит доступа к журналу
admin_utils:log_admin_action(SuperAdminId, <<"view_audit">>, <<"audit">>, <<"list">>, Req1),
handler_utils:send_json(Req1, 200, Json, ExtraHeaders); handler_utils:send_json(Req1, 200, Json, ExtraHeaders);
{error, Code, Msg, Req1} -> {error, Code, Msg, Req1} ->
handler_utils:send_error(Req1, Code, Msg) handler_utils:send_error(Req1, Code, Msg)
end. end.
%%%===================================================================
%%% Вспомогательные функции
%%%===================================================================
%% @private Разбор query-параметров для фильтрации аудита.
-spec parse_audit_filters(cowboy_req:req()) -> proplists:proplist(). -spec parse_audit_filters(cowboy_req:req()) -> proplists:proplist().
parse_audit_filters(Req) -> parse_audit_filters(Req) ->
Qs = cowboy_req:parse_qs(Req), Qs = cowboy_req:parse_qs(Req),
[ [
{admin_id, proplists:get_value(<<"admin_id">>, Qs)}, {admin_id, proplists:get_value(<<"admin_id">>, Qs)},
{action, proplists:get_value(<<"action">>, Qs)}, {action, proplists:get_value(<<"action">>, Qs)},
{date_from, parse_datetime_qs(proplists:get_value(<<"date_from">>, Qs))}, {date_from, handler_utils:parse_datetime_qs(proplists:get_value(<<"date_from">>, Qs))},
{date_to, parse_datetime_qs(proplists:get_value(<<"date_to">>, Qs))} {date_to, handler_utils:parse_datetime_qs(proplists:get_value(<<"date_to">>, Qs))}
]. ].
-spec parse_datetime_qs(binary() | undefined) -> calendar:datetime() | undefined.
parse_datetime_qs(undefined) -> undefined;
parse_datetime_qs(Bin) ->
case handler_utils:parse_datetime(Bin) of
{ok, Dt} -> Dt;
_ -> undefined
end.
-spec audit_to_json(#admin_audit{}) -> map().
audit_to_json(A) ->
#{
id => A#admin_audit.id,
admin_id => A#admin_audit.admin_id,
email => A#admin_audit.email,
role => A#admin_audit.role,
action => A#admin_audit.action,
entity_type => A#admin_audit.entity_type,
entity_id => A#admin_audit.entity_id,
timestamp => handler_utils:datetime_to_iso8601(A#admin_audit.timestamp),
ip => A#admin_audit.ip,
reason => A#admin_audit.reason
}.
-spec pagination_headers(map(), non_neg_integer()) -> map().
pagination_headers(#{limit := Limit, offset := Offset}, Total) ->
RangeEnd = min(Offset + Limit - 1, Total - 1),
#{
<<"content-range">> => iolist_to_binary(io_lib:format("items ~B-~B/~B", [Offset, RangeEnd, Total])),
<<"x-total-count">> => integer_to_binary(Total),
<<"access-control-expose-headers">> => <<"Content-Range, X-Total-Count">>
}.
@@ -31,10 +31,8 @@ init(Req, _Opts) ->
%%% Swagger metadata %%% Swagger metadata
-spec trails() -> [map()]. -spec trails() -> [map()].
trails() -> trails() ->
[ [ % GET list
% GET list #{ path => <<"/v1/admin/banned-words">>,
#{
path => <<"/v1/admin/banned-words">>,
method => <<"GET">>, method => <<"GET">>,
description => <<"List all banned words (admin)">>, description => <<"List all banned words (admin)">>,
tags => [<<"Banned Words">>], tags => [<<"Banned Words">>],
@@ -53,8 +51,7 @@ trails() ->
} }
}, },
% POST add % POST add
#{ #{ path => <<"/v1/admin/banned-words">>,
path => <<"/v1/admin/banned-words">>,
method => <<"POST">>, method => <<"POST">>,
description => <<"Add a new banned word">>, description => <<"Add a new banned word">>,
tags => [<<"Banned Words">>], tags => [<<"Banned Words">>],
@@ -72,8 +69,7 @@ trails() ->
} }
}, },
% PUT update % PUT update
#{ #{ path => <<"/v1/admin/banned-words/{word}">>,
path => <<"/v1/admin/banned-words/{word}">>,
method => <<"PUT">>, method => <<"PUT">>,
description => <<"Update an existing banned word">>, description => <<"Update an existing banned word">>,
tags => [<<"Banned Words">>], tags => [<<"Banned Words">>],
@@ -95,8 +91,7 @@ trails() ->
} }
}, },
% DELETE by word % DELETE by word
#{ #{ path => <<"/v1/admin/banned-words/{word}">>,
path => <<"/v1/admin/banned-words/{word}">>,
method => <<"DELETE">>, method => <<"DELETE">>,
description => <<"Remove a banned word">>, description => <<"Remove a banned word">>,
tags => [<<"Banned Words">>], tags => [<<"Banned Words">>],
@@ -109,8 +104,7 @@ trails() ->
} }
}, },
% POST batch % POST batch
#{ #{ path => <<"/v1/admin/banned-words/batch">>,
path => <<"/v1/admin/banned-words/batch">>,
method => <<"POST">>, method => <<"POST">>,
description => <<"Batch add banned words (only new words are added)">>, description => <<"Batch add banned words (only new words are added)">>,
tags => [<<"Banned Words">>], tags => [<<"Banned Words">>],
@@ -167,7 +161,7 @@ add_word(Req) ->
#{<<"word">> := Word} -> #{<<"word">> := Word} ->
case core_banned_words:add_banned_word(Word, AdminId) of case core_banned_words:add_banned_word(Word, AdminId) of
{ok, _} -> {ok, _} ->
log_admin_action(AdminId, <<"add">>, <<"banned_word">>, Word, Req2), admin_utils:log_admin_action(AdminId, <<"add">>, <<"banned_word">>, Word, Req2),
handler_utils:send_json(Req2, 201, #{status => <<"added">>}); handler_utils:send_json(Req2, 201, #{status => <<"added">>});
{error, already_exists} -> {error, already_exists} ->
handler_utils:send_error(Req2, 409, <<"Word already exists">>); handler_utils:send_error(Req2, 409, <<"Word already exists">>);
@@ -191,7 +185,7 @@ update_word(Req) ->
#{<<"word">> := NewWord} -> #{<<"word">> := NewWord} ->
case core_banned_words:update_banned_word(OldWord, NewWord) of case core_banned_words:update_banned_word(OldWord, NewWord) of
{ok, _Updated} -> {ok, _Updated} ->
log_admin_action(AdminId, <<"update">>, <<"banned_word">>, admin_utils:log_admin_action(AdminId, <<"update">>, <<"banned_word">>,
#{old_word => OldWord, new_word => NewWord}, Req2), #{old_word => OldWord, new_word => NewWord}, Req2),
handler_utils:send_json(Req2, 200, #{status => <<"updated">>}); handler_utils:send_json(Req2, 200, #{status => <<"updated">>});
{error, not_found} -> {error, not_found} ->
@@ -215,7 +209,7 @@ delete_word(Req) ->
Word = cowboy_req:binding(word, Req1), Word = cowboy_req:binding(word, Req1),
case core_banned_words:remove_banned_word(Word) of case core_banned_words:remove_banned_word(Word) of
{ok, _} -> {ok, _} ->
log_admin_action(AdminId, <<"delete">>, <<"banned_word">>, Word, Req1), admin_utils:log_admin_action(AdminId, <<"delete">>, <<"banned_word">>, Word, Req1),
handler_utils:send_json(Req1, 200, #{status => <<"deleted">>}); handler_utils:send_json(Req1, 200, #{status => <<"deleted">>});
{error, not_found} -> {error, not_found} ->
handler_utils:send_error(Req1, 404, <<"Word not found">>); handler_utils:send_error(Req1, 404, <<"Word not found">>);
@@ -246,7 +240,7 @@ batch_add_words(Req) ->
), ),
Added = length([R || R <- Results, maps:get(status, R) =:= <<"added">>]), Added = length([R || R <- Results, maps:get(status, R) =:= <<"added">>]),
Skipped = length([R || R <- Results, maps:get(status, R) =:= <<"skipped">>]), Skipped = length([R || R <- Results, maps:get(status, R) =:= <<"skipped">>]),
log_admin_action(AdminId, <<"batch_add">>, <<"banned_words">>, admin_utils:log_admin_action(AdminId, <<"batch_add">>, <<"banned_words">>,
#{count => length(Words), added => Added, skipped => Skipped}, Req2), #{count => length(Words), added => Added, skipped => Skipped}, Req2),
handler_utils:send_json(Req2, 200, #{ handler_utils:send_json(Req2, 200, #{
results => Results, results => Results,
@@ -266,31 +260,11 @@ batch_add_words(Req) ->
%%% Вспомогательные функции %%% Вспомогательные функции
%%%=================================================================== %%%===================================================================
log_admin_action(AdminId, Action, EntityType, EntityId, Req) -> %% @private Преобразование записи banned_word в JSON-совместимую карту.
case core_admin:get_by_id(AdminId) of
{ok, Admin} ->
Email = Admin#admin.email,
Role = atom_to_binary(Admin#admin.role, utf8),
Ip = ip_to_binary(cowboy_req:peer(Req)),
core_admin_audit:log(AdminId, Email, Role, Action, EntityType, EntityId, Ip),
ok;
_ -> ok
end.
ip_to_binary({A, B, C, D}) ->
list_to_binary(io_lib:format("~B.~B.~B.~B", [A, B, C, D]));
ip_to_binary(_) ->
<<"unknown">>.
datetime_to_iso8601(undefined) -> undefined;
datetime_to_iso8601({{Year, Month, Day}, {Hour, Minute, Second}}) ->
iolist_to_binary(io_lib:format("~4..0B-~2..0B-~2..0BT~2..0B:~2..0B:~2..0BZ",
[Year, Month, Day, Hour, Minute, Second])).
word_to_map(W) -> word_to_map(W) ->
#{ #{
id => W#banned_word.id, id => W#banned_word.id,
word => W#banned_word.word, word => W#banned_word.word,
added_by => W#banned_word.added_by, added_by => W#banned_word.added_by,
added_at => datetime_to_iso8601(W#banned_word.added_at) added_at => handler_utils:datetime_to_iso8601(W#banned_word.added_at)
}. }.
@@ -1,3 +1,11 @@
%%%-------------------------------------------------------------------
%%% @doc Административный обработчик для операций с одним календарём.
%%% Эндпоинты:
%%% GET /v1/admin/calendars/:id
%%% PUT /v1/admin/calendars/:id
%%% DELETE /v1/admin/calendars/:id
%%% @end
%%%-------------------------------------------------------------------
-module(admin_handler_calendar_by_id). -module(admin_handler_calendar_by_id).
-behaviour(cowboy_handler). -behaviour(cowboy_handler).
-export([init/2]). -export([init/2]).
@@ -14,7 +22,7 @@ init(Req, _Opts) ->
_ -> handler_utils:send_error(Req, 405, <<"Method not allowed">>) _ -> handler_utils:send_error(Req, 405, <<"Method not allowed">>)
end. end.
%%% Swagger %%% Swagger metadata
-spec trails() -> [map()]. -spec trails() -> [map()].
trails() -> trails() ->
Path = <<"/v1/admin/calendars/{id}">>, Path = <<"/v1/admin/calendars/{id}">>,
@@ -98,7 +106,7 @@ get_calendar(Req) ->
-spec update_calendar(cowboy_req:req()) -> {ok, binary(), cowboy_req:req()}. -spec update_calendar(cowboy_req:req()) -> {ok, binary(), cowboy_req:req()}.
update_calendar(Req) -> update_calendar(Req) ->
case handler_utils:auth_admin(Req) of case handler_utils:auth_admin(Req) of
{ok, _AdminId, Req1} -> {ok, AdminId, Req1} ->
Id = cowboy_req:binding(id, Req1), Id = cowboy_req:binding(id, Req1),
case cowboy_req:read_body(Req1) of case cowboy_req:read_body(Req1) of
{ok, Body, Req2} -> {ok, Body, Req2} ->
@@ -106,6 +114,7 @@ update_calendar(Req) ->
Updates -> Updates ->
case logic_calendar:admin_update(Id, Updates) of case logic_calendar:admin_update(Id, Updates) of
{ok, Updated} -> {ok, Updated} ->
admin_utils:log_admin_action(AdminId, <<"update">>, <<"calendar">>, Id, Req2),
Json = handler_utils:calendar_to_json(Updated), Json = handler_utils:calendar_to_json(Updated),
handler_utils:send_json(Req2, 200, Json); handler_utils:send_json(Req2, 200, Json);
{error, not_found} -> {error, not_found} ->
@@ -126,10 +135,11 @@ update_calendar(Req) ->
-spec delete_calendar(cowboy_req:req()) -> {ok, binary(), cowboy_req:req()}. -spec delete_calendar(cowboy_req:req()) -> {ok, binary(), cowboy_req:req()}.
delete_calendar(Req) -> delete_calendar(Req) ->
case handler_utils:auth_admin(Req) of case handler_utils:auth_admin(Req) of
{ok, _AdminId, Req1} -> {ok, AdminId, Req1} ->
Id = cowboy_req:binding(id, Req1), Id = cowboy_req:binding(id, Req1),
case logic_calendar:admin_delete(Id) of case logic_calendar:admin_delete(Id) of
ok -> ok ->
admin_utils:log_admin_action(AdminId, <<"delete">>, <<"calendar">>, Id, Req1),
handler_utils:send_json(Req1, 200, #{}); handler_utils:send_json(Req1, 200, #{});
{error, not_found} -> {error, not_found} ->
handler_utils:send_error(Req1, 404, <<"Calendar not found">>) handler_utils:send_error(Req1, 404, <<"Calendar not found">>)
+58 -107
View File
@@ -2,7 +2,6 @@
-behaviour(cowboy_handler). -behaviour(cowboy_handler).
-export([init/2]). -export([init/2]).
-export([trails/0]). -export([trails/0]).
-export([calendar_schema/0]). % для использования в admin_handler_calendar_by_id
-include("records.hrl"). -include("records.hrl").
@@ -17,28 +16,26 @@ trails() ->
#{ #{
path => <<"/v1/admin/calendars">>, path => <<"/v1/admin/calendars">>,
method => <<"GET">>, method => <<"GET">>,
description => <<"List all calendars with filtering, sorting, pagination (admin)">>, description => <<"List all calendars (admin)">>,
tags => [<<"Admin Calendars">>], tags => [<<"Admin Calendars">>],
parameters => [ parameters => [
#{name => limit, in => query, schema => #{type => integer}, description => <<"Limit">>}, #{name => <<"status">>, in => <<"query">>, schema => #{type => string}, description => <<"Filter by status">>},
#{name => offset, in => query, schema => #{type => integer}, description => <<"Offset">>}, #{name => <<"type">>, in => <<"query">>, schema => #{type => string}, description => <<"Filter by type">>},
#{name => status, in => query, schema => #{type => string}, description => <<"Filter by status (active, frozen, deleted)">>}, #{name => <<"q">>, in => <<"query">>, schema => #{type => string}, description => <<"Search in title/description">>},
#{name => type, in => query, schema => #{type => string}, description => <<"Filter by type (personal, commercial)">>}, #{name => <<"sort_by">>, in => <<"query">>, schema => #{type => string}, description => <<"Sort field (title, created_at, updated_at)">>},
#{name => q, in => query, schema => #{type => string}, description => <<"Search query">>}, #{name => <<"sort_order">>, in => <<"query">>, schema => #{type => string, enum => [<<"asc">>, <<"desc">>]}, description => <<"Sort direction">>},
#{name => sort_by, in => query, schema => #{type => string, enum => [<<"title">>, <<"created_at">>, <<"updated_at">>]}, description => <<"Sort field">>}, #{name => <<"limit">>, in => <<"query">>, schema => #{type => integer}, description => <<"Page size">>},
#{name => sort_order, in => query, schema => #{type => string, enum => [<<"asc">>, <<"desc">>]}, description => <<"Sort direction">>} #{name => <<"offset">>, in => <<"query">>, schema => #{type => integer}, description => <<"Offset">>}
], ],
responses => #{ responses => #{
200 => #{ 200 => #{
description => <<"Array of calendars">>, description => <<"Array of calendars">>,
content => #{ content => #{<<"application/json">> => #{
<<"application/json">> => #{ schema => #{
schema => #{ type => array,
type => array, items => calendar_schema()
items => calendar_schema()
}
} }
} }}
}, },
403 => #{description => <<"Forbidden">>} 403 => #{description => <<"Forbidden">>}
} }
@@ -70,34 +67,22 @@ calendar_schema() ->
} }
}. }.
%%%===================================================================
%%% HTTP-метод %%% HTTP-метод
%%%===================================================================
list_calendars(Req) -> list_calendars(Req) ->
case handler_utils:auth_admin(Req) of case handler_utils:auth_admin(Req) of
{ok, _AdminId, Req1} -> {ok, _AdminId, Req1} ->
% Парсим параметры
Qs = cowboy_req:parse_qs(Req1),
Filters = parse_filters(Qs),
Sort = parse_sort(Qs),
Pagination = handler_utils:parse_pagination_params(Req1), Pagination = handler_utils:parse_pagination_params(Req1),
Filters = parse_calendar_filters(Req1),
% Получаем все календари
{ok, AllCalendars} = logic_calendar:admin_list_all(), {ok, AllCalendars} = logic_calendar:admin_list_all(),
Filtered = apply_calendar_filters(AllCalendars, Filters),
% Применяем фильтры % Формируем параметры сортировки из query-параметров
Filtered = apply_filters(AllCalendars, Filters), Qs = cowboy_req:parse_qs(Req1),
SortBy = proplists:get_value(<<"sort_by">>, Qs, <<"created_at">>),
% Сортируем SortOrder = proplists:get_value(<<"sort_order">>, Qs, <<"desc">>),
Sorted = apply_sort(Filtered, Sort), Sorted = sort_calendars(Filtered, SortBy, SortOrder),
% Пагинация
Total = length(Sorted), Total = length(Sorted),
Start = maps:get(offset, Pagination) + 1, Page = lists:sublist(Sorted, maps:get(offset, Pagination) + 1, maps:get(limit, Pagination)),
Limit = maps:get(limit, Pagination),
Page = lists:sublist(Sorted, Start, Limit),
Json = [handler_utils:calendar_to_json(C) || C <- Page], Json = [handler_utils:calendar_to_json(C) || C <- Page],
ExtraHeaders = handler_utils:pagination_headers(Pagination, Total), ExtraHeaders = handler_utils:pagination_headers(Pagination, Total),
handler_utils:send_json(Req1, 200, Json, ExtraHeaders); handler_utils:send_json(Req1, 200, Json, ExtraHeaders);
@@ -105,89 +90,55 @@ list_calendars(Req) ->
handler_utils:send_error(Req1, Code, Msg) handler_utils:send_error(Req1, Code, Msg)
end. end.
%%%===================================================================
%%% Парсинг параметров
%%%===================================================================
parse_filters(Qs) ->
Status = proplists:get_value(<<"status">>, Qs, undefined),
Type = proplists:get_value(<<"type">>, Qs, undefined),
Query = proplists:get_value(<<"q">>, Qs, undefined),
#{
status => safe_to_atom(Status),
type => safe_to_atom(Type),
q => Query
}.
safe_to_atom(undefined) -> undefined;
safe_to_atom(Bin) when is_binary(Bin) ->
try binary_to_existing_atom(Bin, utf8)
catch error:badarg -> undefined
end.
parse_sort(Qs) ->
SortBy = proplists:get_value(<<"sort_by">>, Qs, <<"created_at">>),
SortOrder = proplists:get_value(<<"sort_order">>, Qs, <<"desc">>),
#{
sort_by => SortBy,
sort_order => SortOrder
}.
%%%===================================================================
%%% Фильтрация %%% Фильтрация
%%%===================================================================
apply_filters(Calendars, Filters) -> parse_calendar_filters(Req) ->
StatusFilter = maps:get(status, Filters, undefined), Qs = cowboy_req:parse_qs(Req),
TypeFilter = maps:get(type, Filters, undefined), #{
QueryFilter = maps:get(q, Filters, undefined), status => proplists:get_value(<<"status">>, Qs),
type => proplists:get_value(<<"type">>, Qs),
q => proplists:get_value(<<"q">>, Qs)
}.
F1 = case StatusFilter of apply_calendar_filters(Calendars, Filters) ->
StatusBin = maps:get(status, Filters),
TypeBin = maps:get(type, Filters),
Q = maps:get(q, Filters),
F1 = case StatusBin of
undefined -> Calendars; undefined -> Calendars;
Status -> [C || C <- Calendars, C#calendar.status =:= Status] _ -> Status = binary_to_existing_atom(StatusBin, utf8),
[C || C <- Calendars, C#calendar.status =:= Status]
end, end,
F2 = case TypeBin of
F2 = case TypeFilter of
undefined -> F1; undefined -> F1;
Type -> [C || C <- F1, C#calendar.type =:= Type] _ -> Type = binary_to_existing_atom(TypeBin, utf8),
[C || C <- F1, C#calendar.type =:= Type]
end, end,
case Q of
case QueryFilter of
undefined -> F2; undefined -> F2;
Q -> _ ->
LowerQ = string:lowercase(binary_to_list(Q)), QLower = string:lowercase(binary_to_list(Q)),
[C || C <- F2, [C || C <- F2,
string:str(string:lowercase(binary_to_list(C#calendar.title)), LowerQ) > 0 orelse string:str(string:lowercase(binary_to_list(C#calendar.title)), QLower) > 0 orelse
string:str(string:lowercase(binary_to_list(C#calendar.description)), LowerQ) > 0] string:str(string:lowercase(binary_to_list(C#calendar.description)), QLower) > 0]
end. end.
%%%===================================================================
%%% Сортировка %%% Сортировка
%%%===================================================================
apply_sort(Calendars, #{sort_by := SortBy, sort_order := SortOrder}) -> sort_calendars(Calendars, SortBy, SortOrder) ->
Field = binary_to_sort_field(SortBy), Index = case SortBy of
Order = case SortOrder of <<"title">> -> #calendar.title; % элемент 3
<<"asc">> -> fun(A, B) -> A < B end; <<"created_at">> -> #calendar.created_at; % элемент 17
<<"desc">> -> fun(A, B) -> A > B end; <<"updated_at">> -> #calendar.updated_at; % элемент 18
_ -> fun(A, B) -> A >= B end % default desc _ -> #calendar.created_at
end, end,
lists:sort(fun(A, B) -> Sorted = lists:sort(fun(A, B) ->
ValA = get_field(A, Field), VA = element(Index, A),
ValB = get_field(B, Field), VB = element(Index, B),
case {ValA, ValB} of case SortOrder of
{undefined, _} -> false; <<"desc">> -> VA > VB;
{_, undefined} -> true; _ -> VA < VB
_ -> Order(ValA, ValB)
end end
end, Calendars). end, Calendars),
Sorted.
binary_to_sort_field(<<"title">>) -> title;
binary_to_sort_field(<<"created_at">>) -> created_at;
binary_to_sort_field(<<"updated_at">>) -> updated_at;
binary_to_sort_field(_) -> created_at.
get_field(#calendar{title = Title}, title) -> Title;
get_field(#calendar{created_at = CreatedAt}, created_at) -> CreatedAt;
get_field(#calendar{updated_at = UpdatedAt}, updated_at) -> UpdatedAt;
get_field(_, _) -> undefined.
@@ -1,6 +1,13 @@
%%%-------------------------------------------------------------------
%%% @doc Административный обработчик для операций с одним событием.
%%% Эндпоинты:
%%% GET /v1/admin/events/:id
%%% PUT /v1/admin/events/:id
%%% DELETE /v1/admin/events/:id
%%% @end
%%%-------------------------------------------------------------------
-module(admin_handler_event_by_id). -module(admin_handler_event_by_id).
-behaviour(cowboy_handler). -behaviour(cowboy_handler).
-export([init/2]). -export([init/2]).
-export([trails/0]). -export([trails/0]).
@@ -9,64 +16,53 @@
%%%=================================================================== %%%===================================================================
%%% cowboy_handler callback %%% cowboy_handler callback
%%%=================================================================== %%%===================================================================
init(Req, _Opts) -> init(Req, _Opts) ->
case cowboy_req:method(Req) of case cowboy_req:method(Req) of
<<"GET">> -> get_event(Req); <<"GET">> -> get_event(Req);
<<"PUT">> -> update_event(Req); <<"PUT">> -> update_event(Req);
<<"DELETE">> -> delete_event(Req); <<"DELETE">> -> delete_event(Req);
_ -> handler_utils:send_error(Req, 405, <<"Method not allowed">>) _ -> handler_utils:send_error(Req, 405, <<"Method not allowed">>)
end. end.
%%%=================================================================== %%%===================================================================
%%% Swagger metadata %%% Swagger metadata
%%%=================================================================== %%%===================================================================
trails() -> trails() ->
BaseParams = [ BaseParams = [
#{ #{name => <<"id">>, in => <<"path">>, description => <<"Event ID">>, required => true, schema => #{type => string}}
name => <<"id">>,
in => <<"path">>,
description => <<"Event ID">>,
required => true,
schema => #{type => string}
}
], ],
[ [
#{ % GET #{ % GET
path => <<"/v1/admin/events/:id">>, path => <<"/v1/admin/events/:id">>,
method => <<"GET">>, method => <<"GET">>,
description => <<"Get event by ID (admin)">>, description => <<"Get event by ID (admin)">>,
tags => [<<"Events">>], tags => [<<"Events">>],
parameters => BaseParams, parameters => BaseParams,
responses => #{ responses => #{
200 => #{ 200 => #{description => <<"Event details">>, content => #{<<"application/json">> => #{schema => event_schema()}}}
description => <<"Event details">>,
content => #{<<"application/json">> => #{schema => event_schema()}}
}
} }
}, },
#{ % PUT #{ % PUT
path => <<"/v1/admin/events/:id">>, path => <<"/v1/admin/events/:id">>,
method => <<"PUT">>, method => <<"PUT">>,
description => <<"Update event (admin)">>, description => <<"Update event (admin)">>,
tags => [<<"Events">>], tags => [<<"Events">>],
parameters => BaseParams, parameters => BaseParams,
requestBody => #{ requestBody => #{
required => true, required => true,
content => #{<<"application/json">> => #{schema => event_update_schema()}} content => #{<<"application/json">> => #{schema => event_update_schema()}}
}, },
responses => #{ responses => #{
200 => #{description => <<"Updated event">>} 200 => #{description => <<"Updated event">>}
} }
}, },
#{ % DELETE #{ % DELETE
path => <<"/v1/admin/events/:id">>, path => <<"/v1/admin/events/:id">>,
method => <<"DELETE">>, method => <<"DELETE">>,
description => <<"Soft-delete event (admin)">>, description => <<"Soft-delete event (admin)">>,
tags => [<<"Events">>], tags => [<<"Events">>],
parameters => BaseParams, parameters => BaseParams,
responses => #{ responses => #{
200 => #{description => <<"Event status set to deleted">>} 200 => #{description => <<"Event status set to deleted">>}
} }
} }
@@ -74,55 +70,55 @@ trails() ->
event_schema() -> event_schema() ->
#{ #{
type => object, type => object,
properties => #{ properties => #{
id => #{type => string}, id => #{type => string},
calendar_id => #{type => string}, calendar_id => #{type => string},
title => #{type => string}, title => #{type => string},
description => #{type => string}, description => #{type => string},
event_type => #{type => string, enum => [<<"single">>, <<"recurring">>]}, event_type => #{type => string, enum => [<<"single">>, <<"recurring">>]},
start_time => #{type => string, format => <<"date-time">>}, start_time => #{type => string, format => <<"date-time">>},
duration => #{type => integer}, duration => #{type => integer},
recurrence => #{type => object, nullable => true}, recurrence => #{type => object, nullable => true},
master_id => #{type => string, nullable => true}, master_id => #{type => string, nullable => true},
is_instance => #{type => boolean}, is_instance => #{type => boolean},
specialist_id => #{type => string, nullable => true}, specialist_id => #{type => string, nullable => true},
location => #{type => object, nullable => true}, location => #{type => object, nullable => true},
tags => #{type => array, items => #{type => string}}, tags => #{type => array, items => #{type => string}},
capacity => #{type => integer, nullable => true}, capacity => #{type => integer, nullable => true},
online_link => #{type => string, nullable => true}, online_link => #{type => string, nullable => true},
status => #{type => string, enum => [<<"active">>, <<"cancelled">>, <<"completed">>]}, status => #{type => string, enum => [<<"active">>, <<"cancelled">>, <<"completed">>]},
reason => #{type => string, nullable => true}, reason => #{type => string, nullable => true},
rating_avg => #{type => number, format => float}, rating_avg => #{type => number, format => float},
rating_count => #{type => integer}, rating_count => #{type => integer},
attachments => #{type => array, items => #{type => string}, nullable => true}, attachments => #{type => array, items => #{type => string}, nullable => true},
edit_history => #{type => array, items => #{type => object}, nullable => true}, edit_history => #{type => array, items => #{type => object}, nullable => true},
created_at => #{type => string, format => <<"date-time">>}, created_at => #{type => string, format => <<"date-time">>},
updated_at => #{type => string, format => <<"date-time">>} updated_at => #{type => string, format => <<"date-time">>}
} }
}. }.
event_update_schema() -> event_update_schema() ->
#{ #{
type => object, type => object,
properties => #{ properties => #{
title => #{type => string}, title => #{type => string},
description => #{type => string}, description => #{type => string},
start_time => #{type => string, format => <<"date-time">>}, start_time => #{type => string, format => <<"date-time">>},
duration => #{type => integer}, duration => #{type => integer},
status => #{type => string, enum => [<<"active">>, <<"cancelled">>, <<"completed">>]}, status => #{type => string, enum => [<<"active">>, <<"cancelled">>, <<"completed">>]},
specialist_id => #{type => string}, specialist_id => #{type => string},
location => #{ location => #{
type => object, type => object,
properties => #{ properties => #{
address => #{type => string}, address => #{type => string},
lat => #{type => number, format => float}, lat => #{type => number, format => float},
lon => #{type => number, format => float} lon => #{type => number, format => float}
} }
}, },
tags => #{type => array, items => #{type => string}}, tags => #{type => array, items => #{type => string}},
capacity => #{type => integer}, capacity => #{type => integer},
online_link => #{type => string} online_link => #{type => string}
} }
}. }.
@@ -148,7 +144,7 @@ get_event(Req) ->
update_event(Req) -> update_event(Req) ->
case handler_utils:auth_admin(Req) of case handler_utils:auth_admin(Req) of
{ok, _AdminId, Req1} -> {ok, AdminId, Req1} ->
EventId = cowboy_req:binding(id, Req1), EventId = cowboy_req:binding(id, Req1),
{ok, Body, Req2} = cowboy_req:read_body(Req1), {ok, Body, Req2} = cowboy_req:read_body(Req1),
try jsx:decode(Body, [return_maps]) of try jsx:decode(Body, [return_maps]) of
@@ -157,6 +153,8 @@ update_event(Req) ->
UpdatesWithTypes = convert_fields(Updates), UpdatesWithTypes = convert_fields(Updates),
case logic_event:update_event_admin(EventId, UpdatesWithTypes) of case logic_event:update_event_admin(EventId, UpdatesWithTypes) of
{ok, Event} -> {ok, Event} ->
% Аудит обновления события
admin_utils:log_admin_action(AdminId, <<"update_event">>, <<"event">>, EventId, Req2),
handler_utils:send_json(Req2, 200, handler_utils:event_to_json(Event)); handler_utils:send_json(Req2, 200, handler_utils:event_to_json(Event));
{error, not_found} -> {error, not_found} ->
handler_utils:send_error(Req2, 404, <<"Event not found">>); handler_utils:send_error(Req2, 404, <<"Event not found">>);
@@ -174,10 +172,12 @@ update_event(Req) ->
delete_event(Req) -> delete_event(Req) ->
case handler_utils:auth_admin(Req) of case handler_utils:auth_admin(Req) of
{ok, _AdminId, Req1} -> {ok, AdminId, Req1} ->
EventId = cowboy_req:binding(id, Req1), EventId = cowboy_req:binding(id, Req1),
case logic_event:delete_event_admin(EventId) of case logic_event:delete_event_admin(EventId) of
{ok, _} -> {ok, _} ->
% Аудит удаления события
admin_utils:log_admin_action(AdminId, <<"delete_event">>, <<"event">>, EventId, Req1),
handler_utils:send_json(Req1, 200, #{status => <<"deleted">>}); handler_utils:send_json(Req1, 200, #{status => <<"deleted">>});
{error, not_found} -> {error, not_found} ->
handler_utils:send_error(Req1, 404, <<"Event not found">>); handler_utils:send_error(Req1, 404, <<"Event not found">>);
@@ -188,35 +188,39 @@ delete_event(Req) ->
handler_utils:send_error(Req1, Code, Msg) handler_utils:send_error(Req1, Code, Msg)
end. end.
%%%===================================================================
%%% Вспомогательные функции
%%%===================================================================
convert_fields(Updates) -> convert_fields(Updates) ->
lists:map(fun convert_field/1, Updates). lists:map(fun convert_field/1, Updates).
convert_field({<<"title">>, Val}) -> {title, Val}; convert_field({<<"title">>, Val}) -> {title, Val};
convert_field({<<"description">>, Val}) -> {description, Val}; convert_field({<<"description">>, Val}) -> {description, Val};
convert_field({<<"event_type">>, Val}) -> {event_type, Val}; convert_field({<<"event_type">>, Val}) -> {event_type, Val};
convert_field({<<"start_time">>, Val}) -> convert_field({<<"start_time">>, Val}) ->
case handler_utils:parse_datetime(Val) of case handler_utils:parse_datetime(Val) of
{ok, Dt} -> {start_time, Dt}; {ok, Dt} -> {start_time, Dt};
_ -> {start_time, Val} _ -> {start_time, Val}
end; end;
convert_field({<<"duration">>, Val}) -> {duration, Val}; convert_field({<<"duration">>, Val}) -> {duration, Val};
convert_field({<<"recurrence">>, Val}) -> {recurrence_rule, jsx:encode(Val)}; convert_field({<<"recurrence">>, Val}) -> {recurrence_rule, jsx:encode(Val)};
convert_field({<<"specialist_id">>, Val}) -> {specialist_id, Val}; convert_field({<<"specialist_id">>, Val}) -> {specialist_id, Val};
convert_field({<<"location">>, Val}) when is_map(Val) -> convert_field({<<"location">>, Val}) when is_map(Val) ->
Loc = #location{ Loc = #location{
address = maps:get(<<"address">>, Val, undefined), address = maps:get(<<"address">>, Val, undefined),
lat = maps:get(<<"lat">>, Val, undefined), lat = maps:get(<<"lat">>, Val, undefined),
lon = maps:get(<<"lon">>, Val, undefined) lon = maps:get(<<"lon">>, Val, undefined)
}, },
{location, Loc}; {location, Loc};
convert_field({<<"location">>, Val}) -> {location, Val}; convert_field({<<"location">>, Val}) -> {location, Val};
convert_field({<<"tags">>, Val}) -> {tags, Val}; convert_field({<<"tags">>, Val}) -> {tags, Val};
convert_field({<<"capacity">>, Val}) -> {capacity, Val}; convert_field({<<"capacity">>, Val}) -> {capacity, Val};
convert_field({<<"online_link">>, Val}) -> {online_link, Val}; convert_field({<<"online_link">>, Val}) -> {online_link, Val};
convert_field({<<"status">>, Val}) -> convert_field({<<"status">>, Val}) ->
try binary_to_existing_atom(Val, utf8) of try binary_to_existing_atom(Val, utf8) of
Atom -> {status, Atom} Atom -> {status, Atom}
catch catch
error:badarg -> {status, Val} error:badarg -> {status, Val}
end; end;
convert_field(Other) -> Other. convert_field(Other) -> Other.
+62 -79
View File
@@ -1,90 +1,76 @@
-module(admin_handler_events). -module(admin_handler_events).
-behaviour(cowboy_handler). -behaviour(cowboy_handler).
-export([init/2]). -export([init/2]).
-export([trails/0]). -export([trails/0]).
-include("records.hrl"). -include("records.hrl").
%%%===================================================================
%%% cowboy_handler callback %%% cowboy_handler callback
%%%===================================================================
init(Req, _Opts) -> init(Req, _Opts) ->
case cowboy_req:method(Req) of case cowboy_req:method(Req) of
<<"GET">> -> list_all_events(Req); <<"GET">> -> list_all_events(Req);
_ -> handler_utils:send_error(Req, 405, <<"Method not allowed">>) _ -> handler_utils:send_error(Req, 405, <<"Method not allowed">>)
end. end.
%%%===================================================================
%%% Swagger metadata %%% Swagger metadata
%%%===================================================================
trails() -> trails() ->
[ [ #{ path => <<"/v1/admin/events">>,
#{ method => <<"GET">>,
path => <<"/v1/admin/events">>, description => <<"Search and list events (admin)">>,
method => <<"GET">>, tags => [<<"Events">>],
description => <<"Search and list events (admin)">>, parameters => [
tags => [<<"Events">>], #{name => <<"from">>, in => <<"query">>, description => <<"ISO8601 start datetime">>, required => false, schema => #{type => string}},
parameters => [ #{name => <<"to">>, in => <<"query">>, description => <<"ISO8601 end datetime">>, required => false, schema => #{type => string}},
#{name => <<"from">>, in => <<"query">>, description => <<"ISO8601 start datetime">>, required => false, schema => #{type => string}}, #{name => <<"status">>, in => <<"query">>, description => <<"active, cancelled, completed, or all">>, required => false, schema => #{type => string}},
#{name => <<"to">>, in => <<"query">>, description => <<"ISO8601 end datetime">>, required => false, schema => #{type => string}}, #{name => <<"calendar_id">>, in => <<"query">>, description => <<"Filter by calendar ID">>, required => false, schema => #{type => string}},
#{name => <<"status">>, in => <<"query">>, description => <<"active, cancelled, completed, or all">>, required => false, schema => #{type => string}}, #{name => <<"title">>, in => <<"query">>, description => <<"Exact title match">>, required => false, schema => #{type => string}},
#{name => <<"calendar_id">>, in => <<"query">>, description => <<"Filter by calendar ID">>, required => false, schema => #{type => string}}, #{name => <<"q">>, in => <<"query">>, description => <<"Substring search in title/description">>, required => false, schema => #{type => string}},
#{name => <<"title">>, in => <<"query">>, description => <<"Exact title match">>, required => false, schema => #{type => string}}, #{name => <<"limit">>, in => <<"query">>, description => <<"Page size (max 200)">>, required => false, schema => #{type => integer}},
#{name => <<"q">>, in => <<"query">>, description => <<"Substring search in title/description">>, required => false, schema => #{type => string}}, #{name => <<"offset">>, in => <<"query">>, description => <<"Offset">>, required => false, schema => #{type => integer}},
#{name => <<"limit">>, in => <<"query">>, description => <<"Page size (max 200)">>, required => false, schema => #{type => integer}}, #{name => <<"sort">>, in => <<"query">>, description => <<"created_at, start_time, title, status">>, required => false, schema => #{type => string}},
#{name => <<"offset">>, in => <<"query">>, description => <<"Offset">>, required => false, schema => #{type => integer}}, #{name => <<"order">>, in => <<"query">>, description => <<"asc or desc">>, required => false, schema => #{type => string, enum => [<<"asc">>, <<"desc">>]}}
#{name => <<"sort">>, in => <<"query">>, description => <<"created_at, start_time, title, status">>, required => false, schema => #{type => string}}, ],
#{name => <<"order">>, in => <<"query">>, description => <<"asc or desc">>, required => false, schema => #{type => string, enum => [<<"asc">>, <<"desc">>]}} responses => #{
], 200 => #{
responses => #{ description => <<"Array of events with Content-Range header">>,
200 => #{ content => #{<<"application/json">> => #{schema => #{ type => array, items => event_schema() }}}
description => <<"Array of events with Content-Range header">>, },
content => #{<<"application/json">> => #{schema => #{ 405 => #{description => <<"Method not allowed">>}
type => array,
items => event_schema()
}}}
},
405 => #{description => <<"Method not allowed">>}
}
} }
}
]. ].
event_schema() -> event_schema() ->
#{ #{
type => object, type => object,
properties => #{ properties => #{
id => #{type => string}, id => #{type => string},
calendar_id => #{type => string}, calendar_id => #{type => string},
title => #{type => string}, title => #{type => string},
description => #{type => string}, description => #{type => string},
event_type => #{type => string, enum => [<<"single">>, <<"recurring">>]}, event_type => #{type => string, enum => [<<"single">>, <<"recurring">>]},
start_time => #{type => string, format => <<"date-time">>}, start_time => #{type => string, format => <<"date-time">>},
duration => #{type => integer}, duration => #{type => integer},
recurrence => #{type => object, nullable => true}, recurrence => #{type => object, nullable => true},
master_id => #{type => string, nullable => true}, master_id => #{type => string, nullable => true},
is_instance => #{type => boolean}, is_instance => #{type => boolean},
specialist_id => #{type => string, nullable => true}, specialist_id => #{type => string, nullable => true},
location => #{type => object, nullable => true}, location => #{type => object, nullable => true},
tags => #{type => array, items => #{type => string}}, tags => #{type => array, items => #{type => string}},
capacity => #{type => integer, nullable => true}, capacity => #{type => integer, nullable => true},
online_link => #{type => string, nullable => true}, online_link => #{type => string, nullable => true},
status => #{type => string, enum => [<<"active">>, <<"cancelled">>, <<"completed">>]}, status => #{type => string, enum => [<<"active">>, <<"cancelled">>, <<"completed">>]},
reason => #{type => string, nullable => true}, reason => #{type => string, nullable => true},
rating_avg => #{type => number, format => float}, rating_avg => #{type => number, format => float},
rating_count => #{type => integer}, rating_count => #{type => integer},
attachments => #{type => array, items => #{type => string}, nullable => true}, attachments => #{type => array, items => #{type => string}, nullable => true},
edit_history => #{type => array, items => #{type => object}, nullable => true}, edit_history => #{type => array, items => #{type => object}, nullable => true},
created_at => #{type => string, format => <<"date-time">>}, created_at => #{type => string, format => <<"date-time">>},
updated_at => #{type => string, format => <<"date-time">>} updated_at => #{type => string, format => <<"date-time">>}
} }
}. }.
%%%===================================================================
%%% Internal functions %%% Internal functions
%%%===================================================================
list_all_events(Req) -> list_all_events(Req) ->
case handler_utils:auth_admin(Req) of case handler_utils:auth_admin(Req) of
@@ -92,15 +78,12 @@ list_all_events(Req) ->
Params = parse_admin_event_search(Req1), Params = parse_admin_event_search(Req1),
{ok, Total, Events} = logic_event:search_events(Params), {ok, Total, Events} = logic_event:search_events(Params),
Json = [handler_utils:event_to_json(E) || E <- Events], Json = [handler_utils:event_to_json(E) || E <- Events],
Limit = maps:get(limit, Params, 50), % Используем общую функцию пагинации
Offset = maps:get(offset, Params, 0), Pagination = #{
RangeEnd = min(Offset + Limit - 1, Total - 1), limit => maps:get(limit, Params, 50),
Headers = #{ offset => maps:get(offset, Params, 0)
<<"content-type">> => <<"application/json">>,
<<"content-range">> => iolist_to_binary(io_lib:format("items ~B-~B/~B", [Offset, RangeEnd, Total])),
<<"x-total-count">> => integer_to_binary(Total),
<<"access-control-expose-headers">> => <<"Content-Range, X-Total-Count">>
}, },
Headers = handler_utils:pagination_headers(Pagination, Total),
handler_utils:send_json(Req1, 200, Json, Headers); handler_utils:send_json(Req1, 200, Json, Headers);
{error, Code, Msg, Req1} -> {error, Code, Msg, Req1} ->
handler_utils:send_error(Req1, Code, Msg) handler_utils:send_error(Req1, Code, Msg)
@@ -109,14 +92,14 @@ list_all_events(Req) ->
parse_admin_event_search(Req) -> parse_admin_event_search(Req) ->
Qs = cowboy_req:parse_qs(Req), Qs = cowboy_req:parse_qs(Req),
#{ #{
from => handler_utils:parse_datetime_qs(proplists:get_value(<<"from">>, Qs)), from => handler_utils:parse_datetime_qs(proplists:get_value(<<"from">>, Qs)),
to => handler_utils:parse_datetime_qs(proplists:get_value(<<"to">>, Qs)), to => handler_utils:parse_datetime_qs(proplists:get_value(<<"to">>, Qs)),
status => proplists:get_value(<<"status">>, Qs, undefined), status => proplists:get_value(<<"status">>, Qs, undefined),
calendar_id => proplists:get_value(<<"calendar_id">>, Qs, undefined), calendar_id => proplists:get_value(<<"calendar_id">>, Qs, undefined),
title => proplists:get_value(<<"title">>, Qs, undefined), title => proplists:get_value(<<"title">>, Qs, undefined),
q => proplists:get_value(<<"q">>, Qs, undefined), q => proplists:get_value(<<"q">>, Qs, undefined),
limit => handler_utils:parse_int_qs(proplists:get_value(<<"limit">>, Qs), 50), limit => handler_utils:parse_int_qs(proplists:get_value(<<"limit">>, Qs), 50),
offset => handler_utils:parse_int_qs(proplists:get_value(<<"offset">>, Qs), 0), offset => handler_utils:parse_int_qs(proplists:get_value(<<"offset">>, Qs), 0),
sort => proplists:get_value(<<"sort">>, Qs, <<"created_at">>), sort => proplists:get_value(<<"sort">>, Qs, <<"created_at">>),
order => proplists:get_value(<<"order">>, Qs, <<"desc">>) order => proplists:get_value(<<"order">>, Qs, <<"desc">>)
}. }.
+22 -23
View File
@@ -5,7 +5,6 @@
%%%------------------------------------------------------------------- %%%-------------------------------------------------------------------
-module(admin_handler_login). -module(admin_handler_login).
-behaviour(cowboy_handler). -behaviour(cowboy_handler).
-export([init/2]). -export([init/2]).
-export([trails/0]). -export([trails/0]).
@@ -25,6 +24,8 @@ init(Req0, _State) ->
{RefreshToken, _ExpiresAt} = eventhub_auth:generate_refresh_token(UserId), {RefreshToken, _ExpiresAt} = eventhub_auth:generate_refresh_token(UserId),
core_admin_session:create(UserId, RefreshToken), core_admin_session:create(UserId, RefreshToken),
core_admin:update_last_login(UserId), core_admin:update_last_login(UserId),
% Аудит успешного входа
admin_utils:log_admin_action(UserId, <<"login">>, <<"admin">>, Email, Req1),
Resp = #{ Resp = #{
<<"token">> => Token, <<"token">> => Token,
<<"user">> => #{ <<"user">> => #{
@@ -57,31 +58,29 @@ init(Req0, _State) ->
%%% Swagger metadata %%% Swagger metadata
-spec trails() -> [map()]. -spec trails() -> [map()].
trails() -> trails() ->
[ [ #{ path => <<"/v1/admin/login">>,
#{ method => <<"POST">>,
path => <<"/v1/admin/login">>, description => <<"Admin login">>,
method => <<"POST">>, tags => [<<"Auth">>],
description => <<"Admin login">>, requestBody => #{
tags => [<<"Auth">>], required => true,
requestBody => #{ content => #{
required => true, <<"application/json">> => #{
content => #{ schema => #{
<<"application/json">> => #{ type => object,
schema => #{ required => [<<"email">>, <<"password">>],
type => object, properties => #{
required => [<<"email">>, <<"password">>], email => #{type => string, format => <<"email">>},
properties => #{ password => #{type => string, format => <<"password">>}
email => #{type => string, format => <<"email">>},
password => #{type => string, format => <<"password">>}
}
} }
} }
} }
},
responses => #{
200 => #{description => <<"Login successful, returns token and user info">>},
401 => #{description => <<"Invalid credentials">>},
403 => #{description => <<"Insufficient permissions">>}
} }
},
responses => #{
200 => #{description => <<"Login successful, returns token and user info">>},
401 => #{description => <<"Invalid credentials">>},
403 => #{description => <<"Insufficient permissions">>}
} }
}
]. ].
+32 -50
View File
@@ -6,7 +6,6 @@
%%%------------------------------------------------------------------- %%%-------------------------------------------------------------------
-module(admin_handler_me). -module(admin_handler_me).
-behaviour(cowboy_handler). -behaviour(cowboy_handler).
-export([init/2]). -export([init/2]).
-export([trails/0]). -export([trails/0]).
@@ -18,33 +17,33 @@ init(Req, _Opts) ->
case cowboy_req:method(Req) of case cowboy_req:method(Req) of
<<"GET">> -> get_me(Req); <<"GET">> -> get_me(Req);
<<"PUT">> -> update_me(Req); <<"PUT">> -> update_me(Req);
_ -> handler_utils:send_error(Req, 405, <<"Method not allowed">>) _ -> handler_utils:send_error(Req, 405, <<"Method not allowed">>)
end. end.
%%% Swagger metadata %%% Swagger metadata
-spec trails() -> [map()]. -spec trails() -> [map()].
trails() -> trails() ->
[ [
#{ % GET #{ % GET
path => <<"/v1/admin/me">>, path => <<"/v1/admin/me">>,
method => <<"GET">>, method => <<"GET">>,
description => <<"Get current admin profile">>, description => <<"Get current admin profile">>,
tags => [<<"Admins">>], tags => [<<"Admins">>],
responses => #{ responses => #{
200 => #{ 200 => #{
description => <<"Admin profile">>, description => <<"Admin profile">>,
content => #{<<"application/json">> => #{schema => admin_schema()}} content => #{<<"application/json">> => #{schema => admin_schema()}}
} }
} }
}, },
#{ % PUT #{ % PUT
path => <<"/v1/admin/me">>, path => <<"/v1/admin/me">>,
method => <<"PUT">>, method => <<"PUT">>,
description => <<"Update current admin profile">>, description => <<"Update current admin profile">>,
tags => [<<"Admins">>], tags => [<<"Admins">>],
requestBody => #{ requestBody => #{
required => true, required => true,
content => #{<<"application/json">> => #{schema => admin_update_schema()}} content => #{<<"application/json">> => #{schema => admin_update_schema()}}
}, },
responses => #{ responses => #{
200 => #{description => <<"Updated profile">>} 200 => #{description => <<"Updated profile">>}
@@ -55,28 +54,28 @@ trails() ->
-spec admin_schema() -> map(). -spec admin_schema() -> map().
admin_schema() -> admin_schema() ->
#{ #{
type => object, type => object,
properties => #{ properties => #{
id => #{type => string}, id => #{type => string},
email => #{type => string}, email => #{type => string},
role => #{type => string}, role => #{type => string},
status => #{type => string}, status => #{type => string},
nickname => #{type => string, nullable => true}, nickname => #{type => string, nullable => true},
avatar_url => #{type => string, nullable => true}, avatar_url => #{type => string, nullable => true},
timezone => #{type => string, nullable => true}, timezone => #{type => string, nullable => true},
language => #{type => string, nullable => true}, language => #{type => string, nullable => true},
phone => #{type => string, nullable => true}, phone => #{type => string, nullable => true},
preferences => #{type => object, nullable => true}, preferences => #{type => object, nullable => true},
last_login => #{type => string, format => <<"date-time">>}, last_login => #{type => string, format => <<"date-time">>},
created_at => #{type => string, format => <<"date-time">>}, created_at => #{type => string, format => <<"date-time">>},
updated_at => #{type => string, format => <<"date-time">>} updated_at => #{type => string, format => <<"date-time">>}
} }
}. }.
-spec admin_update_schema() -> map(). -spec admin_update_schema() -> map().
admin_update_schema() -> admin_update_schema() ->
#{ #{
type => object, type => object,
properties => #{ properties => #{
nickname => #{type => string}, nickname => #{type => string},
avatar_url => #{type => string}, avatar_url => #{type => string},
@@ -95,7 +94,7 @@ get_me(Req) ->
{ok, AdminId, Req1} -> {ok, AdminId, Req1} ->
case logic_admin:get_admin(AdminId) of case logic_admin:get_admin(AdminId) of
{ok, Admin} -> {ok, Admin} ->
handler_utils:send_json(Req1, 200, admin_to_json(Admin)); handler_utils:send_json(Req1, 200, handler_utils:admin_to_json(Admin));
{error, not_found} -> {error, not_found} ->
handler_utils:send_error(Req1, 404, <<"Admin not found">>); handler_utils:send_error(Req1, 404, <<"Admin not found">>);
{error, _} -> {error, _} ->
@@ -116,7 +115,9 @@ update_me(Req) ->
Updates = convert_admin_fields(Updates0), Updates = convert_admin_fields(Updates0),
case logic_admin:update_admin(AdminId, Updates) of case logic_admin:update_admin(AdminId, Updates) of
{ok, Admin} -> {ok, Admin} ->
handler_utils:send_json(Req2, 200, admin_to_json(Admin)); % Аудит обновления профиля
admin_utils:log_admin_action(AdminId, <<"update_me">>, <<"admin">>, AdminId, Req2),
handler_utils:send_json(Req2, 200, handler_utils:admin_to_json(Admin));
{error, not_found} -> {error, not_found} ->
handler_utils:send_error(Req2, 404, <<"Admin not found">>); handler_utils:send_error(Req2, 404, <<"Admin not found">>);
{error, _} -> {error, _} ->
@@ -142,23 +143,4 @@ convert_admin_fields(Updates) ->
({<<"phone">>, V}) -> {phone, V}; ({<<"phone">>, V}) -> {phone, V};
({<<"preferences">>, V}) -> {preferences, V}; ({<<"preferences">>, V}) -> {preferences, V};
(Other) -> Other (Other) -> Other
end, Updates). end, Updates).
%% @private Формирует JSON-представление администратора.
-spec admin_to_json(#admin{}) -> map().
admin_to_json(Admin) ->
#{
id => Admin#admin.id,
email => Admin#admin.email,
role => Admin#admin.role,
status => Admin#admin.status,
nickname => Admin#admin.nickname,
avatar_url => Admin#admin.avatar_url,
timezone => Admin#admin.timezone,
language => Admin#admin.language,
phone => Admin#admin.phone,
preferences => Admin#admin.preferences,
last_login => handler_utils:datetime_to_iso8601(Admin#admin.last_login),
created_at => handler_utils:datetime_to_iso8601(Admin#admin.created_at),
updated_at => handler_utils:datetime_to_iso8601(Admin#admin.updated_at)
}.
+25 -50
View File
@@ -5,7 +5,6 @@
%%%------------------------------------------------------------------- %%%-------------------------------------------------------------------
-module(admin_handler_moderation). -module(admin_handler_moderation).
-behaviour(cowboy_handler). -behaviour(cowboy_handler).
-export([init/2]). -export([init/2]).
-export([trails/0]). -export([trails/0]).
@@ -18,7 +17,7 @@
init(Req, _Opts) -> init(Req, _Opts) ->
case cowboy_req:method(Req) of case cowboy_req:method(Req) of
<<"PUT">> -> moderate(Req); <<"PUT">> -> moderate(Req);
_ -> handler_utils:send_error(Req, 405, <<"Method not allowed">>) _ -> handler_utils:send_error(Req, 405, <<"Method not allowed">>)
end. end.
%%% Swagger metadata %%% Swagger metadata
@@ -27,40 +26,28 @@ trails() ->
Targets = [<<"calendar">>, <<"event">>, <<"review">>, <<"user">>], Targets = [<<"calendar">>, <<"event">>, <<"review">>, <<"user">>],
Actions = #{ Actions = #{
<<"calendar">> => [<<"freeze">>, <<"unfreeze">>], <<"calendar">> => [<<"freeze">>, <<"unfreeze">>],
<<"event">> => [<<"freeze">>, <<"unfreeze">>], <<"event">> => [<<"freeze">>, <<"unfreeze">>],
<<"review">> => [<<"hide">>, <<"unhide">>], <<"review">> => [<<"hide">>, <<"unhide">>],
<<"user">> => [<<"block">>, <<"unblock">>] <<"user">> => [<<"block">>, <<"unblock">>]
}, },
lists:flatmap(fun(Target) -> lists:flatmap(fun(Target) ->
ActionList = maps:get(Target, Actions), ActionList = maps:get(Target, Actions),
lists:map(fun(Action) -> lists:map(fun(Action) ->
Path = <<"/v1/admin/", Target/binary, "/:id">>, Path = <<"/v1/admin/", Target/binary, "/:id">>,
#{ #{
path => Path, path => Path,
method => <<"PUT">>, method => <<"PUT">>,
description => <<"Moderate ", Target/binary, " - ", Action/binary>>, description => <<"Moderate ", Target/binary, " - ", Action/binary>>,
tags => [<<"Moderation">>], tags => [<<"Moderation">>],
parameters => [ parameters => [
#{ #{ name => <<"target_type">>, in => <<"path">>, description => <<"Entity type">>, required => true, schema => #{type => string, enum => Targets} },
name => <<"target_type">>, #{ name => <<"id">>, in => <<"path">>, description => <<"Entity ID">>, required => true, schema => #{type => string} }
in => <<"path">>,
description => <<"Entity type">>,
required => true,
schema => #{type => string, enum => Targets}
},
#{
name => <<"id">>,
in => <<"path">>,
description => <<"Entity ID">>,
required => true,
schema => #{type => string}
}
], ],
requestBody => #{ requestBody => #{
required => true, required => true,
content => #{<<"application/json">> => #{schema => #{ content => #{<<"application/json">> => #{schema => #{
type => object, type => object,
required => [<<"action">>], required => [<<"action">>],
properties => #{ properties => #{
action => #{type => string, enum => ActionList}, action => #{type => string, enum => ActionList},
reason => #{type => string} reason => #{type => string}
@@ -73,8 +60,8 @@ trails() ->
404 => #{description => <<"Entity not found">>} 404 => #{description => <<"Entity not found">>}
} }
} }
end, ActionList) end, ActionList)
end, Targets). end, Targets).
%%% Internal functions %%% Internal functions
@@ -82,7 +69,7 @@ moderate(Req) ->
case handler_utils:auth_admin(Req) of case handler_utils:auth_admin(Req) of
{ok, AdminId, Req1} -> {ok, AdminId, Req1} ->
TargetType = cowboy_req:binding(target_type, Req1), TargetType = cowboy_req:binding(target_type, Req1),
TargetId = cowboy_req:binding(id, Req1), TargetId = cowboy_req:binding(id, Req1),
case lists:member(TargetType, ?VALID_TARGETS) of case lists:member(TargetType, ?VALID_TARGETS) of
true -> true ->
{ok, Body, Req2} = cowboy_req:read_body(Req1), {ok, Body, Req2} = cowboy_req:read_body(Req1),
@@ -114,7 +101,7 @@ apply_moderation(<<"user">>, Id, Action, Reason, Req, AdminId) ->
handle_calendar(Id, <<"freeze">>, Reason, Req, AdminId) -> handle_calendar(Id, <<"freeze">>, Reason, Req, AdminId) ->
case core_calendar:freeze(Id, Reason) of case core_calendar:freeze(Id, Reason) of
{ok, Calendar} -> {ok, Calendar} ->
log_audit(AdminId, <<"freeze_calendar">>, <<"calendar">>, Id, Reason), admin_utils:log_admin_action(AdminId, <<"freeze_calendar">>, <<"calendar">>, #{id => Id, reason => Reason}, Req),
handler_utils:send_json(Req, 200, handler_utils:calendar_to_json(Calendar)); handler_utils:send_json(Req, 200, handler_utils:calendar_to_json(Calendar));
{error, not_found} -> {error, not_found} ->
handler_utils:send_error(Req, 404, <<"Calendar not found">>) handler_utils:send_error(Req, 404, <<"Calendar not found">>)
@@ -122,7 +109,7 @@ handle_calendar(Id, <<"freeze">>, Reason, Req, AdminId) ->
handle_calendar(Id, <<"unfreeze">>, Reason, Req, AdminId) -> handle_calendar(Id, <<"unfreeze">>, Reason, Req, AdminId) ->
case core_calendar:unfreeze(Id, Reason) of case core_calendar:unfreeze(Id, Reason) of
{ok, Calendar} -> {ok, Calendar} ->
log_audit(AdminId, <<"unfreeze_calendar">>, <<"calendar">>, Id, Reason), admin_utils:log_admin_action(AdminId, <<"unfreeze_calendar">>, <<"calendar">>, #{id => Id, reason => Reason}, Req),
handler_utils:send_json(Req, 200, handler_utils:calendar_to_json(Calendar)); handler_utils:send_json(Req, 200, handler_utils:calendar_to_json(Calendar));
{error, not_found} -> {error, not_found} ->
handler_utils:send_error(Req, 404, <<"Calendar not found">>) handler_utils:send_error(Req, 404, <<"Calendar not found">>)
@@ -133,7 +120,7 @@ handle_calendar(_Id, _Action, _Reason, Req, _AdminId) ->
handle_event(Id, <<"freeze">>, Reason, Req, AdminId) -> handle_event(Id, <<"freeze">>, Reason, Req, AdminId) ->
case core_event:freeze(Id, Reason) of case core_event:freeze(Id, Reason) of
{ok, Event} -> {ok, Event} ->
log_audit(AdminId, <<"freeze_event">>, <<"event">>, Id, Reason), admin_utils:log_admin_action(AdminId, <<"freeze_event">>, <<"event">>, #{id => Id, reason => Reason}, Req),
handler_utils:send_json(Req, 200, handler_utils:event_to_json(Event)); handler_utils:send_json(Req, 200, handler_utils:event_to_json(Event));
{error, not_found} -> {error, not_found} ->
handler_utils:send_error(Req, 404, <<"Event not found">>) handler_utils:send_error(Req, 404, <<"Event not found">>)
@@ -141,7 +128,7 @@ handle_event(Id, <<"freeze">>, Reason, Req, AdminId) ->
handle_event(Id, <<"unfreeze">>, Reason, Req, AdminId) -> handle_event(Id, <<"unfreeze">>, Reason, Req, AdminId) ->
case core_event:unfreeze(Id, Reason) of case core_event:unfreeze(Id, Reason) of
{ok, Event} -> {ok, Event} ->
log_audit(AdminId, <<"unfreeze_event">>, <<"event">>, Id, Reason), admin_utils:log_admin_action(AdminId, <<"unfreeze_event">>, <<"event">>, #{id => Id, reason => Reason}, Req),
handler_utils:send_json(Req, 200, handler_utils:event_to_json(Event)); handler_utils:send_json(Req, 200, handler_utils:event_to_json(Event));
{error, not_found} -> {error, not_found} ->
handler_utils:send_error(Req, 404, <<"Event not found">>) handler_utils:send_error(Req, 404, <<"Event not found">>)
@@ -152,7 +139,7 @@ handle_event(_Id, _Action, _Reason, Req, _AdminId) ->
handle_review(Id, <<"hide">>, Reason, Req, AdminId) -> handle_review(Id, <<"hide">>, Reason, Req, AdminId) ->
case core_review:hide(Id, Reason) of case core_review:hide(Id, Reason) of
{ok, Review} -> {ok, Review} ->
log_audit(AdminId, <<"hide_review">>, <<"review">>, Id, Reason), admin_utils:log_admin_action(AdminId, <<"hide_review">>, <<"review">>, #{id => Id, reason => Reason}, Req),
handler_utils:send_json(Req, 200, handler_utils:review_to_json(Review)); handler_utils:send_json(Req, 200, handler_utils:review_to_json(Review));
{error, not_found} -> {error, not_found} ->
handler_utils:send_error(Req, 404, <<"Review not found">>) handler_utils:send_error(Req, 404, <<"Review not found">>)
@@ -160,7 +147,7 @@ handle_review(Id, <<"hide">>, Reason, Req, AdminId) ->
handle_review(Id, <<"unhide">>, Reason, Req, AdminId) -> handle_review(Id, <<"unhide">>, Reason, Req, AdminId) ->
case core_review:unhide(Id, Reason) of case core_review:unhide(Id, Reason) of
{ok, Review} -> {ok, Review} ->
log_audit(AdminId, <<"unhide_review">>, <<"review">>, Id, Reason), admin_utils:log_admin_action(AdminId, <<"unhide_review">>, <<"review">>, #{id => Id, reason => Reason}, Req),
handler_utils:send_json(Req, 200, handler_utils:review_to_json(Review)); handler_utils:send_json(Req, 200, handler_utils:review_to_json(Review));
{error, not_found} -> {error, not_found} ->
handler_utils:send_error(Req, 404, <<"Review not found">>) handler_utils:send_error(Req, 404, <<"Review not found">>)
@@ -171,7 +158,7 @@ handle_review(_Id, _Action, _Reason, Req, _AdminId) ->
handle_user(Id, <<"block">>, Reason, Req, AdminId) -> handle_user(Id, <<"block">>, Reason, Req, AdminId) ->
case core_user:block(Id, Reason) of case core_user:block(Id, Reason) of
{ok, User} -> {ok, User} ->
log_audit(AdminId, <<"block_user">>, <<"user">>, Id, Reason), admin_utils:log_admin_action(AdminId, <<"block_user">>, <<"user">>, #{id => Id, reason => Reason}, Req),
handler_utils:send_json(Req, 200, handler_utils:user_to_json(User)); handler_utils:send_json(Req, 200, handler_utils:user_to_json(User));
{error, not_found} -> {error, not_found} ->
handler_utils:send_error(Req, 404, <<"User not found">>) handler_utils:send_error(Req, 404, <<"User not found">>)
@@ -179,22 +166,10 @@ handle_user(Id, <<"block">>, Reason, Req, AdminId) ->
handle_user(Id, <<"unblock">>, Reason, Req, AdminId) -> handle_user(Id, <<"unblock">>, Reason, Req, AdminId) ->
case core_user:unblock(Id, Reason) of case core_user:unblock(Id, Reason) of
{ok, User} -> {ok, User} ->
log_audit(AdminId, <<"unblock_user">>, <<"user">>, Id, Reason), admin_utils:log_admin_action(AdminId, <<"unblock_user">>, <<"user">>, #{id => Id, reason => Reason}, Req),
handler_utils:send_json(Req, 200, handler_utils:user_to_json(User)); handler_utils:send_json(Req, 200, handler_utils:user_to_json(User));
{error, not_found} -> {error, not_found} ->
handler_utils:send_error(Req, 404, <<"User not found">>) handler_utils:send_error(Req, 404, <<"User not found">>)
end; end;
handle_user(_Id, _Action, _Reason, Req, _AdminId) -> handle_user(_Id, _Action, _Reason, Req, _AdminId) ->
handler_utils:send_error(Req, 400, <<"Invalid action for user">>). handler_utils:send_error(Req, 400, <<"Invalid action for user">>).
%% ── АУДИТ ──────────────────────────────────────────────────
log_audit(AdminId, Action, EntityType, EntityId, Reason) ->
case core_admin:get_by_id(AdminId) of
{ok, Admin} ->
core_admin_audit:log(AdminId, Admin#admin.email, Admin#admin.role,
Action, EntityType, EntityId, client_ip(), Reason);
_ -> ok
end.
%% ── ВСПОМОГАТЕЛЬНЫЕ ФУНКЦИИ ────────────────────────────────
client_ip() -> <<"127.0.0.1">>.
@@ -6,7 +6,6 @@
%%%------------------------------------------------------------------- %%%-------------------------------------------------------------------
-module(admin_handler_report_by_id). -module(admin_handler_report_by_id).
-behaviour(cowboy_handler). -behaviour(cowboy_handler).
-export([init/2]). -export([init/2]).
-export([trails/0]). -export([trails/0]).
@@ -17,44 +16,35 @@ init(Req, _Opts) ->
case cowboy_req:method(Req) of case cowboy_req:method(Req) of
<<"GET">> -> get_report(Req); <<"GET">> -> get_report(Req);
<<"PUT">> -> update_report(Req); <<"PUT">> -> update_report(Req);
_ -> handler_utils:send_error(Req, 405, <<"Method not allowed">>) _ -> handler_utils:send_error(Req, 405, <<"Method not allowed">>)
end. end.
-spec trails() -> [map()]. -spec trails() -> [map()].
trails() -> trails() ->
BaseParams = [ BaseParams = [
#{ #{ name => <<"id">>, in => <<"path">>, description => <<"Report ID">>, required => true, schema => #{type => string} }
name => <<"id">>,
in => <<"path">>,
description => <<"Report ID">>,
required => true,
schema => #{type => string}
}
], ],
[ [
#{ % GET #{ % GET
path => <<"/v1/admin/reports/:id">>, path => <<"/v1/admin/reports/:id">>,
method => <<"GET">>, method => <<"GET">>,
description => <<"Get report by ID (admin)">>, description => <<"Get report by ID (admin)">>,
tags => [<<"Reports">>], tags => [<<"Reports">>],
parameters => BaseParams, parameters => BaseParams,
responses => #{ responses => #{
200 => #{ 200 => #{ description => <<"Report details">>, content => #{<<"application/json">> => #{schema => report_schema()}} },
description => <<"Report details">>,
content => #{<<"application/json">> => #{schema => report_schema()}}
},
404 => #{description => <<"Report not found">>} 404 => #{description => <<"Report not found">>}
} }
}, },
#{ % PUT #{ % PUT
path => <<"/v1/admin/reports/:id">>, path => <<"/v1/admin/reports/:id">>,
method => <<"PUT">>, method => <<"PUT">>,
description => <<"Update report status (admin)">>, description => <<"Update report status (admin)">>,
tags => [<<"Reports">>], tags => [<<"Reports">>],
parameters => BaseParams, parameters => BaseParams,
requestBody => #{ requestBody => #{
required => true, required => true,
content => #{<<"application/json">> => #{schema => report_update_schema()}} content => #{<<"application/json">> => #{schema => report_update_schema()}}
}, },
responses => #{ responses => #{
200 => #{description => <<"Updated report">>}, 200 => #{description => <<"Updated report">>},
@@ -65,15 +55,15 @@ trails() ->
report_schema() -> report_schema() ->
#{ #{
type => object, type => object,
properties => #{ properties => #{
id => #{type => string}, id => #{type => string},
reporter_id => #{type => string}, reporter_id => #{type => string},
target_type => #{type => string, enum => [<<"calendar">>, <<"event">>, <<"review">>]}, target_type => #{type => string, enum => [<<"calendar">>, <<"event">>, <<"review">>]},
target_id => #{type => string}, target_id => #{type => string},
reason => #{type => string}, reason => #{type => string},
status => #{type => string, enum => [<<"pending">>, <<"reviewed">>, <<"dismissed">>]}, status => #{type => string, enum => [<<"pending">>, <<"reviewed">>, <<"dismissed">>]},
created_at => #{type => string, format => <<"date-time">>}, created_at => #{type => string, format => <<"date-time">>},
resolved_at => #{type => string, format => <<"date-time">>, nullable => true}, resolved_at => #{type => string, format => <<"date-time">>, nullable => true},
resolved_by => #{type => string, nullable => true} resolved_by => #{type => string, nullable => true}
} }
@@ -81,7 +71,7 @@ report_schema() ->
report_update_schema() -> report_update_schema() ->
#{ #{
type => object, type => object,
properties => #{ properties => #{
status => #{type => string, enum => [<<"reviewed">>, <<"dismissed">>]} status => #{type => string, enum => [<<"reviewed">>, <<"dismissed">>]}
} }
@@ -114,6 +104,8 @@ update_report(Req) ->
#{<<"status">> := Status} -> #{<<"status">> := Status} ->
case logic_report:update_report_status(AdminId, ReportId, Status) of case logic_report:update_report_status(AdminId, ReportId, Status) of
{ok, Report} -> {ok, Report} ->
% Аудит изменения статуса жалобы
admin_utils:log_admin_action(AdminId, <<"update_report_status">>, <<"report">>, ReportId, Req2),
handler_utils:send_json(Req2, 200, handler_utils:report_to_json(Report)); handler_utils:send_json(Req2, 200, handler_utils:report_to_json(Report));
{error, not_found} -> {error, not_found} ->
handler_utils:send_error(Req2, 404, <<"Report not found">>); handler_utils:send_error(Req2, 404, <<"Report not found">>);
+40 -47
View File
@@ -5,7 +5,6 @@
%%%------------------------------------------------------------------- %%%-------------------------------------------------------------------
-module(admin_handler_reports). -module(admin_handler_reports).
-behaviour(cowboy_handler). -behaviour(cowboy_handler).
-export([init/2]). -export([init/2]).
-export([trails/0]). -export([trails/0]).
@@ -15,47 +14,49 @@
init(Req, _Opts) -> init(Req, _Opts) ->
case cowboy_req:method(Req) of case cowboy_req:method(Req) of
<<"GET">> -> list_reports(Req); <<"GET">> -> list_reports(Req);
_ -> handler_utils:send_error(Req, 405, <<"Method not allowed">>) _ -> handler_utils:send_error(Req, 405, <<"Method not allowed">>)
end. end.
-spec trails() -> [map()]. -spec trails() -> [map()].
trails() -> trails() ->
[ [ #{ path => <<"/v1/admin/reports">>,
#{ method => <<"GET">>,
path => <<"/v1/admin/reports">>, description => <<"List all reports (admin)">>,
method => <<"GET">>, tags => [<<"Reports">>],
description => <<"List all reports (admin)">>, parameters => [
tags => [<<"Reports">>], #{name => <<"status">>, in => <<"query">>,
parameters => [ schema => #{type => string, enum => [<<"pending">>, <<"reviewed">>, <<"dismissed">>]},
#{name => <<"status">>, in => <<"query">>, schema => #{type => string, enum => [<<"pending">>, <<"reviewed">>, <<"dismissed">>]}, description => <<"Filter by status">>}, description => <<"Filter by status">>},
#{name => <<"target_type">>, in => <<"query">>, schema => #{type => string, enum => [<<"calendar">>, <<"event">>, <<"review">>]}, description => <<"Filter by target type">>}, #{name => <<"target_type">>, in => <<"query">>,
#{name => <<"q">>, in => <<"query">>, schema => #{type => string}, description => <<"Search in reason">>}, schema => #{type => string, enum => [<<"calendar">>, <<"event">>, <<"review">>]},
#{name => <<"limit">>, in => <<"query">>, schema => #{type => integer}, description => <<"Page size">>}, description => <<"Filter by target type">>},
#{name => <<"offset">>, in => <<"query">>, schema => #{type => integer}, description => <<"Offset">>} #{name => <<"q">>, in => <<"query">>, schema => #{type => string},
], description => <<"Search in reason">>},
responses => #{ #{name => <<"limit">>, in => <<"query">>, schema => #{type => integer},
200 => #{ description => <<"Page size">>},
description => <<"Array of reports">>, #{name => <<"offset">>, in => <<"query">>, schema => #{type => integer},
content => #{<<"application/json">> => #{schema => #{ description => <<"Offset">>}
type => array, ],
items => report_schema() responses => #{
}}} 200 => #{ description => <<"Array of reports">>,
} content => #{<<"application/json">> => #{schema => #{
} type => array,
items => report_schema()
}}} }
} }
}
]. ].
report_schema() -> report_schema() ->
#{ #{ type => object,
type => object,
properties => #{ properties => #{
id => #{type => string}, id => #{type => string},
reporter_id => #{type => string}, reporter_id => #{type => string},
target_type => #{type => string, enum => [<<"calendar">>, <<"event">>, <<"review">>]}, target_type => #{type => string, enum => [<<"calendar">>, <<"event">>, <<"review">>]},
target_id => #{type => string}, target_id => #{type => string},
reason => #{type => string}, reason => #{type => string},
status => #{type => string, enum => [<<"pending">>, <<"reviewed">>, <<"dismissed">>]}, status => #{type => string, enum => [<<"pending">>, <<"reviewed">>, <<"dismissed">>]},
created_at => #{type => string, format => <<"date-time">>}, created_at => #{type => string, format => <<"date-time">>},
resolved_at => #{type => string, format => <<"date-time">>, nullable => true}, resolved_at => #{type => string, format => <<"date-time">>, nullable => true},
resolved_by => #{type => string, nullable => true} resolved_by => #{type => string, nullable => true}
} }
@@ -74,9 +75,10 @@ list_reports(Req) ->
Filtered = apply_report_filters(AllReports, Filters), Filtered = apply_report_filters(AllReports, Filters),
Sorted = sort_reports(Filtered, Pagination), Sorted = sort_reports(Filtered, Pagination),
Total = length(Sorted), Total = length(Sorted),
Page = lists:sublist(Sorted, maps:get(offset, Pagination) + 1, maps:get(limit, Pagination)), Page = lists:sublist(Sorted, maps:get(offset, Pagination) + 1,
maps:get(limit, Pagination)),
Json = [handler_utils:report_to_json(R) || R <- Page], Json = [handler_utils:report_to_json(R) || R <- Page],
ExtraHeaders = pagination_headers(Pagination, Total), ExtraHeaders = handler_utils:pagination_headers(Pagination, Total),
handler_utils:send_json(Req1, 200, Json, ExtraHeaders); handler_utils:send_json(Req1, 200, Json, ExtraHeaders);
{error, access_denied} -> {error, access_denied} ->
handler_utils:send_error(Req1, 403, <<"Admin access required">>) handler_utils:send_error(Req1, 403, <<"Admin access required">>)
@@ -87,11 +89,9 @@ list_reports(Req) ->
parse_report_filters(Req) -> parse_report_filters(Req) ->
Qs = cowboy_req:parse_qs(Req), Qs = cowboy_req:parse_qs(Req),
#{ #{ status => proplists:get_value(<<"status">>, Qs),
status => proplists:get_value(<<"status">>, Qs),
target_type => proplists:get_value(<<"target_type">>, Qs), target_type => proplists:get_value(<<"target_type">>, Qs),
q => proplists:get_value(<<"q">>, Qs) q => proplists:get_value(<<"q">>, Qs) }.
}.
apply_report_filters(Reports, Filters) -> apply_report_filters(Reports, Filters) ->
Status = maps:get(status, Filters, undefined), Status = maps:get(status, Filters, undefined),
@@ -108,7 +108,8 @@ apply_report_filters(Reports, Filters) ->
case Q of case Q of
undefined -> F2; undefined -> F2;
_ -> [R || R <- F2, _ -> [R || R <- F2,
string:str(binary_to_list(R#report.reason), binary_to_list(Q)) > 0] string:str(binary_to_list(R#report.reason),
binary_to_list(Q)) > 0]
end. end.
sort_reports(Reports, #{sort := Sort, order := Order}) -> sort_reports(Reports, #{sort := Sort, order := Order}) ->
@@ -124,12 +125,4 @@ sort_reports(Reports, #{sort := Sort, order := Order}) ->
report_field(#report{created_at = V}, created_at) -> V; report_field(#report{created_at = V}, created_at) -> V;
report_field(#report{status = V}, status) -> V; report_field(#report{status = V}, status) -> V;
report_field(_, _) -> undefined. report_field(_, _) -> undefined.
pagination_headers(#{limit := Limit, offset := Offset}, Total) ->
RangeEnd = min(Offset + Limit - 1, Total - 1),
#{
<<"content-range">> => iolist_to_binary(io_lib:format("items ~B-~B/~B", [Offset, RangeEnd, Total])),
<<"x-total-count">> => integer_to_binary(Total),
<<"access-control-expose-headers">> => <<"Content-Range, X-Total-Count">>
}.
+59 -72
View File
@@ -1,6 +1,11 @@
%%%-------------------------------------------------------------------
%%% @doc Административный обработчик отзывов.
%%% GET – список отзывов с пагинацией, фильтрацией и сортировкой.
%%% PATCH – массовое обновление статусов отзывов.
%%% @end
%%%-------------------------------------------------------------------
-module(admin_handler_reviews). -module(admin_handler_reviews).
-behaviour(cowboy_handler). -behaviour(cowboy_handler).
-export([init/2]). -export([init/2]).
-export([trails/0]). -export([trails/0]).
@@ -9,54 +14,40 @@
%%% cowboy_handler callback %%% cowboy_handler callback
init(Req, _Opts) -> init(Req, _Opts) ->
case cowboy_req:method(Req) of case cowboy_req:method(Req) of
<<"GET">> -> list_reviews(Req); <<"GET">> -> list_reviews(Req);
<<"PATCH">> -> bulk_update_reviews(Req); <<"PATCH">> -> bulk_update_reviews(Req);
_ -> handler_utils:send_error(Req, 405, <<"Method not allowed">>) _ -> handler_utils:send_error(Req, 405, <<"Method not allowed">>)
end. end.
%%% Swagger metadata %%% Swagger metadata
trails() -> trails() ->
[ [ #{ % GET list
#{ % GET list path => <<"/v1/admin/reviews">>,
path => <<"/v1/admin/reviews">>, method => <<"GET">>,
method => <<"GET">>, description => <<"List all reviews (admin)">>,
description => <<"List all reviews (admin)">>, tags => [<<"Reviews">>],
tags => [<<"Reviews">>], parameters => [
parameters => [ #{name => <<"target_type">>, in => <<"query">>, schema => #{type => string}, description => <<"calendar or event">>},
#{name => <<"target_type">>, in => <<"query">>, schema => #{type => string}, description => <<"calendar or event">>}, #{name => <<"target_id">>, in => <<"query">>, schema => #{type => string}, description => <<"ID of target">>},
#{name => <<"target_id">>, in => <<"query">>, schema => #{type => string}, description => <<"ID of target">>}, #{name => <<"user_id">>, in => <<"query">>, schema => #{type => string}, description => <<"Filter by user">>},
#{name => <<"user_id">>, in => <<"query">>, schema => #{type => string}, description => <<"Filter by user">>}, #{name => <<"status">>, in => <<"query">>, schema => #{type => string}, description => <<"visible, hidden, deleted, or all">>},
#{name => <<"status">>, in => <<"query">>, schema => #{type => string}, description => <<"visible, hidden, deleted, or all">>}, #{name => <<"limit">>, in => <<"query">>, schema => #{type => integer}, description => <<"Page size">>},
#{name => <<"limit">>, in => <<"query">>, schema => #{type => integer}, description => <<"Page size">>}, #{name => <<"offset">>, in => <<"query">>, schema => #{type => integer}, description => <<"Offset">>},
#{name => <<"offset">>, in => <<"query">>, schema => #{type => integer}, description => <<"Offset">>} #{name => <<"sort">>, in => <<"query">>, schema => #{type => string, enum => [<<"created_at">>, <<"updated_at">>, <<"rating">>]}, description => <<"Sort field">>},
], #{name => <<"order">>, in => <<"query">>, schema => #{type => string, enum => [<<"asc">>, <<"desc">>]}, description => <<"Sort direction">>}
responses => #{ ],
200 => #{ responses => #{
description => <<"Array of reviews">>, 200 => #{ description => <<"Array of reviews">>, content => #{<<"application/json">> => #{schema => #{ type => array, items => review_schema() }}} }
content => #{<<"application/json">> => #{schema => #{ }
type => array, },
items => review_schema() #{ % PATCH bulk update
}}} path => <<"/v1/admin/reviews">>,
} method => <<"PATCH">>,
}
},
#{ % PATCH bulk update
path => <<"/v1/admin/reviews">>,
method => <<"PATCH">>,
description => <<"Bulk update review statuses">>, description => <<"Bulk update review statuses">>,
tags => [<<"Reviews">>], tags => [<<"Reviews">>],
requestBody => #{ requestBody => #{
required => true, required => true,
content => #{<<"application/json">> => #{schema => #{ content => #{<<"application/json">> => #{schema => #{ type => array, items => #{ type => object, properties => #{ id => #{type => string}, status => #{type => string, enum => [<<"visible">>, <<"hidden">>, <<"deleted">>]} } }}} }
type => array,
items => #{
type => object,
properties => #{
id => #{type => string},
status => #{type => string, enum => [<<"visible">>, <<"hidden">>, <<"deleted">>]}
}
}
}}}
}, },
responses => #{ responses => #{
200 => #{description => <<"Number of updated reviews">>} 200 => #{description => <<"Number of updated reviews">>}
@@ -65,21 +56,20 @@ trails() ->
]. ].
review_schema() -> review_schema() ->
#{ #{ type => object,
type => object,
properties => #{ properties => #{
id => #{type => string}, id => #{type => string},
user_id => #{type => string}, user_id => #{type => string},
target_type => #{type => string, enum => [<<"calendar">>, <<"event">>]}, target_type => #{type => string, enum => [<<"calendar">>, <<"event">>]},
target_id => #{type => string}, target_id => #{type => string},
rating => #{type => integer, minimum => 1, maximum => 5}, rating => #{type => integer, minimum => 1, maximum => 5},
comment => #{type => string}, comment => #{type => string},
status => #{type => string, enum => [<<"visible">>, <<"hidden">>, <<"deleted">>]}, status => #{type => string, enum => [<<"visible">>, <<"hidden">>, <<"deleted">>]},
reason => #{type => string, nullable => true}, reason => #{type => string, nullable => true},
likes => #{type => integer}, likes => #{type => integer},
dislikes => #{type => integer}, dislikes => #{type => integer},
created_at => #{type => string, format => <<"date-time">>}, created_at => #{type => string, format => <<"date-time">>},
updated_at => #{type => string, format => <<"date-time">>} updated_at => #{type => string, format => <<"date-time">>}
} }
}. }.
@@ -91,35 +81,40 @@ list_reviews(Req) ->
Qs = cowboy_req:parse_qs(Req1), Qs = cowboy_req:parse_qs(Req1),
Filters0 = #{ Filters0 = #{
target_type => normalize_target_type(proplists:get_value(<<"target_type">>, Qs)), target_type => normalize_target_type(proplists:get_value(<<"target_type">>, Qs)),
target_id => proplists:get_value(<<"target_id">>, Qs), target_id => proplists:get_value(<<"target_id">>, Qs),
user_id => proplists:get_value(<<"user_id">>, Qs), user_id => proplists:get_value(<<"user_id">>, Qs),
status => proplists:get_value(<<"status">>, Qs) status => proplists:get_value(<<"status">>, Qs)
}, },
% Убираем ключи со значением undefined
Filters = maps:filter(fun(_, V) -> V =/= undefined end, Filters0), Filters = maps:filter(fun(_, V) -> V =/= undefined end, Filters0),
Pagination = handler_utils:parse_pagination_params(Req1), Pagination = handler_utils:parse_pagination_params(Req1),
{ok, Total, Reviews} = logic_review:list_admin_reviews(Filters, Pagination), % Убедимся, что в Pagination есть ключи sort и order со значениями по умолчанию
PaginationWithSort = Pagination#{
sort => maps:get(sort, Pagination, <<"created_at">>),
order => maps:get(order, Pagination, <<"desc">>)
},
{ok, Total, Reviews} = logic_review:list_admin_reviews(Filters, PaginationWithSort),
Json = [handler_utils:review_to_json(R) || R <- Reviews], Json = [handler_utils:review_to_json(R) || R <- Reviews],
ExtraHeaders = pagination_headers(Pagination, Total), ExtraHeaders = handler_utils:pagination_headers(Pagination, Total),
handler_utils:send_json(Req1, 200, Json, ExtraHeaders); handler_utils:send_json(Req1, 200, Json, ExtraHeaders);
{error, Code, Msg, Req1} -> {error, Code, Msg, Req1} ->
handler_utils:send_error(Req1, Code, Msg) handler_utils:send_error(Req1, Code, Msg)
end. end.
%% @private Преобразует бинарный target_type в атом. %% @private Преобразует бинарный target_type в атом.
normalize_target_type(<<"event">>) -> event; normalize_target_type(<<"event">>) -> event;
normalize_target_type(<<"calendar">>) -> calendar; normalize_target_type(<<"calendar">>) -> calendar;
normalize_target_type(Other) -> Other. normalize_target_type(Other) -> Other.
bulk_update_reviews(Req) -> bulk_update_reviews(Req) ->
case handler_utils:auth_admin(Req) of case handler_utils:auth_admin(Req) of
{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 = 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) of
{ok, UpdatedCount} -> {ok, UpdatedCount} ->
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, Reason} ->
handler_utils:send_error(Req2, 400, Reason) handler_utils:send_error(Req2, 400, Reason)
@@ -129,12 +124,4 @@ bulk_update_reviews(Req) ->
end; end;
{error, Code, Msg, Req1} -> {error, Code, Msg, Req1} ->
handler_utils:send_error(Req1, Code, Msg) handler_utils:send_error(Req1, Code, Msg)
end. end.
pagination_headers(#{limit := Limit, offset := Offset}, Total) ->
RangeEnd = min(Offset + Limit - 1, Total - 1),
#{
<<"content-range">> => iolist_to_binary(io_lib:format("items ~B-~B/~B", [Offset, RangeEnd, Total])),
<<"x-total-count">> => integer_to_binary(Total),
<<"access-control-expose-headers">> => <<"Content-Range, X-Total-Count">>
}.
@@ -103,7 +103,7 @@ get_review(Req) ->
update_review(Req) -> update_review(Req) ->
case handler_utils:auth_admin(Req) of case handler_utils:auth_admin(Req) of
{ok, _AdminId, Req1} -> {ok, AdminId, Req1} ->
ReviewId = cowboy_req:binding(id, Req1), ReviewId = cowboy_req:binding(id, Req1),
{ok, Body, Req2} = cowboy_req:read_body(Req1), {ok, Body, Req2} = cowboy_req:read_body(Req1),
try jsx:decode(Body, [return_maps]) of try jsx:decode(Body, [return_maps]) of
@@ -112,6 +112,8 @@ update_review(Req) ->
Converted = convert_review_fields(Updates), Converted = convert_review_fields(Updates),
case logic_review:update_review_admin(ReviewId, Converted) of case logic_review:update_review_admin(ReviewId, Converted) of
{ok, Review} -> {ok, Review} ->
% Аудит изменения отзыва
admin_utils:log_admin_action(AdminId, <<"update_review">>, <<"review">>, ReviewId, Req2),
handler_utils:send_json(Req2, 200, handler_utils:review_to_json(Review)); handler_utils:send_json(Req2, 200, handler_utils:review_to_json(Review));
{error, not_found} -> {error, not_found} ->
handler_utils:send_error(Req2, 404, <<"Review not found">>); handler_utils:send_error(Req2, 404, <<"Review not found">>);
+22 -33
View File
@@ -6,7 +6,6 @@
%%%------------------------------------------------------------------- %%%-------------------------------------------------------------------
-module(admin_handler_stats). -module(admin_handler_stats).
-behaviour(cowboy_handler). -behaviour(cowboy_handler).
-export([init/2]). -export([init/2]).
-export([trails/0]). -export([trails/0]).
@@ -17,7 +16,7 @@
init(Req, _Opts) -> init(Req, _Opts) ->
case cowboy_req:method(Req) of case cowboy_req:method(Req) of
<<"GET">> -> get_stats(Req); <<"GET">> -> get_stats(Req);
_ -> handler_utils:send_error(Req, 405, <<"Method not allowed">>) _ -> handler_utils:send_error(Req, 405, <<"Method not allowed">>)
end. end.
%%% Swagger metadata %%% Swagger metadata
@@ -25,19 +24,19 @@ init(Req, _Opts) ->
trails() -> trails() ->
[ [
#{ #{
path => <<"/v1/admin/stats">>, path => <<"/v1/admin/stats">>,
method => <<"GET">>, method => <<"GET">>,
description => <<"Get admin dashboard statistics">>, description => <<"Get admin dashboard statistics">>,
tags => [<<"Statistics">>], tags => [<<"Statistics">>],
parameters => [ parameters => [
#{name => <<"from">>, in => <<"query">>, schema => #{type => string, format => <<"date-time">>}, description => <<"Start date (ISO8601)">>}, #{name => <<"from">>, in => <<"query">>, schema => #{type => string, format => <<"date-time">>}, description => <<"Start date (ISO8601)">>},
#{name => <<"to">>, in => <<"query">>, schema => #{type => string, format => <<"date-time">>}, description => <<"End date (ISO8601)">>} #{name => <<"to">>, in => <<"query">>, schema => #{type => string, format => <<"date-time">>}, description => <<"End date (ISO8601)">>}
], ],
responses => #{ responses => #{
200 => #{ 200 => #{
description => <<"Statistics object">>, description => <<"Statistics object">>,
content => #{<<"application/json">> => #{schema => #{ content => #{<<"application/json">> => #{schema => #{
type => object, type => object,
properties => stats_schema() properties => stats_schema()
}}} }}}
}, },
@@ -48,13 +47,13 @@ trails() ->
stats_schema() -> stats_schema() ->
#{ #{
<<"users">> => #{type => integer, description => <<"Total number of users">>}, <<"users">> => #{type => integer, description => <<"Total number of users">>},
<<"events">> => #{type => integer}, <<"events">> => #{type => integer},
<<"reviews">> => #{type => integer}, <<"reviews">> => #{type => integer},
<<"calendars">> => #{type => integer}, <<"calendars">> => #{type => integer},
<<"reports">> => #{type => integer}, <<"reports">> => #{type => integer},
<<"tickets">> => #{type => integer}, <<"tickets">> => #{type => integer},
<<"subscriptions">> => #{type => integer}, <<"subscriptions">> => #{type => integer},
<<"active_subscriptions">> => #{type => integer} <<"active_subscriptions">> => #{type => integer}
}. }.
@@ -68,8 +67,10 @@ get_stats(Req) ->
{ok, Admin} = core_admin:get_by_id(AdminId), {ok, Admin} = core_admin:get_by_id(AdminId),
Role = Admin#admin.role, Role = Admin#admin.role,
Stats = case parse_date_range(Req1) of Stats = case parse_date_range(Req1) of
{ok, From, To} -> logic_stats:get_stats(Role, AdminId, From, To); {ok, From, To} ->
_ -> logic_stats:get_stats(Role, AdminId) logic_stats:get_stats(Role, AdminId, From, To);
_ ->
logic_stats:get_stats(Role, AdminId)
end, end,
handler_utils:send_json(Req1, 200, Stats); handler_utils:send_json(Req1, 200, Stats);
{error, Code, Message, Req1} -> {error, Code, Message, Req1} ->
@@ -81,22 +82,10 @@ get_stats(Req) ->
-spec parse_date_range(cowboy_req:req()) -> {ok, calendar:datetime(), calendar:datetime()} | error. -spec parse_date_range(cowboy_req:req()) -> {ok, calendar:datetime(), calendar:datetime()} | error.
parse_date_range(Req) -> parse_date_range(Req) ->
Qs = cowboy_req:parse_qs(Req), Qs = cowboy_req:parse_qs(Req),
From = proplists:get_value(<<"from">>, Qs), From = handler_utils:parse_datetime_qs(proplists:get_value(<<"from">>, Qs)),
To = proplists:get_value(<<"to">>, Qs), To = handler_utils:parse_datetime_qs(proplists:get_value(<<"to">>, Qs)),
case {From, To} of case {From, To} of
{undefined, _} -> error; {undefined, _} -> error;
{_, undefined} -> error; {_, undefined} -> error;
{F, T} -> try FromDT = iso8601_to_datetime(F), {F, T} -> {ok, F, T}
ToDT = iso8601_to_datetime(T), end.
{ok, FromDT, ToDT}
catch _:_ -> error
end
end.
%% @private Преобразует бинарную строку ISO8601 в кортеж datetime().
-spec iso8601_to_datetime(binary()) -> calendar:datetime().
iso8601_to_datetime(Str) ->
[Date, Time] = binary:split(Str, <<"T">>),
[Y, M, D] = [binary_to_integer(X) || X <- binary:split(Date, <<"-">>, [global])],
[H, Min, S] = [binary_to_integer(X) || X <- binary:split(Time, <<":">>, [global])],
{{Y, M, D}, {H, Min, S}}.
@@ -5,7 +5,6 @@
%%%------------------------------------------------------------------- %%%-------------------------------------------------------------------
-module(admin_handler_subscriptions). -module(admin_handler_subscriptions).
-behaviour(cowboy_handler). -behaviour(cowboy_handler).
-export([init/2]). -export([init/2]).
-export([trails/0]). -export([trails/0]).
@@ -15,28 +14,28 @@
init(Req, _Opts) -> init(Req, _Opts) ->
case cowboy_req:method(Req) of case cowboy_req:method(Req) of
<<"GET">> -> list_subscriptions(Req); <<"GET">> -> list_subscriptions(Req);
_ -> handler_utils:send_error(Req, 405, <<"Method not allowed">>) _ -> handler_utils:send_error(Req, 405, <<"Method not allowed">>)
end. end.
-spec trails() -> [map()]. -spec trails() -> [map()].
trails() -> trails() ->
[ [
#{ #{
path => <<"/v1/admin/subscriptions">>, path => <<"/v1/admin/subscriptions">>,
method => <<"GET">>, method => <<"GET">>,
description => <<"List all subscriptions (admin)">>, description => <<"List all subscriptions (admin)">>,
tags => [<<"Subscriptions">>], tags => [<<"Subscriptions">>],
parameters => [ parameters => [
#{name => <<"plan">>, in => <<"query">>, schema => #{type => string, enum => [<<"monthly">>, <<"quarterly">>, <<"biannual">>, <<"annual">>]}, description => <<"Filter by plan">>}, #{name => <<"plan">>, in => <<"query">>, schema => #{type => string, enum => [<<"monthly">>, <<"quarterly">>, <<"biannual">>, <<"annual">>]}, description => <<"Filter by plan">>},
#{name => <<"status">>, in => <<"query">>, schema => #{type => string, enum => [<<"active">>, <<"expired">>, <<"cancelled">>]}, description => <<"Filter by status">>}, #{name => <<"status">>, in => <<"query">>, schema => #{type => string, enum => [<<"active">>, <<"expired">>, <<"cancelled">>]}, description => <<"Filter by status">>},
#{name => <<"limit">>, in => <<"query">>, schema => #{type => integer}, description => <<"Page size">>}, #{name => <<"limit">>, in => <<"query">>, schema => #{type => integer}, description => <<"Page size">>},
#{name => <<"offset">>, in => <<"query">>, schema => #{type => integer}, description => <<"Offset">>} #{name => <<"offset">>, in => <<"query">>, schema => #{type => integer}, description => <<"Offset">>}
], ],
responses => #{ responses => #{
200 => #{ 200 => #{
description => <<"Array of subscriptions">>, description => <<"Array of subscriptions">>,
content => #{<<"application/json">> => #{schema => #{ content => #{<<"application/json">> => #{schema => #{
type => array, type => array,
items => subscription_schema() items => subscription_schema()
}}} }}}
} }
@@ -46,17 +45,17 @@ trails() ->
subscription_schema() -> subscription_schema() ->
#{ #{
type => object, type => object,
properties => #{ properties => #{
id => #{type => string}, id => #{type => string},
user_id => #{type => string}, user_id => #{type => string},
plan => #{type => string, enum => [<<"monthly">>, <<"quarterly">>, <<"biannual">>, <<"annual">>]}, plan => #{type => string, enum => [<<"monthly">>, <<"quarterly">>, <<"biannual">>, <<"annual">>]},
status => #{type => string, enum => [<<"active">>, <<"expired">>, <<"cancelled">>]}, status => #{type => string, enum => [<<"active">>, <<"expired">>, <<"cancelled">>]},
trial_used => #{type => boolean}, trial_used => #{type => boolean},
started_at => #{type => string, format => <<"date-time">>}, started_at => #{type => string, format => <<"date-time">>},
expires_at => #{type => string, format => <<"date-time">>}, expires_at => #{type => string, format => <<"date-time">>},
created_at => #{type => string, format => <<"date-time">>}, created_at => #{type => string, format => <<"date-time">>},
updated_at => #{type => string, format => <<"date-time">>} updated_at => #{type => string, format => <<"date-time">>}
} }
}. }.
@@ -73,7 +72,7 @@ list_subscriptions(Req) ->
Total = length(Sorted), Total = length(Sorted),
Page = lists:sublist(Sorted, maps:get(offset, Pagination) + 1, maps:get(limit, Pagination)), Page = lists:sublist(Sorted, maps:get(offset, Pagination) + 1, maps:get(limit, Pagination)),
Json = [handler_utils:subscription_to_json(S) || S <- Page], Json = [handler_utils:subscription_to_json(S) || S <- Page],
ExtraHeaders = pagination_headers(Pagination, Total), ExtraHeaders = handler_utils:pagination_headers(Pagination, Total),
handler_utils:send_json(Req1, 200, Json, ExtraHeaders); handler_utils:send_json(Req1, 200, Json, ExtraHeaders);
{error, Code, Msg, Req1} -> {error, Code, Msg, Req1} ->
handler_utils:send_error(Req1, Code, Msg) handler_utils:send_error(Req1, Code, Msg)
@@ -82,7 +81,7 @@ list_subscriptions(Req) ->
parse_subscription_filters(Req) -> parse_subscription_filters(Req) ->
Qs = cowboy_req:parse_qs(Req), Qs = cowboy_req:parse_qs(Req),
#{ #{
plan => proplists:get_value(<<"plan">>, Qs), plan => proplists:get_value(<<"plan">>, Qs),
status => proplists:get_value(<<"status">>, Qs) status => proplists:get_value(<<"status">>, Qs)
}. }.
@@ -117,12 +116,4 @@ sort_subscriptions(Subs, #{sort := Sort, order := Order}) ->
sub_field(#subscription{created_at = V}, created_at) -> V; sub_field(#subscription{created_at = V}, created_at) -> V;
sub_field(#subscription{expires_at = V}, expires_at) -> V; sub_field(#subscription{expires_at = V}, expires_at) -> V;
sub_field(_, _) -> undefined. sub_field(_, _) -> undefined.
pagination_headers(#{limit := Limit, offset := Offset}, Total) ->
RangeEnd = min(Offset + Limit - 1, Total - 1),
#{
<<"content-range">> => iolist_to_binary(io_lib:format("items ~B-~B/~B", [Offset, RangeEnd, Total])),
<<"x-total-count">> => integer_to_binary(Total),
<<"access-control-expose-headers">> => <<"Content-Range, X-Total-Count">>
}.
@@ -1,13 +1,12 @@
%%%------------------------------------------------------------------- %%%-------------------------------------------------------------------
%%% @doc Административный обработчик конкретной подписки. %%% @doc Административный обработчик конкретной подписки.
%%% GET – получить подписку по ID. %%% GET – получить подписку по ID.
%%% PUT – обновить подписку (статус, план, дата окончания). %%% PUT – обновить подписку (статус, план, дата окончания).
%%% DELETE – удалить подписку. %%% DELETE – удалить подписку.
%%% @end %%% @end
%%%------------------------------------------------------------------- %%%-------------------------------------------------------------------
-module(admin_handler_subscriptions_by_id). -module(admin_handler_subscriptions_by_id).
-behaviour(cowboy_handler). -behaviour(cowboy_handler).
-export([init/2]). -export([init/2]).
-export([trails/0]). -export([trails/0]).
@@ -16,59 +15,50 @@
-spec init(cowboy_req:req(), any()) -> {ok, cowboy_req:req(), any()}. -spec init(cowboy_req:req(), any()) -> {ok, cowboy_req:req(), any()}.
init(Req, _Opts) -> init(Req, _Opts) ->
case cowboy_req:method(Req) of case cowboy_req:method(Req) of
<<"GET">> -> get_subscription(Req); <<"GET">> -> get_subscription(Req);
<<"PUT">> -> update_subscription(Req); <<"PUT">> -> update_subscription(Req);
<<"DELETE">> -> delete_subscription(Req); <<"DELETE">> -> delete_subscription(Req);
_ -> handler_utils:send_error(Req, 405, <<"Method not allowed">>) _ -> handler_utils:send_error(Req, 405, <<"Method not allowed">>)
end. end.
-spec trails() -> [map()]. -spec trails() -> [map()].
trails() -> trails() ->
BaseParams = [ BaseParams = [
#{ #{name => <<"id">>, in => <<"path">>, description => <<"Subscription ID">>, required => true, schema => #{type => string}}
name => <<"id">>,
in => <<"path">>,
description => <<"Subscription ID">>,
required => true,
schema => #{type => string}
}
], ],
[ [
#{ % GET by id #{ % GET by id
path => <<"/v1/admin/subscriptions/:id">>, path => <<"/v1/admin/subscriptions/:id">>,
method => <<"GET">>, method => <<"GET">>,
description => <<"Get subscription by ID (admin)">>, description => <<"Get subscription by ID (admin)">>,
tags => [<<"Subscriptions">>], tags => [<<"Subscriptions">>],
parameters => BaseParams, parameters => BaseParams,
responses => #{ responses => #{
200 => #{ 200 => #{description => <<"Subscription details">>, content => #{<<"application/json">> => #{schema => subscription_schema()}}},
description => <<"Subscription details">>,
content => #{<<"application/json">> => #{schema => subscription_schema()}}
},
404 => #{description => <<"Subscription not found">>} 404 => #{description => <<"Subscription not found">>}
} }
}, },
#{ % PUT update #{ % PUT update
path => <<"/v1/admin/subscriptions/:id">>, path => <<"/v1/admin/subscriptions/:id">>,
method => <<"PUT">>, method => <<"PUT">>,
description => <<"Update subscription (admin)">>, description => <<"Update subscription (admin)">>,
tags => [<<"Subscriptions">>], tags => [<<"Subscriptions">>],
parameters => BaseParams, parameters => BaseParams,
requestBody => #{ requestBody => #{
required => true, required => true,
content => #{<<"application/json">> => #{schema => subscription_update_schema()}} content => #{<<"application/json">> => #{schema => subscription_update_schema()}}
}, },
responses => #{ responses => #{
200 => #{description => <<"Updated subscription">>}, 200 => #{description => <<"Updated subscription">>},
404 => #{description => <<"Subscription not found">>} 404 => #{description => <<"Subscription not found">>}
} }
}, },
#{ % DELETE #{ % DELETE
path => <<"/v1/admin/subscriptions/:id">>, path => <<"/v1/admin/subscriptions/:id">>,
method => <<"DELETE">>, method => <<"DELETE">>,
description => <<"Delete subscription (admin)">>, description => <<"Delete subscription (admin)">>,
tags => [<<"Subscriptions">>], tags => [<<"Subscriptions">>],
parameters => BaseParams, parameters => BaseParams,
responses => #{ responses => #{
200 => #{description => <<"Subscription deleted">>}, 200 => #{description => <<"Subscription deleted">>},
404 => #{description => <<"Subscription not found">>} 404 => #{description => <<"Subscription not found">>}
@@ -78,26 +68,26 @@ trails() ->
subscription_schema() -> subscription_schema() ->
#{ #{
type => object, type => object,
properties => #{ properties => #{
id => #{type => string}, id => #{type => string},
user_id => #{type => string}, user_id => #{type => string},
plan => #{type => string, enum => [<<"monthly">>, <<"quarterly">>, <<"biannual">>, <<"annual">>]}, plan => #{type => string, enum => [<<"monthly">>, <<"quarterly">>, <<"biannual">>, <<"annual">>]},
status => #{type => string, enum => [<<"active">>, <<"expired">>, <<"cancelled">>]}, status => #{type => string, enum => [<<"active">>, <<"expired">>, <<"cancelled">>]},
trial_used => #{type => boolean}, trial_used => #{type => boolean},
started_at => #{type => string, format => <<"date-time">>}, started_at => #{type => string, format => <<"date-time">>},
expires_at => #{type => string, format => <<"date-time">>}, expires_at => #{type => string, format => <<"date-time">>},
created_at => #{type => string, format => <<"date-time">>}, created_at => #{type => string, format => <<"date-time">>},
updated_at => #{type => string, format => <<"date-time">>} updated_at => #{type => string, format => <<"date-time">>}
} }
}. }.
subscription_update_schema() -> subscription_update_schema() ->
#{ #{
type => object, type => object,
properties => #{ properties => #{
plan => #{type => string, enum => [<<"monthly">>, <<"quarterly">>, <<"biannual">>, <<"annual">>]}, plan => #{type => string, enum => [<<"monthly">>, <<"quarterly">>, <<"biannual">>, <<"annual">>]},
status => #{type => string, enum => [<<"active">>, <<"expired">>, <<"cancelled">>]}, status => #{type => string, enum => [<<"active">>, <<"expired">>, <<"cancelled">>]},
trial_used => #{type => boolean}, trial_used => #{type => boolean},
expires_at => #{type => string, format => <<"date-time">>, description => <<"New expiration date">>} expires_at => #{type => string, format => <<"date-time">>, description => <<"New expiration date">>}
} }
@@ -123,14 +113,14 @@ get_subscription(Req) ->
update_subscription(Req) -> update_subscription(Req) ->
case handler_utils:auth_admin(Req) of case handler_utils:auth_admin(Req) of
{ok, _AdminId, Req1} -> {ok, AdminId, Req1} ->
Id = cowboy_req:binding(id, Req1), Id = cowboy_req:binding(id, Req1),
{ok, Body, Req2} = cowboy_req:read_body(Req1), {ok, Body, Req2} = cowboy_req:read_body(Req1),
try jsx:decode(Body, [return_maps]) of try jsx:decode(Body, [return_maps]) of
Data when is_map(Data) -> Data when is_map(Data) ->
% Передаём карту напрямую, как ожидает core_subscription
case core_subscription:update_subscription(Id, Data) of case core_subscription:update_subscription(Id, Data) of
{ok, Updated} -> {ok, Updated} ->
admin_utils:log_admin_action(AdminId, <<"update_subscription">>, <<"subscription">>, Id, Req2),
handler_utils:send_json(Req2, 200, handler_utils:subscription_to_json(Updated)); handler_utils:send_json(Req2, 200, handler_utils:subscription_to_json(Updated));
{error, not_found} -> {error, not_found} ->
handler_utils:send_error(Req2, 404, <<"Subscription not found">>); handler_utils:send_error(Req2, 404, <<"Subscription not found">>);
@@ -148,10 +138,11 @@ update_subscription(Req) ->
delete_subscription(Req) -> delete_subscription(Req) ->
case handler_utils:auth_admin(Req) of case handler_utils:auth_admin(Req) of
{ok, _AdminId, Req1} -> {ok, AdminId, Req1} ->
Id = cowboy_req:binding(id, Req1), Id = cowboy_req:binding(id, Req1),
case core_subscription:delete_subscription(Id) of case core_subscription:delete_subscription(Id) of
{ok, _} -> {ok, _} ->
admin_utils:log_admin_action(AdminId, <<"delete_subscription">>, <<"subscription">>, Id, Req1),
handler_utils:send_json(Req1, 200, #{status => <<"deleted">>}); handler_utils:send_json(Req1, 200, #{status => <<"deleted">>});
{error, not_found} -> {error, not_found} ->
handler_utils:send_error(Req1, 404, <<"Subscription not found">>); handler_utils:send_error(Req1, 404, <<"Subscription not found">>);
@@ -1,13 +1,12 @@
%%%------------------------------------------------------------------- %%%-------------------------------------------------------------------
%%% @doc Административный обработчик конкретного тикета. %%% @doc Административный обработчик конкретного тикета.
%%% GET – получить тикет по ID. %%% GET – получить тикет по ID.
%%% PUT обновить тикет. %%% PUT – обновить тикет.
%%% DELETE – удалить тикет. %%% DELETE – удалить тикет.
%%% @end %%% @end
%%%------------------------------------------------------------------- %%%-------------------------------------------------------------------
-module(admin_handler_ticket_by_id). -module(admin_handler_ticket_by_id).
-behaviour(cowboy_handler). -behaviour(cowboy_handler).
-export([init/2]). -export([init/2]).
-export([trails/0]). -export([trails/0]).
@@ -17,61 +16,52 @@
-spec init(cowboy_req:req(), any()) -> {ok, cowboy_req:req(), any()}. -spec init(cowboy_req:req(), any()) -> {ok, cowboy_req:req(), any()}.
init(Req, _Opts) -> init(Req, _Opts) ->
case cowboy_req:method(Req) of case cowboy_req:method(Req) of
<<"GET">> -> get_ticket(Req); <<"GET">> -> get_ticket(Req);
<<"PUT">> -> update_ticket(Req); <<"PUT">> -> update_ticket(Req);
<<"DELETE">> -> delete_ticket(Req); <<"DELETE">> -> delete_ticket(Req);
_ -> handler_utils:send_error(Req, 405, <<"Method not allowed">>) _ -> handler_utils:send_error(Req, 405, <<"Method not allowed">>)
end. end.
%%% Swagger metadata %%% Swagger metadata
-spec trails() -> [map()]. -spec trails() -> [map()].
trails() -> trails() ->
BaseParams = [ BaseParams = [
#{ #{ name => <<"id">>, in => <<"path">>, description => <<"Ticket ID">>, required => true, schema => #{type => string} }
name => <<"id">>,
in => <<"path">>,
description => <<"Ticket ID">>,
required => true,
schema => #{type => string}
}
], ],
[ [
#{ % GET by id #{ % GET by id
path => <<"/v1/admin/tickets/:id">>, path => <<"/v1/admin/tickets/:id">>,
method => <<"GET">>, method => <<"GET">>,
description => <<"Get ticket by ID (admin)">>, description => <<"Get ticket by ID (admin)">>,
tags => [<<"Tickets">>], tags => [<<"Tickets">>],
parameters => BaseParams, parameters => BaseParams,
responses => #{ responses => #{
200 => #{ 200 => #{ description => <<"Ticket details">>, content => #{<<"application/json">> => #{schema => ticket_schema()}} },
description => <<"Ticket details">>,
content => #{<<"application/json">> => #{schema => ticket_schema()}}
},
404 => #{description => <<"Ticket not found">>} 404 => #{description => <<"Ticket not found">>}
} }
}, },
#{ % PUT update #{ % PUT update
path => <<"/v1/admin/tickets/:id">>, path => <<"/v1/admin/tickets/:id">>,
method => <<"PUT">>, method => <<"PUT">>,
description => <<"Update ticket (admin)">>, description => <<"Update ticket (admin)">>,
tags => [<<"Tickets">>], tags => [<<"Tickets">>],
parameters => BaseParams, parameters => BaseParams,
requestBody => #{ requestBody => #{
required => true, required => true,
content => #{<<"application/json">> => #{schema => ticket_update_schema()}} content => #{<<"application/json">> => #{schema => ticket_update_schema()}}
}, },
responses => #{ responses => #{
200 => #{description => <<"Updated ticket">>}, 200 => #{description => <<"Updated ticket">>},
404 => #{description => <<"Ticket not found">>} 404 => #{description => <<"Ticket not found">>}
} }
}, },
#{ % DELETE #{ % DELETE
path => <<"/v1/admin/tickets/:id">>, path => <<"/v1/admin/tickets/:id">>,
method => <<"DELETE">>, method => <<"DELETE">>,
description => <<"Delete ticket (admin)">>, description => <<"Delete ticket (admin)">>,
tags => [<<"Tickets">>], tags => [<<"Tickets">>],
parameters => BaseParams, parameters => BaseParams,
responses => #{ responses => #{
200 => #{description => <<"Ticket deleted">>}, 200 => #{description => <<"Ticket deleted">>},
404 => #{description => <<"Ticket not found">>} 404 => #{description => <<"Ticket not found">>}
} }
@@ -80,29 +70,29 @@ trails() ->
ticket_schema() -> ticket_schema() ->
#{ #{
type => object, type => object,
properties => #{ properties => #{
id => #{type => string}, id => #{type => string},
reporter_id => #{type => string}, reporter_id => #{type => string},
error_hash => #{type => string}, error_hash => #{type => string},
error_message => #{type => string}, error_message => #{type => string},
stacktrace => #{type => string}, stacktrace => #{type => string},
context => #{type => string}, context => #{type => string},
count => #{type => integer}, count => #{type => integer},
first_seen => #{type => string, format => <<"date-time">>}, first_seen => #{type => string, format => <<"date-time">>},
last_seen => #{type => string, format => <<"date-time">>}, last_seen => #{type => string, format => <<"date-time">>},
status => #{type => string, enum => [<<"open">>, <<"in_progress">>, <<"resolved">>, <<"closed">>]}, status => #{type => string, enum => [<<"open">>, <<"in_progress">>, <<"resolved">>, <<"closed">>]},
assigned_to => #{type => string, nullable => true}, assigned_to => #{type => string, nullable => true},
resolution_note => #{type => string, nullable => true} resolution_note => #{type => string, nullable => true}
} }
}. }.
ticket_update_schema() -> ticket_update_schema() ->
#{ #{
type => object, type => object,
properties => #{ properties => #{
status => #{type => string, enum => [<<"open">>, <<"in_progress">>, <<"resolved">>, <<"closed">>]}, status => #{type => string, enum => [<<"open">>, <<"in_progress">>, <<"resolved">>, <<"closed">>]},
assigned_to => #{type => string}, assigned_to => #{type => string},
resolution_note => #{type => string} resolution_note => #{type => string}
} }
}. }.
@@ -141,6 +131,7 @@ update_ticket(Req) ->
Result = apply_ticket_changes(AdminId, TicketId, Data), Result = apply_ticket_changes(AdminId, TicketId, Data),
case Result of case Result of
{ok, Ticket} -> {ok, Ticket} ->
admin_utils:log_admin_action(AdminId, <<"update_ticket">>, <<"ticket">>, TicketId, Req2),
handler_utils:send_json(Req2, 200, handler_utils:ticket_to_json(Ticket)); handler_utils:send_json(Req2, 200, handler_utils:ticket_to_json(Ticket));
{error, not_found} -> {error, not_found} ->
handler_utils:send_error(Req2, 404, <<"Ticket not found">>); handler_utils:send_error(Req2, 404, <<"Ticket not found">>);
@@ -166,6 +157,7 @@ delete_ticket(Req) ->
TicketId = cowboy_req:binding(id, Req1), TicketId = cowboy_req:binding(id, Req1),
case logic_ticket:delete_ticket(AdminId, TicketId) of case logic_ticket:delete_ticket(AdminId, TicketId) of
{ok, _} -> {ok, _} ->
admin_utils:log_admin_action(AdminId, <<"delete_ticket">>, <<"ticket">>, TicketId, Req1),
handler_utils:send_json(Req1, 200, #{status => <<"deleted">>}); handler_utils:send_json(Req1, 200, #{status => <<"deleted">>});
{error, not_found} -> {error, not_found} ->
handler_utils:send_error(Req1, 404, <<"Ticket not found">>); handler_utils:send_error(Req1, 404, <<"Ticket not found">>);
@@ -193,7 +185,8 @@ apply_ticket_changes(AdminId, TicketId, Data) ->
case maps:find(<<"assigned_to">>, Data) of case maps:find(<<"assigned_to">>, Data) of
{ok, AssignTo} -> {ok, AssignTo} ->
logic_ticket:assign_ticket(AdminId, TicketId, AssignTo); logic_ticket:assign_ticket(AdminId, TicketId, AssignTo);
error -> {ok, Ticket1} error ->
{ok, Ticket1}
end; end;
Error -> Error Error -> Error
end; end;
@@ -201,6 +194,7 @@ apply_ticket_changes(AdminId, TicketId, Data) ->
case maps:find(<<"assigned_to">>, Data) of case maps:find(<<"assigned_to">>, Data) of
{ok, AssignTo} -> {ok, AssignTo} ->
logic_ticket:assign_ticket(AdminId, TicketId, AssignTo); logic_ticket:assign_ticket(AdminId, TicketId, AssignTo);
error -> {error, no_changes} error ->
{error, no_changes}
end end
end. end.
+118 -70
View File
@@ -1,11 +1,5 @@
%%%-------------------------------------------------------------------
%%% @doc Административный обработчик списка тикетов.
%%% GET – список с пагинацией, фильтрацией и сортировкой.
%%% @end
%%%-------------------------------------------------------------------
-module(admin_handler_tickets). -module(admin_handler_tickets).
-behaviour(cowboy_handler). -behaviour(cowboy_handler).
-export([init/2]). -export([init/2]).
-export([trails/0]). -export([trails/0]).
@@ -15,74 +9,89 @@
-spec init(cowboy_req:req(), any()) -> {ok, cowboy_req:req(), any()}. -spec init(cowboy_req:req(), any()) -> {ok, cowboy_req:req(), any()}.
init(Req, _Opts) -> init(Req, _Opts) ->
case cowboy_req:method(Req) of case cowboy_req:method(Req) of
<<"GET">> -> list_tickets(Req); <<"GET">> -> list_tickets(Req);
_ -> handler_utils:send_error(Req, 405, <<"Method not allowed">>) <<"PUT">> -> update_ticket(Req);
_ -> handler_utils:send_error(Req, 405, <<"Method not allowed">>)
end. end.
%%% Swagger metadata %%% Swagger metadata
-spec trails() -> [map()].
trails() -> trails() ->
[ [ #{ path => <<"/v1/admin/tickets">>,
#{ % GET list method => <<"GET">>,
path => <<"/v1/admin/tickets">>, description => <<"List tickets with optional filtering and pagination (admin)">>,
method => <<"GET">>, tags => [<<"Tickets">>],
description => <<"List all tickets (admin)">>, parameters => [
tags => [<<"Tickets">>], #{name => <<"status">>, in => <<"query">>, schema => #{type => string, enum => [<<"open">>, <<"in_progress">>, <<"resolved">>, <<"closed">>]}, description => <<"Filter by status">>},
parameters => [ #{name => <<"assigned_to">>, in => <<"query">>, schema => #{type => string}, description => <<"Filter by assigned admin ID">>},
#{name => <<"status">>, in => <<"query">>, schema => #{type => string, enum => [<<"open">>, <<"in_progress">>, <<"resolved">>, <<"closed">>]}, description => <<"Filter by status">>}, #{name => <<"q">>, in => <<"query">>, schema => #{type => string}, description => <<"Search in error_message">>},
#{name => <<"assigned_to">>, in => <<"query">>, schema => #{type => string}, description => <<"Filter by assigned admin ID">>}, #{name => <<"limit">>, in => <<"query">>, schema => #{type => integer}, description => <<"Page size">>},
#{name => <<"q">>, in => <<"query">>, schema => #{type => string}, description => <<"Search in error message">>}, #{name => <<"offset">>, in => <<"query">>, schema => #{type => integer}, description => <<"Offset">>}
#{name => <<"limit">>, in => <<"query">>, schema => #{type => integer}, description => <<"Page size">>}, ],
#{name => <<"offset">>, in => <<"query">>, schema => #{type => integer}, description => <<"Offset">>} responses => #{
200 => #{ description => <<"Array of tickets">>, content => #{<<"application/json">> => #{schema => #{ type => array, items => ticket_schema() }}} }
}
},
#{ % PUT update ticket
path => <<"/v1/admin/tickets/:id">>,
method => <<"PUT">>,
description => <<"Update ticket (admin)">>,
tags => [<<"Tickets">>],
parameters => [
#{name => <<"id">>, in => <<"path">>, description => <<"Ticket ID">>, required => true, schema => #{type => string}}
], ],
requestBody => #{
required => true,
content => #{<<"application/json">> => #{schema => ticket_update_schema()}}
},
responses => #{ responses => #{
200 => #{ 200 => #{description => <<"Updated ticket">>},
description => <<"Array of tickets">>, 404 => #{description => <<"Ticket not found">>}
content => #{<<"application/json">> => #{schema => #{
type => array,
items => ticket_schema()
}}}
}
} }
} }
]. ].
ticket_schema() -> ticket_schema() ->
#{ #{
type => object, type => object,
properties => #{ properties => #{
id => #{type => string}, id => #{type => string},
reporter_id => #{type => string}, reporter_id => #{type => string},
error_hash => #{type => string}, error_hash => #{type => string},
error_message => #{type => string}, error_message => #{type => string},
stacktrace => #{type => string}, stacktrace => #{type => string},
context => #{type => string}, context => #{type => string},
count => #{type => integer}, count => #{type => integer},
first_seen => #{type => string, format => <<"date-time">>}, first_seen => #{type => string, format => <<"date-time">>},
last_seen => #{type => string, format => <<"date-time">>}, last_seen => #{type => string, format => <<"date-time">>},
status => #{type => string, enum => [<<"open">>, <<"in_progress">>, <<"resolved">>, <<"closed">>]}, status => #{type => string, enum => [<<"open">>, <<"in_progress">>, <<"resolved">>, <<"closed">>]},
assigned_to => #{type => string, nullable => true}, assigned_to => #{type => string, nullable => true},
resolution_note => #{type => string, nullable => true} resolution_note => #{type => string, nullable => true}
} }
}. }.
ticket_update_schema() ->
#{
type => object,
properties => #{
status => #{type => string, enum => [<<"open">>, <<"in_progress">>, <<"resolved">>, <<"closed">>]},
assigned_to => #{type => string},
resolution_note => #{type => string}
}
}.
%%% Internal functions %%% Internal functions
%% @doc Получить список тикетов с пагинацией и фильтрацией.
-spec list_tickets(cowboy_req:req()) -> {ok, binary(), cowboy_req:req()}.
list_tickets(Req) -> list_tickets(Req) ->
case handler_utils:auth_admin(Req) of case handler_utils:auth_admin(Req) of
{ok, AdminId, Req1} -> {ok, AdminId, Req1} ->
Filters = parse_ticket_filters(Req1), Filters = parse_ticket_filters(Req1),
Pagination = handler_utils:parse_pagination_params(Req1), Pagination = handler_utils:parse_pagination_params(Req1),
TicketsResult = case maps:get(status, Filters, undefined) of TicketsResult = case maps:get(status, Filters, undefined) of
undefined -> undefined -> logic_ticket:list_tickets(AdminId);
logic_ticket:list_tickets(AdminId); StatusBin ->
StatusBinary -> StatusAtom = binary_to_existing_atom(StatusBin, utf8),
%% ← Преобразуем binary в atom logic_ticket:list_tickets_by_status(AdminId, StatusAtom)
StatusAtom = binary_to_existing_atom(StatusBinary, utf8), end,
logic_ticket:list_tickets_by_status(AdminId, StatusAtom)
end,
case TicketsResult of case TicketsResult of
Tickets when is_list(Tickets) -> Tickets when is_list(Tickets) ->
Filtered = apply_ticket_filters(Tickets, Filters), Filtered = apply_ticket_filters(Tickets, Filters),
@@ -90,7 +99,7 @@ list_tickets(Req) ->
Total = length(Sorted), Total = length(Sorted),
Page = lists:sublist(Sorted, maps:get(offset, Pagination) + 1, maps:get(limit, Pagination)), Page = lists:sublist(Sorted, maps:get(offset, Pagination) + 1, maps:get(limit, Pagination)),
Json = [handler_utils:ticket_to_json(T) || T <- Page], Json = [handler_utils:ticket_to_json(T) || T <- Page],
ExtraHeaders = pagination_headers(Pagination, Total), ExtraHeaders = handler_utils:pagination_headers(Pagination, Total),
handler_utils:send_json(Req1, 200, Json, ExtraHeaders); handler_utils:send_json(Req1, 200, Json, ExtraHeaders);
{error, access_denied} -> {error, access_denied} ->
handler_utils:send_error(Req1, 403, <<"Admin access required">>) handler_utils:send_error(Req1, 403, <<"Admin access required">>)
@@ -99,18 +108,42 @@ list_tickets(Req) ->
handler_utils:send_error(Req1, Code, Msg) handler_utils:send_error(Req1, Code, Msg)
end. end.
%% @private Извлечь фильтры из query string. update_ticket(Req) ->
-spec parse_ticket_filters(cowboy_req:req()) -> map(). case handler_utils:auth_admin(Req) of
{ok, AdminId, Req1} ->
TicketId = cowboy_req:binding(id, Req1),
{ok, Body, Req2} = cowboy_req:read_body(Req1),
try jsx:decode(Body, [return_maps]) of
Data when is_map(Data) ->
Result = apply_ticket_changes(AdminId, TicketId, Data),
case Result of
{ok, Ticket} ->
admin_utils:log_admin_action(AdminId, <<"update_ticket">>, <<"ticket">>, TicketId, Req2),
handler_utils:send_json(Req2, 200, handler_utils:ticket_to_json(Ticket));
{error, not_found} ->
handler_utils:send_error(Req2, 404, <<"Ticket not found">>);
{error, access_denied} ->
handler_utils:send_error(Req2, 403, <<"Admin access required">>);
{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.
parse_ticket_filters(Req) -> parse_ticket_filters(Req) ->
Qs = cowboy_req:parse_qs(Req), Qs = cowboy_req:parse_qs(Req),
#{ #{
status => proplists:get_value(<<"status">>, Qs), status => proplists:get_value(<<"status">>, Qs),
assigned_to => proplists:get_value(<<"assigned_to">>, Qs), assigned_to => proplists:get_value(<<"assigned_to">>, Qs),
q => proplists:get_value(<<"q">>, Qs) q => proplists:get_value(<<"q">>, Qs)
}. }.
%% @private Дополнительная фильтрация (status, assigned_to, q).
-spec apply_ticket_filters([#ticket{}], map()) -> [#ticket{}].
apply_ticket_filters(Tickets, Filters) -> apply_ticket_filters(Tickets, Filters) ->
Assigned = maps:get(assigned_to, Filters, undefined), Assigned = maps:get(assigned_to, Filters, undefined),
Q = maps:get(q, Filters, undefined), Q = maps:get(q, Filters, undefined),
@@ -123,31 +156,46 @@ apply_ticket_filters(Tickets, Filters) ->
_ -> [T || T <- F1, string:str(binary_to_list(T#ticket.error_message), binary_to_list(Q)) > 0] _ -> [T || T <- F1, string:str(binary_to_list(T#ticket.error_message), binary_to_list(Q)) > 0]
end. end.
%% @private Отсортировать тикеты согласно параметрам.
-spec sort_tickets([#ticket{}], map()) -> [#ticket{}].
sort_tickets(Tickets, #{sort := Sort, order := Order}) -> sort_tickets(Tickets, #{sort := Sort, order := Order}) ->
Field = binary_to_existing_atom(Sort, utf8), Field = binary_to_existing_atom(Sort, utf8),
Sorted = lists:sort( lists:sort(
fun(A, B) -> fun(A, B) ->
ValA = ticket_field(A, Field), ValA = ticket_field(A, Field),
ValB = ticket_field(B, Field), ValB = ticket_field(B, Field),
if Order == <<"asc">> -> ValA =< ValB; if Order == <<"asc">> -> ValA =< ValB;
true -> ValA >= ValB true -> ValA >= ValB
end end
end, Tickets), end, Tickets).
Sorted.
ticket_field(#ticket{first_seen = V}, first_seen) -> V; ticket_field(#ticket{first_seen = V}, first_seen) -> V;
ticket_field(#ticket{last_seen = V}, last_seen) -> V; ticket_field(#ticket{last_seen = V}, last_seen) -> V;
ticket_field(#ticket{status = V}, status) -> V; ticket_field(#ticket{status = V}, status) -> V;
ticket_field(_, _) -> undefined. ticket_field(_, _) -> undefined.
%% @private Сформировать заголовки пагинации. apply_ticket_changes(AdminId, TicketId, Data) ->
-spec pagination_headers(map(), non_neg_integer()) -> map(). case {maps:find(<<"status">>, Data), maps:find(<<"resolution_note">>, Data)} of
pagination_headers(#{limit := Limit, offset := Offset}, Total) -> {{ok, <<"resolved">>}, {ok, Note}} ->
RangeEnd = min(Offset + Limit - 1, Total - 1), logic_ticket:resolve_ticket(AdminId, TicketId, Note);
#{ {{ok, <<"resolved">>}, error} ->
<<"content-range">> => iolist_to_binary(io_lib:format("items ~B-~B/~B", [Offset, RangeEnd, Total])), logic_ticket:update_status(AdminId, TicketId, resolved);
<<"x-total-count">> => integer_to_binary(Total), {{ok, <<"closed">>}, _} ->
<<"access-control-expose-headers">> => <<"Content-Range, X-Total-Count">> logic_ticket:close_ticket(AdminId, TicketId);
}. {{ok, OtherStatus}, _} ->
case logic_ticket:update_status(AdminId, TicketId, OtherStatus) of
{ok, Ticket1} ->
case maps:find(<<"assigned_to">>, Data) of
{ok, AssignTo} ->
logic_ticket:assign_ticket(AdminId, TicketId, AssignTo);
error ->
{ok, Ticket1}
end;
Error -> Error
end;
{error, _} ->
case maps:find(<<"assigned_to">>, Data) of
{ok, AssignTo} ->
logic_ticket:assign_ticket(AdminId, TicketId, AssignTo);
error ->
{error, no_changes}
end
end.
+48 -58
View File
@@ -1,13 +1,12 @@
%%%------------------------------------------------------------------- %%%-------------------------------------------------------------------
%%% @doc Административный обработчик конкретного пользователя. %%% @doc Административный обработчик конкретного пользователя.
%%% GET – получить пользователя по ID. %%% GET – получить пользователя по ID.
%%% PUT – обновить пользователя (роль, статус, причина). %%% PUT – обновить пользователя (роль, статус, причина).
%%% DELETE – мягко удалить пользователя. %%% DELETE – мягко удалить пользователя.
%%% @end %%% @end
%%%------------------------------------------------------------------- %%%-------------------------------------------------------------------
-module(admin_handler_user_by_id). -module(admin_handler_user_by_id).
-behaviour(cowboy_handler). -behaviour(cowboy_handler).
-export([init/2]). -export([init/2]).
-export([trails/0]). -export([trails/0]).
@@ -17,91 +16,85 @@
-spec init(cowboy_req:req(), any()) -> {ok, cowboy_req:req(), any()}. -spec init(cowboy_req:req(), any()) -> {ok, cowboy_req:req(), any()}.
init(Req, _Opts) -> init(Req, _Opts) ->
case cowboy_req:method(Req) of case cowboy_req:method(Req) of
<<"GET">> -> get_user(Req); <<"GET">> -> get_user(Req);
<<"PUT">> -> update_user(Req); <<"PUT">> -> update_user(Req);
<<"DELETE">> -> delete_user(Req); <<"DELETE">> -> delete_user(Req);
_ -> handler_utils:send_error(Req, 405, <<"Method not allowed">>) _ -> handler_utils:send_error(Req, 405, <<"Method not allowed">>)
end. end.
%%% Swagger metadata %%% Swagger metadata
-spec trails() -> [map()]. -spec trails() -> [map()].
trails() -> trails() ->
BaseParams = [#{ BaseParams = [
name => <<"id">>, #{name => <<"id">>, in => <<"path">>, description => <<"User ID">>, required => true, schema => #{type => string}}
in => <<"path">>, ],
description => <<"User ID">>,
required => true,
schema => #{type => string}
}],
[ [
#{ % GET #{ % GET by id
path => <<"/v1/admin/users/:id">>, path => <<"/v1/admin/users/:id">>,
method => <<"GET">>, method => <<"GET">>,
description => <<"Get user by ID (admin)">>, description => <<"Get user by ID (admin)">>,
tags => [<<"Users">>], tags => [<<"Users">>],
parameters => BaseParams, parameters => BaseParams,
responses => #{ responses => #{
200 => #{ 200 => #{
description => <<"User details">>, description => <<"User details">>,
content => #{<<"application/json">> => #{schema => user_schema()}} content => #{<<"application/json">> => #{schema => user_schema()}}
} }
} }
}, },
#{ % PUT #{ % PUT update
path => <<"/v1/admin/users/:id">>, path => <<"/v1/admin/users/:id">>,
method => <<"PUT">>, method => <<"PUT">>,
description => <<"Update user (admin)">>, description => <<"Update user (admin)">>,
tags => [<<"Users">>], tags => [<<"Users">>],
parameters => BaseParams, parameters => BaseParams,
requestBody => #{ requestBody => #{
required => true, required => true,
content => #{<<"application/json">> => #{schema => user_update_schema()}} content => #{<<"application/json">> => #{schema => user_update_schema()}}
}, },
responses => #{ responses => #{
200 => #{description => <<"Updated user">>} 200 => #{description => <<"Updated user">>}
} }
}, },
#{ % DELETE #{ % DELETE
path => <<"/v1/admin/users/:id">>, path => <<"/v1/admin/users/:id">>,
method => <<"DELETE">>, method => <<"DELETE">>,
description => <<"Soft-delete user (admin)">>, description => <<"Soft-delete user (admin)">>,
tags => [<<"Users">>], tags => [<<"Users">>],
parameters => BaseParams, parameters => BaseParams,
responses => #{ responses => #{
200 => #{description => <<"User status set to deleted">>} 200 => #{description => <<"User status set to deleted">>}
} }
} }
]. ].
-spec user_schema() -> map().
user_schema() -> user_schema() ->
#{ #{
type => object, type => object,
properties => #{ properties => #{
id => #{type => string}, id => #{type => string},
email => #{type => string}, email => #{type => string},
role => #{type => string, enum => [<<"user">>, <<"bot">>]}, role => #{type => string, enum => [<<"user">>, <<"bot">>]},
status => #{type => string, enum => [<<"active">>, <<"frozen">>, <<"deleted">>]}, status => #{type => string, enum => [<<"active">>, <<"frozen">>, <<"deleted">>]},
reason => #{type => string, nullable => true}, reason => #{type => string, nullable => true},
nickname => #{type => string, nullable => true}, nickname => #{type => string, nullable => true},
avatar_url => #{type => string, nullable => true}, avatar_url => #{type => string, nullable => true},
timezone => #{type => string, nullable => true}, timezone => #{type => string, nullable => true},
language => #{type => string, nullable => true}, language => #{type => string, nullable => true},
social_links => #{type => array, items => #{type => string}, nullable => true}, social_links => #{type => array, items => #{type => string}, nullable => true},
phone => #{type => string, nullable => true}, phone => #{type => string, nullable => true},
preferences => #{type => object, nullable => true}, preferences => #{type => object, nullable => true},
last_login => #{type => string, format => <<"date-time">>}, last_login => #{type => string, format => <<"date-time">>},
created_at => #{type => string, format => <<"date-time">>}, created_at => #{type => string, format => <<"date-time">>},
updated_at => #{type => string, format => <<"date-time">>} updated_at => #{type => string, format => <<"date-time">>}
} }
}. }.
-spec user_update_schema() -> map().
user_update_schema() -> user_update_schema() ->
#{ #{
type => object, type => object,
properties => #{ properties => #{
role => #{type => string, enum => [<<"user">>, <<"bot">>]}, role => #{type => string, enum => [<<"user">>, <<"bot">>]},
status => #{type => string, enum => [<<"active">>, <<"frozen">>, <<"deleted">>]}, status => #{type => string, enum => [<<"active">>, <<"frozen">>, <<"deleted">>]},
reason => #{type => string} reason => #{type => string}
} }
@@ -109,7 +102,6 @@ user_update_schema() ->
%%% Internal functions %%% Internal functions
-spec get_user(cowboy_req:req()) -> {ok, binary(), cowboy_req:req()}.
get_user(Req) -> get_user(Req) ->
case handler_utils:auth_admin(Req) of case handler_utils:auth_admin(Req) of
{ok, _AdminId, Req1} -> {ok, _AdminId, Req1} ->
@@ -126,10 +118,9 @@ get_user(Req) ->
handler_utils:send_error(Req1, Code, Msg) handler_utils:send_error(Req1, Code, Msg)
end. end.
-spec update_user(cowboy_req:req()) -> {ok, binary(), cowboy_req:req()}.
update_user(Req) -> update_user(Req) ->
case handler_utils:auth_admin(Req) of case handler_utils:auth_admin(Req) of
{ok, _AdminId, Req1} -> {ok, AdminId, Req1} ->
UserId = cowboy_req:binding(id, Req1), UserId = cowboy_req:binding(id, Req1),
{ok, Body, Req2} = cowboy_req:read_body(Req1), {ok, Body, Req2} = cowboy_req:read_body(Req1),
try jsx:decode(Body, [return_maps]) of try jsx:decode(Body, [return_maps]) of
@@ -138,6 +129,7 @@ update_user(Req) ->
Converted = convert_user_fields(Updates), Converted = convert_user_fields(Updates),
case logic_user:update_user_admin(UserId, Converted) of case logic_user:update_user_admin(UserId, Converted) of
{ok, User} -> {ok, User} ->
admin_utils:log_admin_action(AdminId, <<"update_user">>, <<"user">>, UserId, Req2),
handler_utils:send_json(Req2, 200, handler_utils:user_to_json(User)); handler_utils:send_json(Req2, 200, handler_utils:user_to_json(User));
{error, not_found} -> {error, not_found} ->
handler_utils:send_error(Req2, 404, <<"User not found">>); handler_utils:send_error(Req2, 404, <<"User not found">>);
@@ -153,13 +145,13 @@ update_user(Req) ->
handler_utils:send_error(Req1, Code, Msg) handler_utils:send_error(Req1, Code, Msg)
end. end.
-spec delete_user(cowboy_req:req()) -> {ok, binary(), cowboy_req:req()}.
delete_user(Req) -> delete_user(Req) ->
case handler_utils:auth_admin(Req) of case handler_utils:auth_admin(Req) of
{ok, _AdminId, Req1} -> {ok, AdminId, Req1} ->
UserId = cowboy_req:binding(id, Req1), UserId = cowboy_req:binding(id, Req1),
case logic_user:delete_user_admin(UserId) of case logic_user:delete_user_admin(UserId) of
{ok, _} -> {ok, _} ->
admin_utils:log_admin_action(AdminId, <<"delete_user">>, <<"user">>, UserId, Req1),
handler_utils:send_json(Req1, 200, #{status => <<"deleted">>}); handler_utils:send_json(Req1, 200, #{status => <<"deleted">>});
{error, not_found} -> {error, not_found} ->
handler_utils:send_error(Req1, 404, <<"User not found">>); handler_utils:send_error(Req1, 404, <<"User not found">>);
@@ -171,13 +163,11 @@ delete_user(Req) ->
end. end.
%% @private Преобразует бинарные ключи и значения в атомы, где необходимо. %% @private Преобразует бинарные ключи и значения в атомы, где необходимо.
-spec convert_user_fields([{binary(), term()}]) -> [{atom(), term()}].
convert_user_fields(Updates) -> convert_user_fields(Updates) ->
lists:map(fun convert_field/1, Updates). lists:map(fun convert_field/1, Updates).
-spec convert_field({binary(), term()}) -> {atom(), term()}.
convert_field({<<"role">>, <<"user">>}) -> {role, user}; convert_field({<<"role">>, <<"user">>}) -> {role, user};
convert_field({<<"role">>, <<"bot">>}) -> {role, bot}; convert_field({<<"role">>, <<"bot">>}) -> {role, bot};
convert_field({<<"status">>, <<"active">>}) -> {status, active}; convert_field({<<"status">>, <<"active">>}) -> {status, active};
convert_field({<<"status">>, <<"frozen">>}) -> {status, frozen}; convert_field({<<"status">>, <<"frozen">>}) -> {status, frozen};
convert_field({<<"status">>, <<"deleted">>}) -> {status, deleted}; convert_field({<<"status">>, <<"deleted">>}) -> {status, deleted};
+53 -35
View File
@@ -1,36 +1,44 @@
%%%-------------------------------------------------------------------
%%% @doc Административный обработчик списка пользователей.
%%% GET – список пользователей с пагинацией, фильтрацией и сортировкой.
%%% @end
%%%-------------------------------------------------------------------
-module(admin_handler_users). -module(admin_handler_users).
-behaviour(cowboy_handler). -behaviour(cowboy_handler).
-export([init/2]). -export([init/2]).
-export([trails/0]). -export([trails/0]).
-include("records.hrl"). -include("records.hrl").
-spec init(cowboy_req:req(), any()) -> {ok, cowboy_req:req(), any()}.
init(Req, _Opts) -> init(Req, _Opts) ->
case cowboy_req:method(Req) of case cowboy_req:method(Req) of
<<"GET">> -> list_users(Req); <<"GET">> -> list_users(Req);
_ -> handler_utils:send_error(Req, 405, <<"Method not allowed">>) _ -> handler_utils:send_error(Req, 405, <<"Method not allowed">>)
end. end.
-spec trails() -> [map()].
trails() -> trails() ->
[ [
#{ #{
path => <<"/v1/admin/users">>, path => <<"/v1/admin/users">>,
method => <<"GET">>, method => <<"GET">>,
description => <<"List all users (admin)">>, description => <<"List all users (admin)">>,
tags => [<<"Users">>], tags => [<<"Users">>],
parameters => [ parameters => [
#{name => <<"role">>, in => <<"query">>, schema => #{type => string, enum => [<<"user">>, <<"bot">>]}}, #{name => <<"role">>, in => <<"query">>, schema => #{type => string, enum => [<<"user">>, <<"bot">>]}},
#{name => <<"status">>, in => <<"query">>, schema => #{type => string, enum => [<<"active">>, <<"frozen">>, <<"deleted">>]}}, #{name => <<"status">>, in => <<"query">>, schema => #{type => string, enum => [<<"active">>, <<"frozen">>, <<"deleted">>]}},
#{name => <<"q">>, in => <<"query">>, schema => #{type => string}, description => <<"Search by email or nickname">>}, #{name => <<"q">>, in => <<"query">>, schema => #{type => string}, description => <<"Search by email or nickname">>},
#{name => <<"limit">>, in => <<"query">>, schema => #{type => integer}}, #{name => <<"sort">>, in => <<"query">>, schema => #{type => string, enum => [<<"email">>, <<"role">>, <<"created_at">>]}, description => <<"Sort field">>},
#{name => <<"order">>, in => <<"query">>, schema => #{type => string, enum => [<<"asc">>, <<"desc">>]}, description => <<"Sort direction">>},
#{name => <<"limit">>, in => <<"query">>, schema => #{type => integer}},
#{name => <<"offset">>, in => <<"query">>, schema => #{type => integer}} #{name => <<"offset">>, in => <<"query">>, schema => #{type => integer}}
], ],
responses => #{ responses => #{
200 => #{ 200 => #{
description => <<"Array of users">>, description => <<"Array of users">>,
content => #{<<"application/json">> => #{schema => #{ content => #{<<"application/json">> => #{schema => #{
type => array, type => array,
items => user_schema() items => user_schema()
}}} }}}
} }
@@ -38,53 +46,63 @@ trails() ->
} }
]. ].
-spec user_schema() -> map().
user_schema() -> user_schema() ->
#{ #{
type => object, type => object,
properties => #{ properties => #{
id => #{type => string}, id => #{type => string},
email => #{type => string, format => <<"email">>}, email => #{type => string, format => <<"email">>},
role => #{type => string, enum => [<<"user">>, <<"bot">>]}, role => #{type => string, enum => [<<"user">>, <<"bot">>]},
status => #{type => string, enum => [<<"active">>, <<"frozen">>, <<"deleted">>]}, status => #{type => string, enum => [<<"active">>, <<"frozen">>, <<"deleted">>]},
reason => #{type => string, nullable => true}, reason => #{type => string, nullable => true},
nickname => #{type => string, nullable => true}, nickname => #{type => string, nullable => true},
avatar_url => #{type => string, nullable => true}, avatar_url => #{type => string, nullable => true},
timezone => #{type => string, nullable => true}, timezone => #{type => string, nullable => true},
language => #{type => string, nullable => true}, language => #{type => string, nullable => true},
social_links => #{type => array, items => #{type => string}, nullable => true}, social_links => #{type => array, items => #{type => string}, nullable => true},
phone => #{type => string, nullable => true}, phone => #{type => string, nullable => true},
preferences => #{type => object, nullable => true}, preferences => #{type => object, nullable => true},
last_login => #{type => string, format => <<"date-time">>}, last_login => #{type => string, format => <<"date-time">>},
created_at => #{type => string, format => <<"date-time">>}, created_at => #{type => string, format => <<"date-time">>},
updated_at => #{type => string, format => <<"date-time">>} updated_at => #{type => string, format => <<"date-time">>}
} }
}. }.
%%%===================================================================
%%% HTTP-методы
%%%===================================================================
%% @doc GET /v1/admin/users – список пользователей.
-spec list_users(cowboy_req:req()) -> {ok, binary(), cowboy_req:req()}.
list_users(Req) -> list_users(Req) ->
case handler_utils:auth_admin(Req) of case handler_utils:auth_admin(Req) of
{ok, _AdminId, Req1} -> {ok, _AdminId, Req1} ->
Filters = parse_user_filters(Req1), Filters = parse_user_filters(Req1),
Pagination = handler_utils:parse_pagination_params(Req1), Pagination = handler_utils:parse_pagination_params(Req1),
{ok, Total, Users} = logic_user:list_users_admin(Filters, Pagination), % Передаем параметры сортировки в бизнес-логику
PaginationWithSort = Pagination#{
sort => maps:get(sort, Pagination, <<"created_at">>),
order => maps:get(order, Pagination, <<"desc">>)
},
{ok, Total, Users} = logic_user:list_users_admin(Filters, PaginationWithSort),
Json = [handler_utils:user_to_json(U) || U <- Users], Json = [handler_utils:user_to_json(U) || U <- Users],
ExtraHeaders = pagination_headers(Pagination, Total), ExtraHeaders = handler_utils:pagination_headers(Pagination, Total),
handler_utils:send_json(Req1, 200, Json, ExtraHeaders); handler_utils:send_json(Req1, 200, Json, ExtraHeaders);
{error, Code, Msg, Req1} -> {error, Code, Msg, Req1} ->
handler_utils:send_error(Req1, Code, Msg) handler_utils:send_error(Req1, Code, Msg)
end. end.
%%%===================================================================
%%% Вспомогательные функции
%%%===================================================================
%% @private Разбор query-параметров для фильтрации.
-spec parse_user_filters(cowboy_req:req()) -> map().
parse_user_filters(Req) -> parse_user_filters(Req) ->
Qs = cowboy_req:parse_qs(Req), Qs = cowboy_req:parse_qs(Req),
#{ #{
role => proplists:get_value(<<"role">>, Qs), role => proplists:get_value(<<"role">>, Qs),
status => proplists:get_value(<<"status">>, Qs), status => proplists:get_value(<<"status">>, Qs),
q => proplists:get_value(<<"q">>, Qs) q => proplists:get_value(<<"q">>, Qs)
}.
pagination_headers(#{limit := Limit, offset := Offset}, Total) ->
RangeEnd = min(Offset + Limit - 1, Total - 1),
#{
<<"content-range">> => iolist_to_binary(io_lib:format("items ~B-~B/~B", [Offset, RangeEnd, Total])),
<<"x-total-count">> => integer_to_binary(Total),
<<"access-control-expose-headers">> => <<"Content-Range, X-Total-Count">>
}. }.
+35
View File
@@ -29,6 +29,7 @@
is_superadmin/1, is_superadmin/1,
pagination_headers/2 pagination_headers/2
]). ]).
-export([admin_to_json/1, audit_to_json/1]).
-include("records.hrl"). -include("records.hrl").
@@ -176,6 +177,40 @@ parse_datetime(Str) ->
%%% Сериализация записей (все поля согласно records.hrl) %%% Сериализация записей (все поля согласно records.hrl)
%%%=================================================================== %%%===================================================================
%% @private Преобразование записи администратора в JSON-совместимую карту.
-spec admin_to_json(#admin{}) -> map().
admin_to_json(Admin) ->
#{
id => Admin#admin.id,
email => Admin#admin.email,
role => atom_to_binary(Admin#admin.role, utf8),
status => atom_to_binary(Admin#admin.status, utf8),
nickname => Admin#admin.nickname,
avatar_url => Admin#admin.avatar_url,
timezone => Admin#admin.timezone,
language => Admin#admin.language,
phone => Admin#admin.phone,
preferences => Admin#admin.preferences,
last_login => handler_utils:datetime_to_iso8601(Admin#admin.last_login),
created_at => handler_utils:datetime_to_iso8601(Admin#admin.created_at),
updated_at => handler_utils:datetime_to_iso8601(Admin#admin.updated_at)
}.
-spec audit_to_json(#admin_audit{}) -> map().
audit_to_json(A) ->
#{
id => A#admin_audit.id,
admin_id => A#admin_audit.admin_id,
email => A#admin_audit.email,
role => A#admin_audit.role,
action => A#admin_audit.action,
entity_type => A#admin_audit.entity_type,
entity_id => A#admin_audit.entity_id,
timestamp => handler_utils:datetime_to_iso8601(A#admin_audit.timestamp),
ip => A#admin_audit.ip,
reason => A#admin_audit.reason
}.
%% @doc Преобразует #event{} в JSON-карту. %% @doc Преобразует #event{} в JSON-карту.
-spec event_to_json(#event{}) -> map(). -spec event_to_json(#event{}) -> map().
event_to_json(Event) -> event_to_json(Event) ->
+44 -13
View File
@@ -1,8 +1,11 @@
-module(admin_utils). -module(admin_utils).
-include("records.hrl"). -include("records.hrl").
-export([is_admin/1, check_role/2, get_permissions/1, is_superadmin/1]). -export([is_admin/1, check_role/2, get_permissions/1, is_superadmin/1]).
-export([client_ip/1]). -export([client_ip/1]). % IP
-export([log_admin_action/5]). % аудит действий администратора
-export([ip_to_binary/1]). % преобразование IP
%% -- существующие функции -------------------------------------------------
is_admin(UserId) -> is_admin(UserId) ->
case core_admin:get_by_id(UserId) of case core_admin:get_by_id(UserId) of
@@ -16,8 +19,6 @@ is_superadmin(AdminId) ->
_ -> false _ -> false
end. end.
%% Проверка конкретной роли (или одной из списка ролей)
-spec check_role(UserId :: binary(), RequiredRole :: atom() | [atom()]) -> boolean().
check_role(UserId, RequiredRoles) when is_list(RequiredRoles) -> check_role(UserId, RequiredRoles) when is_list(RequiredRoles) ->
case core_admin:get_by_id(UserId) of case core_admin:get_by_id(UserId) of
{ok, Admin} -> lists:member(Admin#admin.role, RequiredRoles); {ok, Admin} -> lists:member(Admin#admin.role, RequiredRoles);
@@ -29,25 +30,55 @@ check_role(UserId, RequiredRole) when is_atom(RequiredRole) ->
_ -> false _ -> false
end. end.
%% Возвращает список прав для роли администратора
-spec get_permissions(Role :: atom()) -> [binary()].
get_permissions(superadmin) -> get_permissions(superadmin) ->
[<<"manage_admins">>, <<"manage_users">>, <<"manage_events">>, [<<"manage_admins">>, <<"manage_users">>, <<"manage_events">>,
<<"manage_calendars">>, <<"manage_reviews">>, <<"manage_reports">>, <<"manage_calendars">>, <<"manage_reviews">>, <<"manage_reports">>,
<<"manage_tickets">>, <<"manage_banned_words">>, <<"view_stats">>, <<"manage_tickets">>, <<"manage_banned_words">>, <<"view_stats">>,
<<"view_audit">>]; <<"view_audit">>];
get_permissions(admin) -> get_permissions(admin) ->
[<<"manage_users">>, <<"manage_events">>, [<<"manage_users">>, <<"manage_events">>, <<"manage_calendars">>,
<<"manage_calendars">>, <<"manage_reviews">>, <<"manage_reports">>, <<"manage_reviews">>, <<"manage_reports">>, <<"manage_tickets">>,
<<"manage_tickets">>, <<"manage_banned_words">>, <<"view_stats">>, <<"manage_banned_words">>, <<"view_stats">>, <<"view_audit">>];
<<"view_audit">>];
get_permissions(moderator) -> get_permissions(moderator) ->
[<<"manage_events">>, <<"manage_calendars">>, <<"manage_reviews">>, [<<"manage_events">>, <<"manage_calendars">>, <<"manage_reviews">>,
<<"manage_reports">>, <<"manage_tickets">>, <<"manage_banned_words">>, <<"manage_reports">>, <<"manage_tickets">>, <<"manage_banned_words">>,
<<"view_stats">>]; <<"view_stats">>];
get_permissions(support) -> get_permissions(support) ->
[<<"manage_tickets">>, <<"view_stats">>]; [<<"manage_tickets">>, <<"view_stats">>];
get_permissions(_) -> get_permissions(_) -> [].
[].
client_ip(_Req) -> <<"127.0.0.1">>. %% -- работа с IP -----------------------------------------------------------
%% @doc Возвращает IP-адрес клиента в виде бинарной строки (напр. <<"127.0.0.1">>).
%% Сохранена для обратной совместимости; внутренне вызывает ip_to_binary/1.
-spec client_ip(cowboy_req:req()) -> binary().
client_ip(Req) ->
ip_to_binary(cowboy_req:peer(Req)).
%% @doc Преобразует IP-адрес из кортежа {A,B,C,D} в бинарную строку.
-spec ip_to_binary({byte(), byte(), byte(), byte()}) -> binary().
ip_to_binary({A, B, C, D}) ->
list_to_binary(io_lib:format("~B.~B.~B.~B", [A, B, C, D]));
ip_to_binary(_) ->
<<"unknown">>.
%% -- аудит ----------------------------------------------------------------
%% @doc Логирует действие администратора.
%% Параметры:
%% AdminId ID администратора, выполнившего действие
%% Action бинарная строка действия (напр. <<"create">>)
%% EntityType тип сущности (напр. <<"admin">>, <<"banned_word">>)
%% EntityId идентификатор сущности
%% Req cowboy_req для извлечения IP
-spec log_admin_action(binary(), binary(), binary(), term(), cowboy_req:req()) -> ok.
log_admin_action(AdminId, Action, EntityType, EntityId, Req) ->
case core_admin:get_by_id(AdminId) of
{ok, Admin} ->
Email = Admin#admin.email,
Role = atom_to_binary(Admin#admin.role, utf8),
Ip = ip_to_binary(cowboy_req:peer(Req)),
core_admin_audit:log(AdminId, Email, Role, Action, EntityType, EntityId, Ip),
ok;
_ -> ok
end.