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