From ed5b275efb9cbfa8e285a1254e543982c996119c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=90=D0=BB=D0=B5=D0=BA=D1=81=D0=B5=D0=B9=20=D0=A1=D0=B0?= =?UTF-8?q?=D0=B1=D0=B8=D0=BB=D0=B8=D0=BD?= Date: Tue, 26 May 2026 13:04:31 +0300 Subject: [PATCH] =?UTF-8?q?=D0=A0=D0=B5=D1=84=D0=B0=D0=BA=D1=82=D0=BE?= =?UTF-8?q?=D1=80=D0=B8=D0=BD=D0=B3=20=D0=B0=D0=B4=D0=BC=D0=B8=D0=BD=D1=81?= =?UTF-8?q?=D0=BA=D0=B8=D1=85=20=D0=BE=D0=B1=D1=80=D0=B0=D0=B1=D0=BE=D1=82?= =?UTF-8?q?=D1=87=D0=B8=D0=BA=D0=BE=D0=B2=20-=20=D0=BF=D0=B0=D0=B3=D0=B8?= =?UTF-8?q?=D0=BD=D0=B0=D1=86=D0=B8=D1=8F,=20=D1=81=D0=BE=D1=80=D1=82?= =?UTF-8?q?=D0=B8=D1=80=D0=BE=D0=B2=D0=BA=D0=B0,=20=D1=84=D0=B8=D0=BB?= =?UTF-8?q?=D1=8C=D1=82=D1=80=D0=B0=D1=86=D0=B8=D1=8F.=20=D0=A7=D0=B0?= =?UTF-8?q?=D1=81=D1=82=D1=8C=201?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/handlers/admin/admin_handler_admins.erl | 107 +++++----- .../admin/admin_handler_admins_by_id.erl | 111 +++++------ src/handlers/admin/admin_handler_audit.erl | 112 +++++------ .../admin/admin_handler_banned_words.erl | 50 ++--- .../admin/admin_handler_calendar_by_id.erl | 16 +- .../admin/admin_handler_calendars.erl | 165 ++++++--------- .../admin/admin_handler_event_by_id.erl | 184 ++++++++--------- src/handlers/admin/admin_handler_events.erl | 141 ++++++------- src/handlers/admin/admin_handler_login.erl | 45 ++--- src/handlers/admin/admin_handler_me.erl | 82 +++----- .../admin/admin_handler_moderation.erl | 75 +++---- .../admin/admin_handler_report_by_id.erl | 56 +++--- src/handlers/admin/admin_handler_reports.erl | 87 ++++---- src/handlers/admin/admin_handler_reviews.erl | 131 ++++++------ .../admin/admin_handler_reviews_by_id.erl | 4 +- src/handlers/admin/admin_handler_stats.erl | 55 ++--- .../admin/admin_handler_subscriptions.erl | 51 ++--- .../admin_handler_subscriptions_by_id.erl | 91 ++++----- .../admin/admin_handler_ticket_by_id.erl | 98 +++++---- src/handlers/admin/admin_handler_tickets.erl | 188 +++++++++++------- .../admin/admin_handler_user_by_id.erl | 106 +++++----- src/handlers/admin/admin_handler_users.erl | 88 ++++---- src/handlers/handler_utils.erl | 35 ++++ src/infra/admin_utils.erl | 57 ++++-- 24 files changed, 1009 insertions(+), 1126 deletions(-) diff --git a/src/handlers/admin/admin_handler_admins.erl b/src/handlers/admin/admin_handler_admins.erl index cda695d..30bd0f5 100644 --- a/src/handlers/admin/admin_handler_admins.erl +++ b/src/handlers/admin/admin_handler_admins.erl @@ -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) }. \ No newline at end of file diff --git a/src/handlers/admin/admin_handler_admins_by_id.erl b/src/handlers/admin/admin_handler_admins_by_id.erl index 2463383..2cc513d 100644 --- a/src/handlers/admin/admin_handler_admins_by_id.erl +++ b/src/handlers/admin/admin_handler_admins_by_id.erl @@ -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) - }. \ No newline at end of file + end, Updates). \ No newline at end of file diff --git a/src/handlers/admin/admin_handler_audit.erl b/src/handlers/admin/admin_handler_audit.erl index f696dcb..d1f65f1 100644 --- a/src/handlers/admin/admin_handler_audit.erl +++ b/src/handlers/admin/admin_handler_audit.erl @@ -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">> - }. \ No newline at end of file + {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))} + ]. \ No newline at end of file diff --git a/src/handlers/admin/admin_handler_banned_words.erl b/src/handlers/admin/admin_handler_banned_words.erl index e9ee57c..90665d8 100644 --- a/src/handlers/admin/admin_handler_banned_words.erl +++ b/src/handlers/admin/admin_handler_banned_words.erl @@ -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) }. \ No newline at end of file diff --git a/src/handlers/admin/admin_handler_calendar_by_id.erl b/src/handlers/admin/admin_handler_calendar_by_id.erl index f31180f..99791cb 100644 --- a/src/handlers/admin/admin_handler_calendar_by_id.erl +++ b/src/handlers/admin/admin_handler_calendar_by_id.erl @@ -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">>) diff --git a/src/handlers/admin/admin_handler_calendars.erl b/src/handlers/admin/admin_handler_calendars.erl index 587ae11..0b185a5 100644 --- a/src/handlers/admin/admin_handler_calendars.erl +++ b/src/handlers/admin/admin_handler_calendars.erl @@ -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. \ No newline at end of file + end, Calendars), + Sorted. \ No newline at end of file diff --git a/src/handlers/admin/admin_handler_event_by_id.erl b/src/handlers/admin/admin_handler_event_by_id.erl index e095cbb..3063a43 100644 --- a/src/handlers/admin/admin_handler_event_by_id.erl +++ b/src/handlers/admin/admin_handler_event_by_id.erl @@ -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. \ No newline at end of file +convert_field(Other) -> Other. \ No newline at end of file diff --git a/src/handlers/admin/admin_handler_events.erl b/src/handlers/admin/admin_handler_events.erl index d992efe..ba56388 100644 --- a/src/handlers/admin/admin_handler_events.erl +++ b/src/handlers/admin/admin_handler_events.erl @@ -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">>) }. \ No newline at end of file diff --git a/src/handlers/admin/admin_handler_login.erl b/src/handlers/admin/admin_handler_login.erl index ac7ee23..3e6026c 100644 --- a/src/handlers/admin/admin_handler_login.erl +++ b/src/handlers/admin/admin_handler_login.erl @@ -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">>} } + } ]. \ No newline at end of file diff --git a/src/handlers/admin/admin_handler_me.erl b/src/handlers/admin/admin_handler_me.erl index 83d145a..159b095 100644 --- a/src/handlers/admin/admin_handler_me.erl +++ b/src/handlers/admin/admin_handler_me.erl @@ -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) - }. \ No newline at end of file + end, Updates). \ No newline at end of file diff --git a/src/handlers/admin/admin_handler_moderation.erl b/src/handlers/admin/admin_handler_moderation.erl index 7eb3f39..4896ff8 100644 --- a/src/handlers/admin/admin_handler_moderation.erl +++ b/src/handlers/admin/admin_handler_moderation.erl @@ -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">>. \ No newline at end of file + handler_utils:send_error(Req, 400, <<"Invalid action for user">>). \ No newline at end of file diff --git a/src/handlers/admin/admin_handler_report_by_id.erl b/src/handlers/admin/admin_handler_report_by_id.erl index 872b950..9047672 100644 --- a/src/handlers/admin/admin_handler_report_by_id.erl +++ b/src/handlers/admin/admin_handler_report_by_id.erl @@ -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">>); diff --git a/src/handlers/admin/admin_handler_reports.erl b/src/handlers/admin/admin_handler_reports.erl index 3dbb7e9..6534f1d 100644 --- a/src/handlers/admin/admin_handler_reports.erl +++ b/src/handlers/admin/admin_handler_reports.erl @@ -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">> - }. \ No newline at end of file +report_field(_, _) -> undefined. \ No newline at end of file diff --git a/src/handlers/admin/admin_handler_reviews.erl b/src/handlers/admin/admin_handler_reviews.erl index c6f6c44..ed875db 100644 --- a/src/handlers/admin/admin_handler_reviews.erl +++ b/src/handlers/admin/admin_handler_reviews.erl @@ -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">> - }. \ No newline at end of file + end. \ No newline at end of file diff --git a/src/handlers/admin/admin_handler_reviews_by_id.erl b/src/handlers/admin/admin_handler_reviews_by_id.erl index b75ecab..69f780d 100644 --- a/src/handlers/admin/admin_handler_reviews_by_id.erl +++ b/src/handlers/admin/admin_handler_reviews_by_id.erl @@ -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">>); diff --git a/src/handlers/admin/admin_handler_stats.erl b/src/handlers/admin/admin_handler_stats.erl index ec99562..ba2f1ac 100644 --- a/src/handlers/admin/admin_handler_stats.erl +++ b/src/handlers/admin/admin_handler_stats.erl @@ -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}}. \ No newline at end of file + {F, T} -> {ok, F, T} + end. \ No newline at end of file diff --git a/src/handlers/admin/admin_handler_subscriptions.erl b/src/handlers/admin/admin_handler_subscriptions.erl index 7278ded..9a228b4 100644 --- a/src/handlers/admin/admin_handler_subscriptions.erl +++ b/src/handlers/admin/admin_handler_subscriptions.erl @@ -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">> - }. \ No newline at end of file +sub_field(_, _) -> undefined. \ No newline at end of file diff --git a/src/handlers/admin/admin_handler_subscriptions_by_id.erl b/src/handlers/admin/admin_handler_subscriptions_by_id.erl index d63bfa8..f4f8e44 100644 --- a/src/handlers/admin/admin_handler_subscriptions_by_id.erl +++ b/src/handlers/admin/admin_handler_subscriptions_by_id.erl @@ -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">>); diff --git a/src/handlers/admin/admin_handler_ticket_by_id.erl b/src/handlers/admin/admin_handler_ticket_by_id.erl index 1dbf677..4ef5e09 100644 --- a/src/handlers/admin/admin_handler_ticket_by_id.erl +++ b/src/handlers/admin/admin_handler_ticket_by_id.erl @@ -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. \ No newline at end of file diff --git a/src/handlers/admin/admin_handler_tickets.erl b/src/handlers/admin/admin_handler_tickets.erl index afae26d..f0347f5 100644 --- a/src/handlers/admin/admin_handler_tickets.erl +++ b/src/handlers/admin/admin_handler_tickets.erl @@ -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">> - }. \ No newline at end of file +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. \ No newline at end of file diff --git a/src/handlers/admin/admin_handler_user_by_id.erl b/src/handlers/admin/admin_handler_user_by_id.erl index 375bd9f..a215991 100644 --- a/src/handlers/admin/admin_handler_user_by_id.erl +++ b/src/handlers/admin/admin_handler_user_by_id.erl @@ -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}; diff --git a/src/handlers/admin/admin_handler_users.erl b/src/handlers/admin/admin_handler_users.erl index fd99c36..6467113 100644 --- a/src/handlers/admin/admin_handler_users.erl +++ b/src/handlers/admin/admin_handler_users.erl @@ -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">> }. \ No newline at end of file diff --git a/src/handlers/handler_utils.erl b/src/handlers/handler_utils.erl index e7ed1f3..5a5fbdf 100644 --- a/src/handlers/handler_utils.erl +++ b/src/handlers/handler_utils.erl @@ -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) -> diff --git a/src/infra/admin_utils.erl b/src/infra/admin_utils.erl index 2ee51f3..63b5535 100644 --- a/src/infra/admin_utils.erl +++ b/src/infra/admin_utils.erl @@ -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">>. \ No newline at end of file +%% -- работа с 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. \ No newline at end of file