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