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

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