Рефакторинг обработчиков. Часть 3 #21

This commit is contained in:
2026-05-13 23:02:59 +03:00
parent 61bb44ab4a
commit 40806df62a
91 changed files with 6138 additions and 7150 deletions
+105 -29
View File
@@ -1,3 +1,9 @@
%%%-------------------------------------------------------------------
%%% @doc Административный обработчик списка администраторов.
%%% GET – список всех администраторов (только для superadmin).
%%% POST – создать нового администратора (только для superadmin).
%%% @end
%%%-------------------------------------------------------------------
-module(admin_handler_admins).
-behaviour(cowboy_handler).
@@ -6,37 +12,66 @@
-include("records.hrl").
%%% cowboy_handler callback
-spec init(cowboy_req:req(), any()) -> {ok, cowboy_req:req(), any()}.
init(Req, _Opts) ->
case cowboy_req:method(Req) of
<<"GET">> -> list_admins(Req);
_ -> handler_utils:send_error(Req, 405, <<"Method not allowed">>)
<<"GET">> -> list_admins(Req);
<<"POST">> -> create_admin(Req);
_ -> handler_utils:send_error(Req, 405, <<"Method not allowed">>)
end.
%%% Swagger metadata
-spec trails() -> [map()].
trails() ->
[
#{
path => <<"/v1/admin/admins">>,
method => <<"GET">>,
description => <<"List all admins (superadmin only)">>,
tags => [<<"Admins">>],
parameters => [
#{name => <<"role">>, in => <<"query">>, schema => #{type => string}},
#{name => <<"status">>, in => <<"query">>, schema => #{type => string}},
#{name => <<"limit">>, in => <<"query">>, schema => #{type => integer}},
#{name => <<"offset">>, in => <<"query">>, schema => #{type => integer}}
],
responses => #{
200 => #{
description => <<"Array of admins">>,
content => #{<<"application/json">> => #{schema => #{
type => array,
items => admin_schema()
}}}
}
ListGet = #{
path => <<"/v1/admin/admins">>,
method => <<"GET">>,
description => <<"List all admins (superadmin only)">>,
tags => [<<"Admins">>],
parameters => [
#{name => <<"role">>, in => <<"query">>, schema => #{type => string}},
#{name => <<"status">>, in => <<"query">>, schema => #{type => string}},
#{name => <<"limit">>, in => <<"query">>, schema => #{type => integer}},
#{name => <<"offset">>, in => <<"query">>, schema => #{type => integer}}
],
responses => #{
200 => #{
description => <<"Array of admins">>,
content => #{<<"application/json">> => #{schema => #{
type => array,
items => admin_schema()
}}}
}
}
].
},
PostCreate = #{
path => <<"/v1/admin/admins">>,
method => <<"POST">>,
description => <<"Create a new admin (superadmin only)">>,
tags => [<<"Admins">>],
requestBody => #{
required => true,
content => #{<<"application/json">> => #{schema => #{
type => object,
required => [<<"email">>, <<"password">>, <<"role">>],
properties => #{
email => #{type => string, format => <<"email">>},
password => #{type => string},
role => #{type => string, enum => [<<"superadmin">>, <<"admin">>, <<"moderator">>, <<"support">>]}
}
}}}
},
responses => #{
201 => #{description => <<"Admin created">>},
400 => #{description => <<"Invalid fields">>},
403 => #{description => <<"Only superadmin can create admins">>},
409 => #{description => <<"Email already exists">>}
}
},
[ListGet, PostCreate].
-spec admin_schema() -> map().
admin_schema() ->
#{
type => object,
@@ -57,8 +92,12 @@ admin_schema() ->
}
}.
%%% Internal functions
%% @doc GET /v1/admin/admins – список администраторов.
-spec list_admins(cowboy_req:req()) -> {ok, binary(), cowboy_req:req()}.
list_admins(Req) ->
case handler_utils:auth_admin(Req) of
case handler_utils:is_superadmin(Req) of
{ok, _AdminId, Req1} ->
Filters = parse_admin_filters(Req1),
Pagination = handler_utils:parse_pagination_params(Req1),
@@ -70,6 +109,41 @@ list_admins(Req) ->
handler_utils:send_error(Req1, Code, Msg)
end.
%% @doc POST /v1/admin/admins – создание администратора.
-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, Body, Req2} = cowboy_req:read_body(Req1),
try jsx:decode(Body, [return_maps]) of
#{<<"email">> := Email, <<"password">> := Password, <<"role">> := RoleBin} ->
Role = try binary_to_existing_atom(RoleBin, utf8)
catch error:badarg -> undefined
end,
case Role of
undefined ->
handler_utils:send_error(Req2, 400, <<"Invalid role">>);
_ ->
case logic_admin:create_admin(Email, Password, Role) of
{ok, Admin} ->
handler_utils:send_json(Req2, 201, admin_to_json(Admin));
{error, email_exists} ->
handler_utils:send_error(Req2, 409, <<"Email already exists">>);
{error, invalid_role} ->
handler_utils:send_error(Req2, 400, <<"Invalid role">>);
{error, _} ->
handler_utils:send_error(Req2, 500, <<"Internal server error">>)
end
end;
_ -> handler_utils:send_error(Req2, 400, <<"Missing fields">>)
catch
_:_ -> handler_utils:send_error(Req1, 400, <<"Invalid JSON">>)
end;
{error, Code, Msg, Req1} ->
handler_utils:send_error(Req1, Code, Msg)
end.
-spec parse_admin_filters(cowboy_req:req()) -> map().
parse_admin_filters(Req) ->
Qs = cowboy_req:parse_qs(Req),
#{
@@ -77,23 +151,25 @@ parse_admin_filters(Req) ->
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 => Admin#admin.role,
status => Admin#admin.status,
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:parse_datetime(Admin#admin.last_login), % требует доработки – лучше общую функцию
created_at => handler_utils:parse_datetime(Admin#admin.created_at),
updated_at => handler_utils:parse_datetime(Admin#admin.updated_at)
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),
#{
@@ -0,0 +1,206 @@
%%%-------------------------------------------------------------------
%%% @doc Административный обработчик конкретного администратора.
%%% 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]).
-include("records.hrl").
%%% cowboy_handler callback
-spec init(cowboy_req:req(), any()) -> {ok, cowboy_req:req(), any()}.
init(Req, _Opts) ->
case cowboy_req:method(Req) of
<<"GET">> -> get_admin(Req);
<<"PUT">> -> update_admin(Req);
<<"DELETE">> -> delete_admin(Req);
_ -> handler_utils:send_error(Req, 405, <<"Method not allowed">>)
end.
%%% Swagger metadata
-spec trails() -> [map()].
trails() ->
IdParam = [#{name => <<"id">>, in => <<"path">>, required => true, schema => #{type => string}}],
[
#{ % 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()}}
},
404 => #{description => <<"Admin not found">>}
}
},
#{ % PUT update
path => <<"/v1/admin/admins/:id">>,
method => <<"PUT">>,
description => <<"Update admin (superadmin only)">>,
tags => [<<"Admins">>],
parameters => IdParam,
requestBody => #{
required => true,
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">>,
description => <<"Delete admin (superadmin only)">>,
tags => [<<"Admins">>],
parameters => IdParam,
responses => #{
200 => #{description => <<"Admin deleted">>},
404 => #{description => <<"Admin not found">>}
}
}
].
-spec admin_schema() -> map().
admin_schema() ->
#{
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">>}
}
}.
-spec admin_update_schema() -> map().
admin_update_schema() ->
#{
type => object,
properties => #{
nickname => #{type => string},
avatar_url => #{type => string},
timezone => #{type => string},
language => #{type => string},
phone => #{type => string},
preferences => #{type => object}
}
}.
%%% Internal functions
%% @doc GET /v1/admin/admins/:id получение администратора.
-spec get_admin(cowboy_req:req()) -> {ok, binary(), cowboy_req:req()}.
get_admin(Req) ->
case handler_utils:is_superadmin(Req) of
{ok, _AdminId, Req1} ->
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));
{error, not_found} ->
handler_utils:send_error(Req1, 404, <<"Admin not found">>);
{error, _} ->
handler_utils:send_error(Req1, 500, <<"Internal server error">>)
end;
{error, Code, Msg, Req1} ->
handler_utils:send_error(Req1, Code, Msg)
end.
%% @doc PUT /v1/admin/admins/:id обновление администратора.
-spec update_admin(cowboy_req:req()) -> {ok, binary(), cowboy_req:req()}.
update_admin(Req) ->
case handler_utils:is_superadmin(Req) of
{ok, _AdminId, Req1} ->
Id = cowboy_req:binding(id, Req1),
{ok, Body, Req2} = cowboy_req:read_body(Req1),
try jsx:decode(Body, [return_maps]) of
UpdatesMap when is_map(UpdatesMap) ->
Updates0 = maps:to_list(UpdatesMap),
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));
{error, not_found} ->
handler_utils:send_error(Req2, 404, <<"Admin not found">>);
{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.
%% @doc DELETE /v1/admin/admins/:id физическое удаление администратора.
-spec delete_admin(cowboy_req:req()) -> {ok, binary(), cowboy_req:req()}.
delete_admin(Req) ->
case handler_utils:is_superadmin(Req) of
{ok, _AdminId, Req1} ->
Id = cowboy_req:binding(id, Req1),
case logic_admin:delete_admin(Id) of
{ok, _} ->
handler_utils:send_json(Req1, 200, #{status => <<"deleted">>});
{error, not_found} ->
handler_utils:send_error(Req1, 404, <<"Admin not found">>);
{error, _} ->
handler_utils:send_error(Req1, 500, <<"Internal server error">>)
end;
{error, Code, Msg, Req1} ->
handler_utils:send_error(Req1, Code, Msg)
end.
%% @private Преобразует бинарные ключи в атомы для обновлений администратора.
-spec convert_admin_fields([{binary(), term()}]) -> [{atom(), term()}].
convert_admin_fields(Updates) ->
lists:map(fun
({<<"nickname">>, V}) -> {nickname, V};
({<<"avatar_url">>, V}) -> {avatar_url, V};
({<<"timezone">>, V}) -> {timezone, V};
({<<"language">>, V}) -> {language, V};
({<<"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)
}.
+25 -70
View File
@@ -1,6 +1,7 @@
%%%-------------------------------------------------------------------
%%% @doc Административный обработчик журнала аудита.
%%% GET – список записей аудита с пагинацией и фильтрацией.
%%% Доступно только суперадмину.
%%% @end
%%%-------------------------------------------------------------------
-module(admin_handler_audit).
@@ -26,16 +27,15 @@ trails() ->
#{
path => <<"/v1/admin/audit">>,
method => <<"GET">>,
description => <<"List audit records (admin)">>,
description => <<"List audit records (superadmin only)">>,
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 => <<"entity_type">>, in => <<"query">>, schema => #{type => string}, description => <<"Filter by entity type">>},
#{name => <<"from">>, in => <<"query">>, schema => #{type => string, format => <<"date-time">>}, description => <<"Start timestamp (ISO8601)">>},
#{name => <<"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 => <<"admin_id">>, in => <<"query">>, schema => #{type => string}, description => <<"Filter by admin ID">>},
#{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">>}
],
responses => #{
200 => #{
@@ -71,16 +71,13 @@ audit_schema() ->
%% @doc Получить список записей аудита с пагинацией и фильтрацией.
-spec list_audit(cowboy_req:req()) -> {ok, binary(), cowboy_req:req()}.
list_audit(Req) ->
case handler_utils:auth_admin(Req) of
case handler_utils:is_superadmin(Req) of
{ok, _AdminId, Req1} ->
Filters = parse_audit_filters(Req1),
Pagination = handler_utils:parse_pagination_params(Req1),
%% Предполагается, что core_admin_audit (или аналогичный) предоставляет list_all/0
{ok, AllRecords} = core_admin_audit:list(),
Filtered = apply_filters(AllRecords, Filters),
Sorted = sort_audit(Filtered, Pagination),
Total = length(Sorted),
Page = lists:sublist(Sorted, maps:get(offset, Pagination) + 1, maps:get(limit, Pagination)),
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),
handler_utils:send_json(Req1, 200, Json, ExtraHeaders);
@@ -88,65 +85,24 @@ list_audit(Req) ->
handler_utils:send_error(Req1, Code, Msg)
end.
%% @private Извлечь фильтры из query string.
-spec parse_audit_filters(cowboy_req:req()) -> map().
-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),
entity_type => proplists:get_value(<<"entity_type">>, Qs),
from => handler_utils:parse_datetime_qs(proplists:get_value(<<"from">>, Qs)),
to => handler_utils:parse_datetime_qs(proplists:get_value(<<"to">>, Qs))
}.
[
{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))}
].
%% @private Применить фильтры к списку записей аудита.
-spec apply_filters([#admin_audit{}], map()) -> [#admin_audit{}].
apply_filters(Records, Filters) ->
AdminId = maps:get(admin_id, Filters, undefined),
Action = maps:get(action, Filters, undefined),
EntityType = maps:get(entity_type, Filters, undefined),
From = maps:get(from, Filters, undefined),
To = maps:get(to, Filters, undefined),
R1 = case AdminId of
undefined -> Records;
_ -> [R || R <- Records, R#admin_audit.admin_id =:= AdminId]
end,
R2 = case Action of
undefined -> R1;
_ -> [R || R <- R1, R#admin_audit.action =:= Action]
end,
R3 = case EntityType of
undefined -> R2;
_ -> [R || R <- R2, R#admin_audit.entity_type =:= EntityType]
end,
R4 = case From of
undefined -> R3;
_ -> [R || R <- R3, R#admin_audit.timestamp >= From]
end,
case To of
undefined -> R4;
_ -> [R || R <- R4, R#admin_audit.timestamp =< To]
-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.
%% @private Отсортировать записи аудита.
-spec sort_audit([#admin_audit{}], map()) -> [#admin_audit{}].
sort_audit(Records, #{sort := Sort, order := Order}) ->
Field = binary_to_existing_atom(Sort, utf8),
lists:sort(
fun(A, B) ->
ValA = audit_field(A, Field),
ValB = audit_field(B, Field),
if Order == <<"asc">> -> ValA =< ValB;
true -> ValA >= ValB
end
end, Records).
audit_field(#admin_audit{timestamp = V}, timestamp) -> V;
audit_field(#admin_audit{action = V}, action) -> V;
audit_field(_, _) -> undefined.
%% @private Преобразовать запись аудита в JSON-карту.
-spec audit_to_json(#admin_audit{}) -> map().
audit_to_json(A) ->
#{
@@ -162,7 +118,6 @@ audit_to_json(A) ->
reason => A#admin_audit.reason
}.
%% @private Сформировать заголовки пагинации.
-spec pagination_headers(map(), non_neg_integer()) -> map().
pagination_headers(#{limit := Limit, offset := Offset}, Total) ->
RangeEnd = min(Offset + Limit - 1, Total - 1),
+1 -1
View File
@@ -17,7 +17,7 @@ init(Req, _State) ->
trails() ->
[
#{
path => <<"/v1/admin/health">>,
path => <<"/admin/health">>,
method => <<"GET">>,
description => <<"Admin API health check">>,
tags => [<<"Health">>],
+37 -10
View File
@@ -1,3 +1,9 @@
%%%-------------------------------------------------------------------
%%% @doc Административный обработчик профиля текущего администратора.
%%% GET – получить свой профиль.
%%% PUT – обновить свой профиль.
%%% @end
%%%-------------------------------------------------------------------
-module(admin_handler_me).
-behaviour(cowboy_handler).
@@ -6,6 +12,8 @@
-include("records.hrl").
%%% cowboy_handler callback
-spec init(cowboy_req:req(), any()) -> {ok, cowboy_req:req(), any()}.
init(Req, _Opts) ->
case cowboy_req:method(Req) of
<<"GET">> -> get_me(Req);
@@ -13,6 +21,8 @@ init(Req, _Opts) ->
_ -> handler_utils:send_error(Req, 405, <<"Method not allowed">>)
end.
%%% Swagger metadata
-spec trails() -> [map()].
trails() ->
[
#{ % GET
@@ -42,6 +52,7 @@ trails() ->
}
].
-spec admin_schema() -> map().
admin_schema() ->
#{
type => object,
@@ -62,6 +73,7 @@ admin_schema() ->
}
}.
-spec admin_update_schema() -> map().
admin_update_schema() ->
#{
type => object,
@@ -75,6 +87,9 @@ admin_update_schema() ->
}
}.
%%% Internal functions
-spec get_me(cowboy_req:req()) -> {ok, binary(), cowboy_req:req()}.
get_me(Req) ->
case handler_utils:auth_admin(Req) of
{ok, AdminId, Req1} ->
@@ -90,13 +105,15 @@ get_me(Req) ->
handler_utils:send_error(Req1, Code, Msg)
end.
-spec update_me(cowboy_req:req()) -> {ok, binary(), cowboy_req:req()}.
update_me(Req) ->
case handler_utils:auth_admin(Req) of
{ok, AdminId, Req1} ->
{ok, Body, Req2} = cowboy_req:read_body(Req1),
try jsx:decode(Body, [return_maps]) of
UpdatesMap when is_map(UpdatesMap) ->
Updates = maps:to_list(UpdatesMap),
Updates0 = maps:to_list(UpdatesMap),
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));
@@ -114,6 +131,21 @@ update_me(Req) ->
handler_utils:send_error(Req1, Code, Msg)
end.
%% @private Преобразует бинарные ключи в атомы для обновлений администратора.
-spec convert_admin_fields([{binary(), term()}]) -> [{atom(), term()}].
convert_admin_fields(Updates) ->
lists:map(fun
({<<"nickname">>, V}) -> {nickname, V};
({<<"avatar_url">>, V}) -> {avatar_url, V};
({<<"timezone">>, V}) -> {timezone, V};
({<<"language">>, V}) -> {language, V};
({<<"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,
@@ -126,12 +158,7 @@ admin_to_json(Admin) ->
language => Admin#admin.language,
phone => Admin#admin.phone,
preferences => Admin#admin.preferences,
last_login => datetime_to_iso8601(Admin#admin.last_login),
created_at => datetime_to_iso8601(Admin#admin.created_at),
updated_at => datetime_to_iso8601(Admin#admin.updated_at)
}.
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])).
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)
}.
+14 -10
View File
@@ -88,7 +88,15 @@ review_schema() ->
list_reviews(Req) ->
case handler_utils:auth_admin(Req) of
{ok, _AdminId, Req1} ->
Filters = parse_review_filters(Req1),
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)
},
% Убираем ключи со значением 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),
Json = [handler_utils:review_to_json(R) || R <- Reviews],
@@ -98,6 +106,11 @@ list_reviews(Req) ->
handler_utils:send_error(Req1, Code, Msg)
end.
%% @private Преобразует бинарный target_type в атом.
normalize_target_type(<<"event">>) -> event;
normalize_target_type(<<"calendar">>) -> calendar;
normalize_target_type(Other) -> Other.
bulk_update_reviews(Req) ->
case handler_utils:auth_admin(Req) of
{ok, _AdminId, Req1} ->
@@ -118,15 +131,6 @@ bulk_update_reviews(Req) ->
handler_utils:send_error(Req1, Code, Msg)
end.
parse_review_filters(Req) ->
Qs = cowboy_req:parse_qs(Req),
#{
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)
}.
pagination_headers(#{limit := Limit, offset := Offset}, Total) ->
RangeEnd = min(Offset + Limit - 1, Total - 1),
#{
@@ -109,7 +109,8 @@ update_review(Req) ->
try jsx:decode(Body, [return_maps]) of
UpdatesMap when is_map(UpdatesMap) ->
Updates = maps:to_list(UpdatesMap),
case logic_review:update_review_admin(ReviewId, Updates) of
Converted = convert_review_fields(Updates),
case logic_review:update_review_admin(ReviewId, Converted) of
{ok, Review} ->
handler_utils:send_json(Req2, 200, handler_utils:review_to_json(Review));
{error, not_found} ->
@@ -124,4 +125,19 @@ update_review(Req) ->
end;
{error, Code, Msg, Req1} ->
handler_utils:send_error(Req1, Code, Msg)
end.
end.
%% @private Преобразует бинарные ключи и значения в атомы, где необходимо.
convert_review_fields(Updates) ->
lists:map(fun convert_field/1, Updates).
convert_field({<<"status">>, Val}) ->
try binary_to_existing_atom(Val, utf8) of
Atom -> {status, Atom}
catch
error:badarg -> {status, Val}
end;
convert_field({<<"reason">>, Val}) -> {reason, Val};
convert_field({<<"comment">>, Val}) -> {comment, Val};
convert_field({<<"rating">>, Val}) -> {rating, Val};
convert_field(Other) -> Other.
@@ -87,15 +87,21 @@ parse_subscription_filters(Req) ->
}.
apply_filters(Subs, Filters) ->
Plan = maps:get(plan, Filters, undefined),
Status = maps:get(status, Filters, undefined),
F1 = case Plan of
PlanBin = maps:get(plan, Filters, undefined),
StatusBin = maps:get(status, Filters, undefined),
F1 = case PlanBin of
undefined -> Subs;
_ -> [S || S <- Subs, S#subscription.plan =:= Plan]
_ ->
Plan = try binary_to_existing_atom(PlanBin, utf8)
catch error:badarg -> PlanBin end,
[S || S <- Subs, S#subscription.plan =:= Plan]
end,
case Status of
case StatusBin of
undefined -> F1;
_ -> [S || S <- F1, S#subscription.status =:= Status]
_ ->
Status = try binary_to_existing_atom(StatusBin, utf8)
catch error:badarg -> StatusBin end,
[S || S <- F1, S#subscription.status =:= Status]
end.
sort_subscriptions(Subs, #{sort := Sort, order := Order}) ->
+55 -14
View File
@@ -1,3 +1,10 @@
%%%-------------------------------------------------------------------
%%% @doc Административный обработчик конкретного пользователя.
%%% GET – получить пользователя по ID.
%%% PUT – обновить пользователя (роль, статус, причина).
%%% DELETE – мягко удалить пользователя.
%%% @end
%%%-------------------------------------------------------------------
-module(admin_handler_user_by_id).
-behaviour(cowboy_handler).
@@ -6,6 +13,8 @@
-include("records.hrl").
%%% cowboy_handler callback
-spec init(cowboy_req:req(), any()) -> {ok, cowboy_req:req(), any()}.
init(Req, _Opts) ->
case cowboy_req:method(Req) of
<<"GET">> -> get_user(Req);
@@ -14,8 +23,16 @@ init(Req, _Opts) ->
_ -> handler_utils:send_error(Req, 405, <<"Method not allowed">>)
end.
%%% Swagger metadata
-spec trails() -> [map()].
trails() ->
BaseParams = [#{name => <<"id">>, in => <<"path">>, required => true, schema => #{type => string}}],
BaseParams = [#{
name => <<"id">>,
in => <<"path">>,
description => <<"User ID">>,
required => true,
schema => #{type => string}
}],
[
#{ % GET
path => <<"/v1/admin/users/:id">>,
@@ -56,20 +73,21 @@ trails() ->
}
].
-spec user_schema() -> map().
user_schema() ->
#{
type => object,
properties => #{
id => #{type => string},
email => #{type => string},
role => #{type => string},
status => #{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}},
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">>},
@@ -78,21 +96,20 @@ user_schema() ->
}
}.
-spec user_update_schema() -> map().
user_update_schema() ->
#{
type => object,
properties => #{
role => #{type => string, enum => [<<"user">>, <<"bot">>]},
status => #{type => string, enum => [<<"active">>, <<"frozen">>, <<"deleted">>]},
reason => #{type => string},
nickname => #{type => string},
timezone => #{type => string},
language => #{type => string},
phone => #{type => string},
preferences => #{type => object}
role => #{type => string, enum => [<<"user">>, <<"bot">>]},
status => #{type => string, enum => [<<"active">>, <<"frozen">>, <<"deleted">>]},
reason => #{type => string}
}
}.
%%% 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} ->
@@ -109,6 +126,7 @@ 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} ->
@@ -117,7 +135,8 @@ update_user(Req) ->
try jsx:decode(Body, [return_maps]) of
UpdatesMap when is_map(UpdatesMap) ->
Updates = maps:to_list(UpdatesMap),
case logic_user:update_user_admin(UserId, Updates) of
Converted = convert_user_fields(Updates),
case logic_user:update_user_admin(UserId, Converted) of
{ok, User} ->
handler_utils:send_json(Req2, 200, handler_utils:user_to_json(User));
{error, not_found} ->
@@ -134,6 +153,7 @@ 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} ->
@@ -148,4 +168,25 @@ delete_user(Req) ->
end;
{error, Code, Msg, Req1} ->
handler_utils:send_error(Req1, Code, Msg)
end.
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({<<"status">>, <<"active">>}) -> {status, active};
convert_field({<<"status">>, <<"frozen">>}) -> {status, frozen};
convert_field({<<"status">>, <<"deleted">>}) -> {status, deleted};
convert_field({<<"reason">>, Val}) -> {reason, Val};
convert_field({<<"nickname">>, Val}) -> {nickname, Val};
convert_field({<<"avatar_url">>, Val}) -> {avatar_url, Val};
convert_field({<<"timezone">>, Val}) -> {timezone, Val};
convert_field({<<"language">>, Val}) -> {language, Val};
convert_field({<<"social_links">>, Val}) -> {social_links, Val};
convert_field({<<"phone">>, Val}) -> {phone, Val};
convert_field({<<"preferences">>, Val}) -> {preferences, Val};
convert_field(Other) -> Other.
+35 -49
View File
@@ -1,6 +1,5 @@
%%%-------------------------------------------------------------------
%%% @doc Обработчик конкретного календаря (клиентский API).
%%%
%%% GET – получить информацию о календаре.
%%% PUT – обновить календарь (владельцем).
%%% DELETE – удалить календарь (владельцем).
@@ -16,11 +15,7 @@
%%% cowboy_handler callback
-spec init(cowboy_req:req(), any()) -> {ok, cowboy_req:req(), any()}.
init(Req, Opts) ->
handle(Req, Opts).
-spec handle(cowboy_req:req(), any()) -> {ok, cowboy_req:req(), any()}.
handle(Req, _Opts) ->
init(Req, _Opts) ->
case cowboy_req:method(Req) of
<<"GET">> -> get_calendar(Req);
<<"PUT">> -> update_calendar(Req);
@@ -31,15 +26,13 @@ handle(Req, _Opts) ->
%%% Swagger metadata
-spec trails() -> [map()].
trails() ->
BaseParams = [
#{
name => <<"id">>,
in => <<"path">>,
description => <<"Calendar ID">>,
required => true,
schema => #{type => string}
}
],
BaseParams = [#{
name => <<"id">>,
in => <<"path">>,
description => <<"Calendar ID">>,
required => true,
schema => #{type => string}
}],
[
#{ % GET
path => <<"/v1/calendars/:id">>,
@@ -51,9 +44,7 @@ trails() ->
200 => #{
description => <<"Calendar details">>,
content => #{<<"application/json">> => #{schema => calendar_schema()}}
},
403 => #{description => <<"Access denied">>},
404 => #{description => <<"Calendar not found">>}
}
}
},
#{ % PUT
@@ -67,10 +58,7 @@ trails() ->
content => #{<<"application/json">> => #{schema => calendar_update_schema()}}
},
responses => #{
200 => #{description => <<"Calendar updated">>},
400 => #{description => <<"Invalid request">>},
403 => #{description => <<"Access denied">>},
404 => #{description => <<"Calendar not found">>}
200 => #{description => <<"Calendar updated">>}
}
},
#{ % DELETE
@@ -80,9 +68,7 @@ trails() ->
tags => [<<"Calendars">>],
parameters => BaseParams,
responses => #{
200 => #{description => <<"Calendar deleted">>},
403 => #{description => <<"Access denied">>},
404 => #{description => <<"Calendar not found">>}
200 => #{description => <<"Calendar deleted">>}
}
}
].
@@ -90,24 +76,18 @@ trails() ->
-spec calendar_schema() -> map().
calendar_schema() ->
#{
type => object,
type => object,
properties => #{
id => #{type => string},
owner_id => #{type => string},
title => #{type => string},
description => #{type => string},
short_name => #{type => string, nullable => true},
category => #{type => string, nullable => true},
color => #{type => string, nullable => true},
image_url => #{type => string, nullable => true},
settings => #{type => object, nullable => true},
tags => #{type => array, items => #{type => string}},
type => #{type => string, enum => [<<"personal">>, <<"commercial">>]},
confirmation => #{type => string, description => <<"auto, manual, or {timeout, N}">>},
confirmation => #{type => string, enum => [<<"auto">>, <<"manual">>]},
tags => #{type => array, items => #{type => string}},
rating_avg => #{type => number, format => float},
rating_count => #{type => integer},
status => #{type => string, enum => [<<"active">>, <<"frozen">>, <<"deleted">>]},
reason => #{type => string, nullable => true},
status => #{type => string},
created_at => #{type => string, format => <<"date-time">>},
updated_at => #{type => string, format => <<"date-time">>}
}
@@ -116,22 +96,18 @@ calendar_schema() ->
-spec calendar_update_schema() -> map().
calendar_update_schema() ->
#{
type => object,
type => object,
properties => #{
title => #{type => string},
description => #{type => string},
type => #{type => string, enum => [<<"personal">>, <<"commercial">>]},
confirmation => #{type => string, description => <<"auto, manual, or {timeout, N}">>},
type => #{type => string},
confirmation => #{type => string},
tags => #{type => array, items => #{type => string}}
}
}.
%%%===================================================================
%%% HTTP-методы
%%%===================================================================
%%% Internal functions
%% @doc GET /v1/calendars/:id — получение календаря.
-spec get_calendar(cowboy_req:req()) -> {ok, binary(), cowboy_req:req()}.
get_calendar(Req) ->
case handler_utils:auth_user(Req) of
{ok, UserId, Req1} ->
@@ -150,8 +126,6 @@ get_calendar(Req) ->
handler_utils:send_error(Req1, Code, Message)
end.
%% @doc PUT /v1/calendars/:id — обновление календаря.
-spec update_calendar(cowboy_req:req()) -> {ok, binary(), cowboy_req:req()}.
update_calendar(Req) ->
case handler_utils:auth_user(Req) of
{ok, UserId, Req1} ->
@@ -159,7 +133,8 @@ update_calendar(Req) ->
{ok, Body, Req2} = cowboy_req:read_body(Req1),
try jsx:decode(Body, [return_maps]) of
UpdatesMap when is_map(UpdatesMap) ->
Updates = maps:to_list(UpdatesMap),
Updates0 = maps:to_list(UpdatesMap),
Updates = convert_calendar_fields(Updates0),
case logic_calendar:update_calendar(UserId, CalendarId, Updates) of
{ok, Calendar} ->
handler_utils:send_json(Req2, 200, handler_utils:calendar_to_json(Calendar));
@@ -179,8 +154,6 @@ update_calendar(Req) ->
handler_utils:send_error(Req1, Code, Message)
end.
%% @doc DELETE /v1/calendars/:id — удаление календаря.
-spec delete_calendar(cowboy_req:req()) -> {ok, binary(), cowboy_req:req()}.
delete_calendar(Req) ->
case handler_utils:auth_user(Req) of
{ok, UserId, Req1} ->
@@ -197,4 +170,17 @@ delete_calendar(Req) ->
end;
{error, Code, Message, Req1} ->
handler_utils:send_error(Req1, Code, Message)
end.
end.
%% @private Преобразует бинарные ключи и значения в атомы для обновлений календаря.
-spec convert_calendar_fields([{binary(), term()}]) -> [{atom(), term()}].
convert_calendar_fields(Updates) ->
lists:map(fun convert_field/1, Updates).
-spec convert_field({binary(), term()}) -> {atom(), term()}.
convert_field({<<"title">>, Val}) -> {title, Val};
convert_field({<<"description">>, Val}) -> {description, Val};
convert_field({<<"type">>, Val}) -> {type, Val};
convert_field({<<"confirmation">>, Val}) -> {confirmation, Val};
convert_field({<<"tags">>, Val}) -> {tags, Val};
convert_field(Other) -> Other.
+1 -1
View File
@@ -19,7 +19,7 @@ init(Req, Opts) ->
trails() ->
[
#{
path => <<"/v1/health">>,
path => <<"/health">>,
method => <<"GET">>,
description => <<"API health check">>,
tags => [<<"Health">>],
+15 -2
View File
@@ -25,8 +25,8 @@
ticket_to_json/1,
calendar_to_json/1,
subscription_to_json/1,
trails_for_crud/4
]).
trails_for_crud/4,
is_superadmin/1]).
-include("records.hrl").
@@ -48,6 +48,19 @@ auth_admin(Req) ->
{error, Code, Msg, Req1}
end.
%% @doc Проверяет, что запрос выполняет суперадмин.
-spec is_superadmin(cowboy_req:req()) ->
{ok, binary(), cowboy_req:req()} | {error, integer(), binary(), cowboy_req:req()}.
is_superadmin(Req) ->
case handler_utils:auth_admin(Req) of
{ok, AdminId, Req1} ->
case admin_utils:is_superadmin(AdminId) of
true -> {ok, AdminId, Req1};
false -> {error, 403, <<"Only superadmin allowed">>, Req1}
end;
Error -> Error
end.
%% @doc Проверяет, что запрос содержит валидный токен пользователя.
-spec auth_user(cowboy_req:req()) ->
{ok, binary(), cowboy_req:req()} | {error, integer(), binary(), cowboy_req:req()}.
+14 -14
View File
@@ -7,8 +7,8 @@
%%% GET / – индексная страница с выбором API
%%% GET /admin/ Swagger UI для административного API
%%% GET /admin/swagger.json OpenAPI-спецификация (admin)
%%% GET /client/ Swagger UI для клиентского API
%%% GET /client/swagger.json OpenAPI-спецификация (client)
%%% GET /user/ Swagger UI для клиентского API
%%% GET /user/swagger.json OpenAPI-спецификация (user)
%%% @end
%%%-------------------------------------------------------------------
-module(swagger_docs_handler).
@@ -35,12 +35,12 @@ handle(<<"/admin/">>, Req) ->
serve_ui(admin, Req);
handle(<<"/admin/swagger.json">>, Req) ->
serve_json(admin, Req);
handle(<<"/client">>, Req) ->
redirect_to_slash(<<"/client/">>, Req);
handle(<<"/client/">>, Req) ->
serve_ui(client, Req);
handle(<<"/client/swagger.json">>, Req) ->
serve_json(client, Req);
handle(<<"/user">>, Req) ->
redirect_to_slash(<<"/user/">>, Req);
handle(<<"/user/">>, Req) ->
serve_ui(user, Req);
handle(<<"/user/swagger.json">>, Req) ->
serve_json(user, Req);
handle(_, Req) ->
cowboy_req:reply(404, #{}, <<"Not Found">>, Req),
{ok, [], []}.
@@ -58,7 +58,7 @@ serve_index(Req) ->
<h1>EventHub API Documentation</h1>
<ul>
<li><a href=\"/admin/\">Admin API</a></li>
<li><a href=\"/client/\">Client API</a></li>
<li><a href=\"/user/\">User API</a></li>
</ul>
</body>
</html>">>,
@@ -69,11 +69,11 @@ serve_index(Req) ->
%% Swagger UI
%%--------------------------------------------------------------------
-spec serve_ui(admin | client, cowboy_req:req()) -> {ok, cowboy_req:req(), any()}.
-spec serve_ui(admin | user, cowboy_req:req()) -> {ok, cowboy_req:req(), any()}.
serve_ui(Api, Req) ->
{Title, SpecUrl} = case Api of
admin -> {<<"EventHub Admin API">>, <<"/admin/swagger.json">>};
client -> {<<"EventHub Client API">>, <<"/client/swagger.json">>}
user -> {<<"EventHub User API">>, <<"/user/swagger.json">>}
end,
Html = iolist_to_binary([
"<!DOCTYPE html><html><head><title>", Title,
@@ -91,18 +91,18 @@ serve_ui(Api, Req) ->
%% OpenAPI JSON
%%--------------------------------------------------------------------
-spec serve_json(admin | client, cowboy_req:req()) -> {ok, cowboy_req:req(), any()}.
-spec serve_json(admin | user, cowboy_req:req()) -> {ok, cowboy_req:req(), any()}.
serve_json(Api, Req) ->
Trails = case Api of
admin -> trails:admin();
client -> trails:client()
user -> trails:user()
end,
OpenApi = #{
openapi => <<"3.0.3">>,
info => #{
title => case Api of
admin -> <<"EventHub Admin API">>;
client -> <<"EventHub Client API">>
user -> <<"EventHub User API">>
end,
version => <<"1.0.0">>
},