diff --git a/.gitignore b/.gitignore index b8b2ca7..e60fc5d 100644 --- a/.gitignore +++ b/.gitignore @@ -24,4 +24,5 @@ rebar3.crashdump /rebar.lock /build/ docker/.env -/Mnesia.*/ \ No newline at end of file +/Mnesia.*/ +/doc/ diff --git a/Makefile b/Makefile index a058242..c41829f 100644 --- a/Makefile +++ b/Makefile @@ -34,7 +34,7 @@ clean: ## Очистить проект @echo "Очистка проекта..." @$(REBAR3) clean #@rm -rf _build build/ct_run.* deps logs *.log - @rm -rf build/ct_run.* deps logs *.log + @rm -rf _build logs/ct_run.* deps doc *.log @echo "✓ Очистка завершена" deps: ## Установить зависимости @@ -98,25 +98,15 @@ eunit-verbose: ## Запустить EUnit тесты с подробным вы @echo "Запуск EUnit тестов (verbose)..." @$(REBAR3) eunit --sname $(SNAME)_test --verbose -test-api: test-ct - -test-ct: ## Запустить Common Test для API +test-api: ## Запустить Common Test для API @echo "Cleaning old data..." - @rm -rf Mnesia.* @rm -rf logs/test/ct/ct_run.* - @$(REBAR3) ct --sname $(SNAME)_api_test + @$(REBAR3) ct --sname $(SNAME)_api_test #-v -test-ct-verbose: ## Запустить Common Test с подробным выводом - @ct_run -suite test/api_SUITE \ - -pa _build/default/lib/*/ebin \ - -pa test/api \ - -logdir build \ - -verbosity 50 +test-api-remote: + @CT_MODE=remote API_HOST=http://localhost:8080 WS_HOST=ws://localhost:8081 ADMIN_API_HOST=http://localhost:8445 ADMIN_WS_HOST=ws://localhost:8446 rebar3 ct -test-remote: - @CT_MODE=remote API_HOST=http://localhost:8080 ADMIN_API_HOST=http://localhost:8445 rebar3 ct - -test-remote-cluster: +test-api-remote-cluster: @rm -rf logs/test/ct/ct_run.* @CT_MODE=remote \ API_HOST=https://api.eventhub.local/api \ diff --git a/docker/traefik/dynamic_conf.yml b/docker/traefik/dynamic_conf.yml index c053798..e649074 100644 --- a/docker/traefik/dynamic_conf.yml +++ b/docker/traefik/dynamic_conf.yml @@ -154,7 +154,7 @@ http: servers: - url: "http://eventhub:8445" healthCheck: - path: "/v1/admin/health" + path: "/admin/health" interval: "10s" timeout: "3s" admin-api-fallback: diff --git a/rebar.config b/rebar.config index 53634de..9d919ec 100644 --- a/rebar.config +++ b/rebar.config @@ -34,7 +34,7 @@ ]} ]}, {test, [ - {erl_opts, [debug_info, {i, "include"}, {d, 'TEST'}]}, + {erl_opts, [debug_info, nowarn_export_all, {i, "include"}, {d, 'TEST'}]}, {src_dirs, ["src", "test/unit"]}, {deps, [ {meck, "0.9.2"} diff --git a/src/core/core_admin.erl b/src/core/core_admin.erl index 3c2256a..8fc0c6e 100644 --- a/src/core/core_admin.erl +++ b/src/core/core_admin.erl @@ -2,7 +2,7 @@ -include("records.hrl"). -export([create/3, get_by_email/1, get_by_id/1, list_all/0, update_role/2, block/1, unblock/1, update_last_login/1]). --export([update/2]). +-export([update/2, delete/1]). create(Email, Password, Role) -> case get_by_email(Email) of @@ -90,6 +90,16 @@ update_status(Id, Status) -> Error -> Error end. +%% Физическое удаление администратора из базы +-spec delete(binary()) -> {ok, deleted} | {error, not_found}. +delete(AdminId) -> + case get_by_id(AdminId) of + {ok, _Admin} -> + mnesia:dirty_delete(admin, AdminId), + {ok, deleted}; + Error -> Error + end. + %%%=================================================================== %%% ВНУТРЕННИЕ ФУНКЦИИ %%%=================================================================== diff --git a/src/core/core_user.erl b/src/core/core_user.erl index 5e2b3ea..1391d66 100644 --- a/src/core/core_user.erl +++ b/src/core/core_user.erl @@ -177,8 +177,16 @@ apply_updates(User, Updates) -> set_field(email, Value, U) -> U#user{email = Value}; set_field(password_hash, Value, U) -> U#user{password_hash = Value}; -set_field(role, Value, U) when Value =:= user; Value =:= admin -> U#user{role = Value}; +set_field(role, Value, U) when Value =:= user; Value =:= admin; Value =:= bot -> U#user{role = Value}; set_field(status, Value, U) when Value =:= active; Value =:= frozen; Value =:= deleted -> U#user{status = Value}; +set_field(reason, Value, U) -> U#user{reason = Value}; +set_field(nickname, Value, U) -> U#user{nickname = Value}; +set_field(avatar_url, Value, U) -> U#user{avatar_url = Value}; +set_field(timezone, Value, U) -> U#user{timezone = Value}; +set_field(language, Value, U) -> U#user{language = Value}; +set_field(social_links, Value, U) -> U#user{social_links = Value}; +set_field(phone, Value, U) -> U#user{phone = Value}; +set_field(preferences, Value, U) -> U#user{preferences = Value}; set_field(_, _, U) -> U. %% ------------------------------------------------------------------ diff --git a/src/eventhub_app.erl b/src/eventhub_app.erl index 220b782..be45051 100644 --- a/src/eventhub_app.erl +++ b/src/eventhub_app.erl @@ -35,9 +35,9 @@ start(_StartType, _StartArgs) -> end, case Nodes of [] -> - io:format("Cluster: no nodes found or first node~n"); + io:format("~nCluster: no nodes found or first node~n"); _ -> - io:format("Cluster: discovered nodes ~p, joining cluster~n", [Nodes]), + io:format("~nCluster: discovered nodes ~p, joining cluster~n", [Nodes]), application:set_env(eventhub, extra_db_nodes, Nodes) end, ok = infra_mnesia:init_tables(), @@ -102,7 +102,7 @@ start_admin_http() -> Dispatch = cowboy_router:compile([ {'_', [ % ================== БАЗОВЫЕ ================== - {"/v1/admin/health", admin_handler_health, []}, + {"/admin/health", admin_handler_health, []}, {"/v1/admin/stats", admin_handler_stats, []}, {"/v1/admin/login", admin_handler_login, []}, % ================== ПОЛЬЗОВАТЕЛИ ================== @@ -127,13 +127,13 @@ start_admin_http() -> % ================== ПОДПИСКИ ================== {"/v1/admin/subscriptions", admin_handler_subscriptions, []}, {"/v1/admin/subscriptions/:id", admin_handler_subscriptions_by_id, []}, - % ================== МОДЕРАЦИЯ (общий маршрут) ================== - {"/v1/admin/:target_type/:id", admin_handler_moderation, []}, % ================== Управление ролями (только для superadmin) ================== {"/v1/admin/me", admin_handler_me, []}, {"/v1/admin/admins", admin_handler_admins, []}, - {"/v1/admin/admins/:id", admin_handler_admins, []}, - {"/v1/admin/audit", admin_handler_audit, []} + {"/v1/admin/admins/:id", admin_handler_admins_by_id, []}, + {"/v1/admin/audit", admin_handler_audit, []}, + % ================== МОДЕРАЦИЯ (общий маршрут) ================== + {"/v1/admin/:target_type/:id", admin_handler_moderation, []} ]} ]), diff --git a/src/handlers/admin/admin_handler_admins.erl b/src/handlers/admin/admin_handler_admins.erl index 6df4940..cda695d 100644 --- a/src/handlers/admin/admin_handler_admins.erl +++ b/src/handlers/admin/admin_handler_admins.erl @@ -1,3 +1,9 @@ +%%%------------------------------------------------------------------- +%%% @doc Административный обработчик списка администраторов. +%%% GET – список всех администраторов (только для superadmin). +%%% POST – создать нового администратора (только для superadmin). +%%% @end +%%%------------------------------------------------------------------- -module(admin_handler_admins). -behaviour(cowboy_handler). @@ -6,37 +12,66 @@ -include("records.hrl"). +%%% cowboy_handler callback +-spec init(cowboy_req:req(), any()) -> {ok, cowboy_req:req(), any()}. init(Req, _Opts) -> case cowboy_req:method(Req) of - <<"GET">> -> list_admins(Req); - _ -> handler_utils:send_error(Req, 405, <<"Method not allowed">>) + <<"GET">> -> list_admins(Req); + <<"POST">> -> create_admin(Req); + _ -> handler_utils:send_error(Req, 405, <<"Method not allowed">>) end. +%%% Swagger metadata +-spec trails() -> [map()]. trails() -> - [ - #{ - path => <<"/v1/admin/admins">>, - method => <<"GET">>, - description => <<"List all admins (superadmin only)">>, - tags => [<<"Admins">>], - parameters => [ - #{name => <<"role">>, in => <<"query">>, schema => #{type => string}}, - #{name => <<"status">>, in => <<"query">>, schema => #{type => string}}, - #{name => <<"limit">>, in => <<"query">>, schema => #{type => integer}}, - #{name => <<"offset">>, in => <<"query">>, schema => #{type => integer}} - ], - responses => #{ - 200 => #{ - description => <<"Array of admins">>, - content => #{<<"application/json">> => #{schema => #{ - type => array, - items => admin_schema() - }}} - } + ListGet = #{ + path => <<"/v1/admin/admins">>, + method => <<"GET">>, + description => <<"List all admins (superadmin only)">>, + tags => [<<"Admins">>], + parameters => [ + #{name => <<"role">>, in => <<"query">>, schema => #{type => string}}, + #{name => <<"status">>, in => <<"query">>, schema => #{type => string}}, + #{name => <<"limit">>, in => <<"query">>, schema => #{type => integer}}, + #{name => <<"offset">>, in => <<"query">>, schema => #{type => integer}} + ], + responses => #{ + 200 => #{ + description => <<"Array of admins">>, + content => #{<<"application/json">> => #{schema => #{ + type => array, + items => admin_schema() + }}} } } - ]. + }, + PostCreate = #{ + path => <<"/v1/admin/admins">>, + method => <<"POST">>, + description => <<"Create a new admin (superadmin only)">>, + tags => [<<"Admins">>], + requestBody => #{ + required => true, + content => #{<<"application/json">> => #{schema => #{ + type => object, + required => [<<"email">>, <<"password">>, <<"role">>], + properties => #{ + email => #{type => string, format => <<"email">>}, + password => #{type => string}, + role => #{type => string, enum => [<<"superadmin">>, <<"admin">>, <<"moderator">>, <<"support">>]} + } + }}} + }, + responses => #{ + 201 => #{description => <<"Admin created">>}, + 400 => #{description => <<"Invalid fields">>}, + 403 => #{description => <<"Only superadmin can create admins">>}, + 409 => #{description => <<"Email already exists">>} + } + }, + [ListGet, PostCreate]. +-spec admin_schema() -> map(). admin_schema() -> #{ type => object, @@ -57,8 +92,12 @@ admin_schema() -> } }. +%%% Internal functions + +%% @doc GET /v1/admin/admins – список администраторов. +-spec list_admins(cowboy_req:req()) -> {ok, binary(), cowboy_req:req()}. list_admins(Req) -> - case handler_utils:auth_admin(Req) of + case handler_utils:is_superadmin(Req) of {ok, _AdminId, Req1} -> Filters = parse_admin_filters(Req1), Pagination = handler_utils:parse_pagination_params(Req1), @@ -70,6 +109,41 @@ list_admins(Req) -> handler_utils:send_error(Req1, Code, Msg) end. +%% @doc POST /v1/admin/admins – создание администратора. +-spec create_admin(cowboy_req:req()) -> {ok, binary(), cowboy_req:req()}. +create_admin(Req) -> + case handler_utils:is_superadmin(Req) of + {ok, _AdminId, Req1} -> + {ok, Body, Req2} = cowboy_req:read_body(Req1), + try jsx:decode(Body, [return_maps]) of + #{<<"email">> := Email, <<"password">> := Password, <<"role">> := RoleBin} -> + Role = try binary_to_existing_atom(RoleBin, utf8) + catch error:badarg -> undefined + end, + case Role of + undefined -> + handler_utils:send_error(Req2, 400, <<"Invalid role">>); + _ -> + case logic_admin:create_admin(Email, Password, Role) of + {ok, Admin} -> + handler_utils:send_json(Req2, 201, admin_to_json(Admin)); + {error, email_exists} -> + handler_utils:send_error(Req2, 409, <<"Email already exists">>); + {error, invalid_role} -> + handler_utils:send_error(Req2, 400, <<"Invalid role">>); + {error, _} -> + handler_utils:send_error(Req2, 500, <<"Internal server error">>) + end + end; + _ -> handler_utils:send_error(Req2, 400, <<"Missing fields">>) + catch + _:_ -> handler_utils:send_error(Req1, 400, <<"Invalid JSON">>) + end; + {error, Code, Msg, Req1} -> + handler_utils:send_error(Req1, Code, Msg) + end. + +-spec parse_admin_filters(cowboy_req:req()) -> map(). parse_admin_filters(Req) -> Qs = cowboy_req:parse_qs(Req), #{ @@ -77,23 +151,25 @@ parse_admin_filters(Req) -> status => proplists:get_value(<<"status">>, Qs) }. +-spec admin_to_json(#admin{}) -> map(). admin_to_json(Admin) -> #{ id => Admin#admin.id, email => Admin#admin.email, - role => Admin#admin.role, - status => Admin#admin.status, + role => atom_to_binary(Admin#admin.role, utf8), + status => atom_to_binary(Admin#admin.status, utf8), nickname => Admin#admin.nickname, avatar_url => Admin#admin.avatar_url, timezone => Admin#admin.timezone, language => Admin#admin.language, phone => Admin#admin.phone, preferences => Admin#admin.preferences, - last_login => handler_utils:parse_datetime(Admin#admin.last_login), % требует доработки – лучше общую функцию - created_at => handler_utils:parse_datetime(Admin#admin.created_at), - updated_at => handler_utils:parse_datetime(Admin#admin.updated_at) + last_login => handler_utils:datetime_to_iso8601(Admin#admin.last_login), + created_at => handler_utils:datetime_to_iso8601(Admin#admin.created_at), + updated_at => handler_utils:datetime_to_iso8601(Admin#admin.updated_at) }. +-spec pagination_headers(map(), non_neg_integer()) -> map(). pagination_headers(#{limit := Limit, offset := Offset}, Total) -> RangeEnd = min(Offset + Limit - 1, Total - 1), #{ diff --git a/src/handlers/admin/admin_handler_admins_by_id.erl b/src/handlers/admin/admin_handler_admins_by_id.erl new file mode 100644 index 0000000..2463383 --- /dev/null +++ b/src/handlers/admin/admin_handler_admins_by_id.erl @@ -0,0 +1,206 @@ +%%%------------------------------------------------------------------- +%%% @doc Административный обработчик конкретного администратора. +%%% GET /v1/admin/admins/:id – получить администратора +%%% PUT /v1/admin/admins/:id – обновить администратора +%%% DELETE /v1/admin/admins/:id – удалить администратора +%%% +%%% Все операции доступны только суперадмину. +%%% @end +%%%------------------------------------------------------------------- +-module(admin_handler_admins_by_id). +-behaviour(cowboy_handler). + +-export([init/2]). +-export([trails/0]). + +-include("records.hrl"). + +%%% cowboy_handler callback +-spec init(cowboy_req:req(), any()) -> {ok, cowboy_req:req(), any()}. +init(Req, _Opts) -> + case cowboy_req:method(Req) of + <<"GET">> -> get_admin(Req); + <<"PUT">> -> update_admin(Req); + <<"DELETE">> -> delete_admin(Req); + _ -> handler_utils:send_error(Req, 405, <<"Method not allowed">>) + end. + +%%% Swagger metadata +-spec trails() -> [map()]. +trails() -> + IdParam = [#{name => <<"id">>, in => <<"path">>, required => true, schema => #{type => string}}], + [ + #{ % GET by id + path => <<"/v1/admin/admins/:id">>, + method => <<"GET">>, + description => <<"Get admin by ID (superadmin only)">>, + tags => [<<"Admins">>], + parameters => IdParam, + responses => #{ + 200 => #{ + description => <<"Admin details">>, + content => #{<<"application/json">> => #{schema => admin_schema()}} + }, + 404 => #{description => <<"Admin not found">>} + } + }, + #{ % PUT update + path => <<"/v1/admin/admins/:id">>, + method => <<"PUT">>, + description => <<"Update admin (superadmin only)">>, + tags => [<<"Admins">>], + parameters => IdParam, + requestBody => #{ + required => true, + content => #{<<"application/json">> => #{schema => admin_update_schema()}} + }, + responses => #{ + 200 => #{description => <<"Admin updated">>}, + 404 => #{description => <<"Admin not found">>} + } + }, + #{ % DELETE + path => <<"/v1/admin/admins/:id">>, + method => <<"DELETE">>, + description => <<"Delete admin (superadmin only)">>, + tags => [<<"Admins">>], + parameters => IdParam, + responses => #{ + 200 => #{description => <<"Admin deleted">>}, + 404 => #{description => <<"Admin not found">>} + } + } + ]. + +-spec admin_schema() -> map(). +admin_schema() -> + #{ + type => object, + properties => #{ + id => #{type => string}, + email => #{type => string}, + role => #{type => string, enum => [<<"superadmin">>, <<"admin">>, <<"moderator">>, <<"support">>]}, + status => #{type => string, enum => [<<"active">>, <<"blocked">>]}, + nickname => #{type => string, nullable => true}, + avatar_url => #{type => string, nullable => true}, + timezone => #{type => string, nullable => true}, + language => #{type => string, nullable => true}, + phone => #{type => string, nullable => true}, + preferences => #{type => object, nullable => true}, + last_login => #{type => string, format => <<"date-time">>}, + created_at => #{type => string, format => <<"date-time">>}, + updated_at => #{type => string, format => <<"date-time">>} + } + }. + +-spec admin_update_schema() -> map(). +admin_update_schema() -> + #{ + type => object, + properties => #{ + nickname => #{type => string}, + avatar_url => #{type => string}, + timezone => #{type => string}, + language => #{type => string}, + phone => #{type => string}, + preferences => #{type => object} + } + }. + +%%% Internal functions + +%% @doc GET /v1/admin/admins/:id – получение администратора. +-spec get_admin(cowboy_req:req()) -> {ok, binary(), cowboy_req:req()}. +get_admin(Req) -> + case handler_utils:is_superadmin(Req) of + {ok, _AdminId, Req1} -> + Id = cowboy_req:binding(id, Req1), + case logic_admin:get_admin(Id) of + {ok, Admin} -> + handler_utils:send_json(Req1, 200, admin_to_json(Admin)); + {error, not_found} -> + handler_utils:send_error(Req1, 404, <<"Admin not found">>); + {error, _} -> + handler_utils:send_error(Req1, 500, <<"Internal server error">>) + end; + {error, Code, Msg, Req1} -> + handler_utils:send_error(Req1, Code, Msg) + end. + +%% @doc PUT /v1/admin/admins/:id – обновление администратора. +-spec update_admin(cowboy_req:req()) -> {ok, binary(), cowboy_req:req()}. +update_admin(Req) -> + case handler_utils:is_superadmin(Req) of + {ok, _AdminId, Req1} -> + Id = cowboy_req:binding(id, Req1), + {ok, Body, Req2} = cowboy_req:read_body(Req1), + try jsx:decode(Body, [return_maps]) of + UpdatesMap when is_map(UpdatesMap) -> + Updates0 = maps:to_list(UpdatesMap), + Updates = convert_admin_fields(Updates0), + case logic_admin:update_admin(Id, Updates) of + {ok, Admin} -> + handler_utils:send_json(Req2, 200, admin_to_json(Admin)); + {error, not_found} -> + handler_utils:send_error(Req2, 404, <<"Admin not found">>); + {error, _} -> + handler_utils:send_error(Req2, 500, <<"Internal server error">>) + end; + _ -> + handler_utils:send_error(Req2, 400, <<"Invalid JSON">>) + catch + _:_ -> handler_utils:send_error(Req1, 400, <<"Invalid JSON format">>) + end; + {error, Code, Msg, Req1} -> + handler_utils:send_error(Req1, Code, Msg) + end. + +%% @doc DELETE /v1/admin/admins/:id – физическое удаление администратора. +-spec delete_admin(cowboy_req:req()) -> {ok, binary(), cowboy_req:req()}. +delete_admin(Req) -> + case handler_utils:is_superadmin(Req) of + {ok, _AdminId, Req1} -> + Id = cowboy_req:binding(id, Req1), + case logic_admin:delete_admin(Id) of + {ok, _} -> + handler_utils:send_json(Req1, 200, #{status => <<"deleted">>}); + {error, not_found} -> + handler_utils:send_error(Req1, 404, <<"Admin not found">>); + {error, _} -> + handler_utils:send_error(Req1, 500, <<"Internal server error">>) + end; + {error, Code, Msg, Req1} -> + handler_utils:send_error(Req1, Code, Msg) + end. + +%% @private Преобразует бинарные ключи в атомы для обновлений администратора. +-spec convert_admin_fields([{binary(), term()}]) -> [{atom(), term()}]. +convert_admin_fields(Updates) -> + lists:map(fun + ({<<"nickname">>, V}) -> {nickname, V}; + ({<<"avatar_url">>, V}) -> {avatar_url, V}; + ({<<"timezone">>, V}) -> {timezone, V}; + ({<<"language">>, V}) -> {language, V}; + ({<<"phone">>, V}) -> {phone, V}; + ({<<"preferences">>, V}) -> {preferences, V}; + (Other) -> Other + end, Updates). + +%% @private Преобразует запись администратора в JSON-совместимую карту. +-spec admin_to_json(#admin{}) -> map(). +admin_to_json(Admin) -> + #{ + id => Admin#admin.id, + email => Admin#admin.email, + role => atom_to_binary(Admin#admin.role, utf8), + status => atom_to_binary(Admin#admin.status, utf8), + nickname => Admin#admin.nickname, + avatar_url => Admin#admin.avatar_url, + timezone => Admin#admin.timezone, + language => Admin#admin.language, + phone => Admin#admin.phone, + preferences => Admin#admin.preferences, + last_login => handler_utils:datetime_to_iso8601(Admin#admin.last_login), + created_at => handler_utils:datetime_to_iso8601(Admin#admin.created_at), + updated_at => handler_utils:datetime_to_iso8601(Admin#admin.updated_at) + }. \ 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 09a2f57..f696dcb 100644 --- a/src/handlers/admin/admin_handler_audit.erl +++ b/src/handlers/admin/admin_handler_audit.erl @@ -1,6 +1,7 @@ %%%------------------------------------------------------------------- %%% @doc Административный обработчик журнала аудита. %%% GET – список записей аудита с пагинацией и фильтрацией. +%%% Доступно только суперадмину. %%% @end %%%------------------------------------------------------------------- -module(admin_handler_audit). @@ -26,16 +27,15 @@ trails() -> #{ path => <<"/v1/admin/audit">>, method => <<"GET">>, - description => <<"List audit records (admin)">>, + description => <<"List audit records (superadmin only)">>, tags => [<<"Audit">>], parameters => [ - #{name => <<"admin_id">>, in => <<"query">>, schema => #{type => string}, description => <<"Filter by admin ID">>}, - #{name => <<"action">>, in => <<"query">>, schema => #{type => string}, description => <<"Filter by action">>}, - #{name => <<"entity_type">>, in => <<"query">>, schema => #{type => string}, description => <<"Filter by entity type">>}, - #{name => <<"from">>, in => <<"query">>, schema => #{type => string, format => <<"date-time">>}, description => <<"Start timestamp (ISO8601)">>}, - #{name => <<"to">>, in => <<"query">>, schema => #{type => string, format => <<"date-time">>}, description => <<"End timestamp (ISO8601)">>}, - #{name => <<"limit">>, in => <<"query">>, schema => #{type => integer}, description => <<"Page size">>}, - #{name => <<"offset">>, in => <<"query">>, schema => #{type => integer}, description => <<"Offset">>} + #{name => <<"admin_id">>, in => <<"query">>, schema => #{type => string}, description => <<"Filter by admin ID">>}, + #{name => <<"action">>, in => <<"query">>, schema => #{type => string}, description => <<"Filter by action">>}, + #{name => <<"date_from">>, in => <<"query">>, schema => #{type => string, format => <<"date-time">>}, description => <<"Start timestamp (ISO8601)">>}, + #{name => <<"date_to">>, in => <<"query">>, schema => #{type => string, format => <<"date-time">>}, description => <<"End timestamp (ISO8601)">>}, + #{name => <<"limit">>, in => <<"query">>, schema => #{type => integer}, description => <<"Page size">>}, + #{name => <<"offset">>, in => <<"query">>, schema => #{type => integer}, description => <<"Offset">>} ], responses => #{ 200 => #{ @@ -71,16 +71,13 @@ audit_schema() -> %% @doc Получить список записей аудита с пагинацией и фильтрацией. -spec list_audit(cowboy_req:req()) -> {ok, binary(), cowboy_req:req()}. list_audit(Req) -> - case handler_utils:auth_admin(Req) of + case handler_utils:is_superadmin(Req) of {ok, _AdminId, Req1} -> Filters = parse_audit_filters(Req1), Pagination = handler_utils:parse_pagination_params(Req1), - %% Предполагается, что core_admin_audit (или аналогичный) предоставляет list_all/0 - {ok, AllRecords} = core_admin_audit:list(), - Filtered = apply_filters(AllRecords, Filters), - Sorted = sort_audit(Filtered, Pagination), - Total = length(Sorted), - Page = lists:sublist(Sorted, maps:get(offset, Pagination) + 1, maps:get(limit, Pagination)), + AllRecords = core_admin_audit:list(Filters), + Total = length(AllRecords), + Page = lists:sublist(AllRecords, maps:get(offset, Pagination) + 1, maps:get(limit, Pagination)), Json = [audit_to_json(R) || R <- Page], ExtraHeaders = pagination_headers(Pagination, Total), handler_utils:send_json(Req1, 200, Json, ExtraHeaders); @@ -88,65 +85,24 @@ list_audit(Req) -> handler_utils:send_error(Req1, Code, Msg) end. -%% @private Извлечь фильтры из query string. --spec parse_audit_filters(cowboy_req:req()) -> map(). +-spec parse_audit_filters(cowboy_req:req()) -> proplists:proplist(). parse_audit_filters(Req) -> Qs = cowboy_req:parse_qs(Req), - #{ - admin_id => proplists:get_value(<<"admin_id">>, Qs), - action => proplists:get_value(<<"action">>, Qs), - entity_type => proplists:get_value(<<"entity_type">>, Qs), - from => handler_utils:parse_datetime_qs(proplists:get_value(<<"from">>, Qs)), - to => handler_utils:parse_datetime_qs(proplists:get_value(<<"to">>, Qs)) - }. + [ + {admin_id, proplists:get_value(<<"admin_id">>, Qs)}, + {action, proplists:get_value(<<"action">>, Qs)}, + {date_from, parse_datetime_qs(proplists:get_value(<<"date_from">>, Qs))}, + {date_to, parse_datetime_qs(proplists:get_value(<<"date_to">>, Qs))} + ]. -%% @private Применить фильтры к списку записей аудита. --spec apply_filters([#admin_audit{}], map()) -> [#admin_audit{}]. -apply_filters(Records, Filters) -> - AdminId = maps:get(admin_id, Filters, undefined), - Action = maps:get(action, Filters, undefined), - EntityType = maps:get(entity_type, Filters, undefined), - From = maps:get(from, Filters, undefined), - To = maps:get(to, Filters, undefined), - R1 = case AdminId of - undefined -> Records; - _ -> [R || R <- Records, R#admin_audit.admin_id =:= AdminId] - end, - R2 = case Action of - undefined -> R1; - _ -> [R || R <- R1, R#admin_audit.action =:= Action] - end, - R3 = case EntityType of - undefined -> R2; - _ -> [R || R <- R2, R#admin_audit.entity_type =:= EntityType] - end, - R4 = case From of - undefined -> R3; - _ -> [R || R <- R3, R#admin_audit.timestamp >= From] - end, - case To of - undefined -> R4; - _ -> [R || R <- R4, R#admin_audit.timestamp =< To] +-spec parse_datetime_qs(binary() | undefined) -> calendar:datetime() | undefined. +parse_datetime_qs(undefined) -> undefined; +parse_datetime_qs(Bin) -> + case handler_utils:parse_datetime(Bin) of + {ok, Dt} -> Dt; + _ -> undefined end. -%% @private Отсортировать записи аудита. --spec sort_audit([#admin_audit{}], map()) -> [#admin_audit{}]. -sort_audit(Records, #{sort := Sort, order := Order}) -> - Field = binary_to_existing_atom(Sort, utf8), - lists:sort( - fun(A, B) -> - ValA = audit_field(A, Field), - ValB = audit_field(B, Field), - if Order == <<"asc">> -> ValA =< ValB; - true -> ValA >= ValB - end - end, Records). - -audit_field(#admin_audit{timestamp = V}, timestamp) -> V; -audit_field(#admin_audit{action = V}, action) -> V; -audit_field(_, _) -> undefined. - -%% @private Преобразовать запись аудита в JSON-карту. -spec audit_to_json(#admin_audit{}) -> map(). audit_to_json(A) -> #{ @@ -162,7 +118,6 @@ audit_to_json(A) -> reason => A#admin_audit.reason }. -%% @private Сформировать заголовки пагинации. -spec pagination_headers(map(), non_neg_integer()) -> map(). pagination_headers(#{limit := Limit, offset := Offset}, Total) -> RangeEnd = min(Offset + Limit - 1, Total - 1), diff --git a/src/handlers/admin/admin_handler_health.erl b/src/handlers/admin/admin_handler_health.erl index a56e84b..9722999 100644 --- a/src/handlers/admin/admin_handler_health.erl +++ b/src/handlers/admin/admin_handler_health.erl @@ -17,7 +17,7 @@ init(Req, _State) -> trails() -> [ #{ - path => <<"/v1/admin/health">>, + path => <<"/admin/health">>, method => <<"GET">>, description => <<"Admin API health check">>, tags => [<<"Health">>], diff --git a/src/handlers/admin/admin_handler_me.erl b/src/handlers/admin/admin_handler_me.erl index 326fe37..83d145a 100644 --- a/src/handlers/admin/admin_handler_me.erl +++ b/src/handlers/admin/admin_handler_me.erl @@ -1,3 +1,9 @@ +%%%------------------------------------------------------------------- +%%% @doc Административный обработчик профиля текущего администратора. +%%% GET – получить свой профиль. +%%% PUT – обновить свой профиль. +%%% @end +%%%------------------------------------------------------------------- -module(admin_handler_me). -behaviour(cowboy_handler). @@ -6,6 +12,8 @@ -include("records.hrl"). +%%% cowboy_handler callback +-spec init(cowboy_req:req(), any()) -> {ok, cowboy_req:req(), any()}. init(Req, _Opts) -> case cowboy_req:method(Req) of <<"GET">> -> get_me(Req); @@ -13,6 +21,8 @@ init(Req, _Opts) -> _ -> handler_utils:send_error(Req, 405, <<"Method not allowed">>) end. +%%% Swagger metadata +-spec trails() -> [map()]. trails() -> [ #{ % GET @@ -42,6 +52,7 @@ trails() -> } ]. +-spec admin_schema() -> map(). admin_schema() -> #{ type => object, @@ -62,6 +73,7 @@ admin_schema() -> } }. +-spec admin_update_schema() -> map(). admin_update_schema() -> #{ type => object, @@ -75,6 +87,9 @@ admin_update_schema() -> } }. +%%% Internal functions + +-spec get_me(cowboy_req:req()) -> {ok, binary(), cowboy_req:req()}. get_me(Req) -> case handler_utils:auth_admin(Req) of {ok, AdminId, Req1} -> @@ -90,13 +105,15 @@ get_me(Req) -> handler_utils:send_error(Req1, Code, Msg) end. +-spec update_me(cowboy_req:req()) -> {ok, binary(), cowboy_req:req()}. update_me(Req) -> case handler_utils:auth_admin(Req) of {ok, AdminId, Req1} -> {ok, Body, Req2} = cowboy_req:read_body(Req1), try jsx:decode(Body, [return_maps]) of UpdatesMap when is_map(UpdatesMap) -> - Updates = maps:to_list(UpdatesMap), + Updates0 = maps:to_list(UpdatesMap), + Updates = convert_admin_fields(Updates0), case logic_admin:update_admin(AdminId, Updates) of {ok, Admin} -> handler_utils:send_json(Req2, 200, admin_to_json(Admin)); @@ -114,6 +131,21 @@ update_me(Req) -> handler_utils:send_error(Req1, Code, Msg) end. +%% @private Преобразует бинарные ключи в атомы для обновлений администратора. +-spec convert_admin_fields([{binary(), term()}]) -> [{atom(), term()}]. +convert_admin_fields(Updates) -> + lists:map(fun + ({<<"nickname">>, V}) -> {nickname, V}; + ({<<"avatar_url">>, V}) -> {avatar_url, V}; + ({<<"timezone">>, V}) -> {timezone, V}; + ({<<"language">>, V}) -> {language, V}; + ({<<"phone">>, V}) -> {phone, V}; + ({<<"preferences">>, V}) -> {preferences, V}; + (Other) -> Other + end, Updates). + +%% @private Формирует JSON-представление администратора. +-spec admin_to_json(#admin{}) -> map(). admin_to_json(Admin) -> #{ id => Admin#admin.id, @@ -126,12 +158,7 @@ admin_to_json(Admin) -> language => Admin#admin.language, phone => Admin#admin.phone, preferences => Admin#admin.preferences, - last_login => datetime_to_iso8601(Admin#admin.last_login), - created_at => datetime_to_iso8601(Admin#admin.created_at), - updated_at => datetime_to_iso8601(Admin#admin.updated_at) - }. - -datetime_to_iso8601(undefined) -> undefined; -datetime_to_iso8601({{Year, Month, Day}, {Hour, Minute, Second}}) -> - iolist_to_binary(io_lib:format("~4..0B-~2..0B-~2..0BT~2..0B:~2..0B:~2..0BZ", - [Year, Month, Day, Hour, Minute, Second])). \ No newline at end of file + 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 diff --git a/src/handlers/admin/admin_handler_reviews.erl b/src/handlers/admin/admin_handler_reviews.erl index f9799f6..c6f6c44 100644 --- a/src/handlers/admin/admin_handler_reviews.erl +++ b/src/handlers/admin/admin_handler_reviews.erl @@ -88,7 +88,15 @@ review_schema() -> list_reviews(Req) -> case handler_utils:auth_admin(Req) of {ok, _AdminId, Req1} -> - Filters = parse_review_filters(Req1), + Qs = cowboy_req:parse_qs(Req1), + Filters0 = #{ + target_type => normalize_target_type(proplists:get_value(<<"target_type">>, Qs)), + target_id => proplists:get_value(<<"target_id">>, Qs), + user_id => proplists:get_value(<<"user_id">>, Qs), + status => proplists:get_value(<<"status">>, Qs) + }, + % Убираем ключи со значением undefined + Filters = maps:filter(fun(_, V) -> V =/= undefined end, Filters0), Pagination = handler_utils:parse_pagination_params(Req1), {ok, Total, Reviews} = logic_review:list_admin_reviews(Filters, Pagination), Json = [handler_utils:review_to_json(R) || R <- Reviews], @@ -98,6 +106,11 @@ list_reviews(Req) -> handler_utils:send_error(Req1, Code, Msg) end. +%% @private Преобразует бинарный target_type в атом. +normalize_target_type(<<"event">>) -> event; +normalize_target_type(<<"calendar">>) -> calendar; +normalize_target_type(Other) -> Other. + bulk_update_reviews(Req) -> case handler_utils:auth_admin(Req) of {ok, _AdminId, Req1} -> @@ -118,15 +131,6 @@ bulk_update_reviews(Req) -> handler_utils:send_error(Req1, Code, Msg) end. -parse_review_filters(Req) -> - Qs = cowboy_req:parse_qs(Req), - #{ - target_type => proplists:get_value(<<"target_type">>, Qs), - target_id => proplists:get_value(<<"target_id">>, Qs), - user_id => proplists:get_value(<<"user_id">>, Qs), - status => proplists:get_value(<<"status">>, Qs) - }. - pagination_headers(#{limit := Limit, offset := Offset}, Total) -> RangeEnd = min(Offset + Limit - 1, Total - 1), #{ diff --git a/src/handlers/admin/admin_handler_reviews_by_id.erl b/src/handlers/admin/admin_handler_reviews_by_id.erl index 725cccf..b75ecab 100644 --- a/src/handlers/admin/admin_handler_reviews_by_id.erl +++ b/src/handlers/admin/admin_handler_reviews_by_id.erl @@ -109,7 +109,8 @@ update_review(Req) -> try jsx:decode(Body, [return_maps]) of UpdatesMap when is_map(UpdatesMap) -> Updates = maps:to_list(UpdatesMap), - case logic_review:update_review_admin(ReviewId, Updates) of + Converted = convert_review_fields(Updates), + case logic_review:update_review_admin(ReviewId, Converted) of {ok, Review} -> handler_utils:send_json(Req2, 200, handler_utils:review_to_json(Review)); {error, not_found} -> @@ -124,4 +125,19 @@ update_review(Req) -> end; {error, Code, Msg, Req1} -> handler_utils:send_error(Req1, Code, Msg) - end. \ No newline at end of file + end. + +%% @private Преобразует бинарные ключи и значения в атомы, где необходимо. +convert_review_fields(Updates) -> + lists:map(fun convert_field/1, Updates). + +convert_field({<<"status">>, Val}) -> + try binary_to_existing_atom(Val, utf8) of + Atom -> {status, Atom} + catch + error:badarg -> {status, Val} + end; +convert_field({<<"reason">>, Val}) -> {reason, Val}; +convert_field({<<"comment">>, Val}) -> {comment, Val}; +convert_field({<<"rating">>, Val}) -> {rating, Val}; +convert_field(Other) -> Other. \ 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 f5dc544..7278ded 100644 --- a/src/handlers/admin/admin_handler_subscriptions.erl +++ b/src/handlers/admin/admin_handler_subscriptions.erl @@ -87,15 +87,21 @@ parse_subscription_filters(Req) -> }. apply_filters(Subs, Filters) -> - Plan = maps:get(plan, Filters, undefined), - Status = maps:get(status, Filters, undefined), - F1 = case Plan of + PlanBin = maps:get(plan, Filters, undefined), + StatusBin = maps:get(status, Filters, undefined), + F1 = case PlanBin of undefined -> Subs; - _ -> [S || S <- Subs, S#subscription.plan =:= Plan] + _ -> + Plan = try binary_to_existing_atom(PlanBin, utf8) + catch error:badarg -> PlanBin end, + [S || S <- Subs, S#subscription.plan =:= Plan] end, - case Status of + case StatusBin of undefined -> F1; - _ -> [S || S <- F1, S#subscription.status =:= Status] + _ -> + Status = try binary_to_existing_atom(StatusBin, utf8) + catch error:badarg -> StatusBin end, + [S || S <- F1, S#subscription.status =:= Status] end. sort_subscriptions(Subs, #{sort := Sort, order := Order}) -> diff --git a/src/handlers/admin/admin_handler_user_by_id.erl b/src/handlers/admin/admin_handler_user_by_id.erl index 3379f8d..375bd9f 100644 --- a/src/handlers/admin/admin_handler_user_by_id.erl +++ b/src/handlers/admin/admin_handler_user_by_id.erl @@ -1,3 +1,10 @@ +%%%------------------------------------------------------------------- +%%% @doc Административный обработчик конкретного пользователя. +%%% GET – получить пользователя по ID. +%%% PUT – обновить пользователя (роль, статус, причина). +%%% DELETE – мягко удалить пользователя. +%%% @end +%%%------------------------------------------------------------------- -module(admin_handler_user_by_id). -behaviour(cowboy_handler). @@ -6,6 +13,8 @@ -include("records.hrl"). +%%% cowboy_handler callback +-spec init(cowboy_req:req(), any()) -> {ok, cowboy_req:req(), any()}. init(Req, _Opts) -> case cowboy_req:method(Req) of <<"GET">> -> get_user(Req); @@ -14,8 +23,16 @@ init(Req, _Opts) -> _ -> handler_utils:send_error(Req, 405, <<"Method not allowed">>) end. +%%% Swagger metadata +-spec trails() -> [map()]. trails() -> - BaseParams = [#{name => <<"id">>, in => <<"path">>, required => true, schema => #{type => string}}], + BaseParams = [#{ + name => <<"id">>, + in => <<"path">>, + description => <<"User ID">>, + required => true, + schema => #{type => string} + }], [ #{ % GET path => <<"/v1/admin/users/:id">>, @@ -56,20 +73,21 @@ trails() -> } ]. +-spec user_schema() -> map(). user_schema() -> #{ type => object, properties => #{ id => #{type => string}, email => #{type => string}, - role => #{type => string}, - status => #{type => string}, + role => #{type => string, enum => [<<"user">>, <<"bot">>]}, + status => #{type => string, enum => [<<"active">>, <<"frozen">>, <<"deleted">>]}, reason => #{type => string, nullable => true}, nickname => #{type => string, nullable => true}, avatar_url => #{type => string, nullable => true}, timezone => #{type => string, nullable => true}, language => #{type => string, nullable => true}, - social_links => #{type => array, items => #{type => string}}, + social_links => #{type => array, items => #{type => string}, nullable => true}, phone => #{type => string, nullable => true}, preferences => #{type => object, nullable => true}, last_login => #{type => string, format => <<"date-time">>}, @@ -78,21 +96,20 @@ user_schema() -> } }. +-spec user_update_schema() -> map(). user_update_schema() -> #{ type => object, properties => #{ - role => #{type => string, enum => [<<"user">>, <<"bot">>]}, - status => #{type => string, enum => [<<"active">>, <<"frozen">>, <<"deleted">>]}, - reason => #{type => string}, - nickname => #{type => string}, - timezone => #{type => string}, - language => #{type => string}, - phone => #{type => string}, - preferences => #{type => object} + role => #{type => string, enum => [<<"user">>, <<"bot">>]}, + status => #{type => string, enum => [<<"active">>, <<"frozen">>, <<"deleted">>]}, + reason => #{type => string} } }. +%%% Internal functions + +-spec get_user(cowboy_req:req()) -> {ok, binary(), cowboy_req:req()}. get_user(Req) -> case handler_utils:auth_admin(Req) of {ok, _AdminId, Req1} -> @@ -109,6 +126,7 @@ get_user(Req) -> handler_utils:send_error(Req1, Code, Msg) end. +-spec update_user(cowboy_req:req()) -> {ok, binary(), cowboy_req:req()}. update_user(Req) -> case handler_utils:auth_admin(Req) of {ok, _AdminId, Req1} -> @@ -117,7 +135,8 @@ update_user(Req) -> try jsx:decode(Body, [return_maps]) of UpdatesMap when is_map(UpdatesMap) -> Updates = maps:to_list(UpdatesMap), - case logic_user:update_user_admin(UserId, Updates) of + Converted = convert_user_fields(Updates), + case logic_user:update_user_admin(UserId, Converted) of {ok, User} -> handler_utils:send_json(Req2, 200, handler_utils:user_to_json(User)); {error, not_found} -> @@ -134,6 +153,7 @@ update_user(Req) -> handler_utils:send_error(Req1, Code, Msg) end. +-spec delete_user(cowboy_req:req()) -> {ok, binary(), cowboy_req:req()}. delete_user(Req) -> case handler_utils:auth_admin(Req) of {ok, _AdminId, Req1} -> @@ -148,4 +168,25 @@ delete_user(Req) -> end; {error, Code, Msg, Req1} -> handler_utils:send_error(Req1, Code, Msg) - end. \ No newline at end of file + end. + +%% @private Преобразует бинарные ключи и значения в атомы, где необходимо. +-spec convert_user_fields([{binary(), term()}]) -> [{atom(), term()}]. +convert_user_fields(Updates) -> + lists:map(fun convert_field/1, Updates). + +-spec convert_field({binary(), term()}) -> {atom(), term()}. +convert_field({<<"role">>, <<"user">>}) -> {role, user}; +convert_field({<<"role">>, <<"bot">>}) -> {role, bot}; +convert_field({<<"status">>, <<"active">>}) -> {status, active}; +convert_field({<<"status">>, <<"frozen">>}) -> {status, frozen}; +convert_field({<<"status">>, <<"deleted">>}) -> {status, deleted}; +convert_field({<<"reason">>, Val}) -> {reason, Val}; +convert_field({<<"nickname">>, Val}) -> {nickname, Val}; +convert_field({<<"avatar_url">>, Val}) -> {avatar_url, Val}; +convert_field({<<"timezone">>, Val}) -> {timezone, Val}; +convert_field({<<"language">>, Val}) -> {language, Val}; +convert_field({<<"social_links">>, Val}) -> {social_links, Val}; +convert_field({<<"phone">>, Val}) -> {phone, Val}; +convert_field({<<"preferences">>, Val}) -> {preferences, Val}; +convert_field(Other) -> Other. \ No newline at end of file diff --git a/src/handlers/handler_calendar_by_id.erl b/src/handlers/handler_calendar_by_id.erl index 5ec024a..009623c 100644 --- a/src/handlers/handler_calendar_by_id.erl +++ b/src/handlers/handler_calendar_by_id.erl @@ -1,6 +1,5 @@ %%%------------------------------------------------------------------- %%% @doc Обработчик конкретного календаря (клиентский API). -%%% %%% GET – получить информацию о календаре. %%% PUT – обновить календарь (владельцем). %%% DELETE – удалить календарь (владельцем). @@ -16,11 +15,7 @@ %%% cowboy_handler callback -spec init(cowboy_req:req(), any()) -> {ok, cowboy_req:req(), any()}. -init(Req, Opts) -> - handle(Req, Opts). - --spec handle(cowboy_req:req(), any()) -> {ok, cowboy_req:req(), any()}. -handle(Req, _Opts) -> +init(Req, _Opts) -> case cowboy_req:method(Req) of <<"GET">> -> get_calendar(Req); <<"PUT">> -> update_calendar(Req); @@ -31,15 +26,13 @@ handle(Req, _Opts) -> %%% Swagger metadata -spec trails() -> [map()]. trails() -> - BaseParams = [ - #{ - name => <<"id">>, - in => <<"path">>, - description => <<"Calendar ID">>, - required => true, - schema => #{type => string} - } - ], + BaseParams = [#{ + name => <<"id">>, + in => <<"path">>, + description => <<"Calendar ID">>, + required => true, + schema => #{type => string} + }], [ #{ % GET path => <<"/v1/calendars/:id">>, @@ -51,9 +44,7 @@ trails() -> 200 => #{ description => <<"Calendar details">>, content => #{<<"application/json">> => #{schema => calendar_schema()}} - }, - 403 => #{description => <<"Access denied">>}, - 404 => #{description => <<"Calendar not found">>} + } } }, #{ % PUT @@ -67,10 +58,7 @@ trails() -> content => #{<<"application/json">> => #{schema => calendar_update_schema()}} }, responses => #{ - 200 => #{description => <<"Calendar updated">>}, - 400 => #{description => <<"Invalid request">>}, - 403 => #{description => <<"Access denied">>}, - 404 => #{description => <<"Calendar not found">>} + 200 => #{description => <<"Calendar updated">>} } }, #{ % DELETE @@ -80,9 +68,7 @@ trails() -> tags => [<<"Calendars">>], parameters => BaseParams, responses => #{ - 200 => #{description => <<"Calendar deleted">>}, - 403 => #{description => <<"Access denied">>}, - 404 => #{description => <<"Calendar not found">>} + 200 => #{description => <<"Calendar deleted">>} } } ]. @@ -90,24 +76,18 @@ trails() -> -spec calendar_schema() -> map(). calendar_schema() -> #{ - type => object, + type => object, properties => #{ id => #{type => string}, owner_id => #{type => string}, title => #{type => string}, description => #{type => string}, - short_name => #{type => string, nullable => true}, - category => #{type => string, nullable => true}, - color => #{type => string, nullable => true}, - image_url => #{type => string, nullable => true}, - settings => #{type => object, nullable => true}, - tags => #{type => array, items => #{type => string}}, type => #{type => string, enum => [<<"personal">>, <<"commercial">>]}, - confirmation => #{type => string, description => <<"auto, manual, or {timeout, N}">>}, + confirmation => #{type => string, enum => [<<"auto">>, <<"manual">>]}, + tags => #{type => array, items => #{type => string}}, rating_avg => #{type => number, format => float}, rating_count => #{type => integer}, - status => #{type => string, enum => [<<"active">>, <<"frozen">>, <<"deleted">>]}, - reason => #{type => string, nullable => true}, + status => #{type => string}, created_at => #{type => string, format => <<"date-time">>}, updated_at => #{type => string, format => <<"date-time">>} } @@ -116,22 +96,18 @@ calendar_schema() -> -spec calendar_update_schema() -> map(). calendar_update_schema() -> #{ - type => object, + type => object, properties => #{ title => #{type => string}, description => #{type => string}, - type => #{type => string, enum => [<<"personal">>, <<"commercial">>]}, - confirmation => #{type => string, description => <<"auto, manual, or {timeout, N}">>}, + type => #{type => string}, + confirmation => #{type => string}, tags => #{type => array, items => #{type => string}} } }. -%%%=================================================================== -%%% HTTP-методы -%%%=================================================================== +%%% Internal functions -%% @doc GET /v1/calendars/:id — получение календаря. --spec get_calendar(cowboy_req:req()) -> {ok, binary(), cowboy_req:req()}. get_calendar(Req) -> case handler_utils:auth_user(Req) of {ok, UserId, Req1} -> @@ -150,8 +126,6 @@ get_calendar(Req) -> handler_utils:send_error(Req1, Code, Message) end. -%% @doc PUT /v1/calendars/:id — обновление календаря. --spec update_calendar(cowboy_req:req()) -> {ok, binary(), cowboy_req:req()}. update_calendar(Req) -> case handler_utils:auth_user(Req) of {ok, UserId, Req1} -> @@ -159,7 +133,8 @@ update_calendar(Req) -> {ok, Body, Req2} = cowboy_req:read_body(Req1), try jsx:decode(Body, [return_maps]) of UpdatesMap when is_map(UpdatesMap) -> - Updates = maps:to_list(UpdatesMap), + Updates0 = maps:to_list(UpdatesMap), + Updates = convert_calendar_fields(Updates0), case logic_calendar:update_calendar(UserId, CalendarId, Updates) of {ok, Calendar} -> handler_utils:send_json(Req2, 200, handler_utils:calendar_to_json(Calendar)); @@ -179,8 +154,6 @@ update_calendar(Req) -> handler_utils:send_error(Req1, Code, Message) end. -%% @doc DELETE /v1/calendars/:id — удаление календаря. --spec delete_calendar(cowboy_req:req()) -> {ok, binary(), cowboy_req:req()}. delete_calendar(Req) -> case handler_utils:auth_user(Req) of {ok, UserId, Req1} -> @@ -197,4 +170,17 @@ delete_calendar(Req) -> end; {error, Code, Message, Req1} -> handler_utils:send_error(Req1, Code, Message) - end. \ No newline at end of file + end. + +%% @private Преобразует бинарные ключи и значения в атомы для обновлений календаря. +-spec convert_calendar_fields([{binary(), term()}]) -> [{atom(), term()}]. +convert_calendar_fields(Updates) -> + lists:map(fun convert_field/1, Updates). + +-spec convert_field({binary(), term()}) -> {atom(), term()}. +convert_field({<<"title">>, Val}) -> {title, Val}; +convert_field({<<"description">>, Val}) -> {description, Val}; +convert_field({<<"type">>, Val}) -> {type, Val}; +convert_field({<<"confirmation">>, Val}) -> {confirmation, Val}; +convert_field({<<"tags">>, Val}) -> {tags, Val}; +convert_field(Other) -> Other. \ No newline at end of file diff --git a/src/handlers/handler_health.erl b/src/handlers/handler_health.erl index 26608cc..f19c273 100644 --- a/src/handlers/handler_health.erl +++ b/src/handlers/handler_health.erl @@ -19,7 +19,7 @@ init(Req, Opts) -> trails() -> [ #{ - path => <<"/v1/health">>, + path => <<"/health">>, method => <<"GET">>, description => <<"API health check">>, tags => [<<"Health">>], diff --git a/src/handlers/handler_utils.erl b/src/handlers/handler_utils.erl index ee3b478..5d3efc1 100644 --- a/src/handlers/handler_utils.erl +++ b/src/handlers/handler_utils.erl @@ -25,8 +25,8 @@ ticket_to_json/1, calendar_to_json/1, subscription_to_json/1, - trails_for_crud/4 -]). + trails_for_crud/4, + is_superadmin/1]). -include("records.hrl"). @@ -48,6 +48,19 @@ auth_admin(Req) -> {error, Code, Msg, Req1} end. +%% @doc Проверяет, что запрос выполняет суперадмин. +-spec is_superadmin(cowboy_req:req()) -> + {ok, binary(), cowboy_req:req()} | {error, integer(), binary(), cowboy_req:req()}. +is_superadmin(Req) -> + case handler_utils:auth_admin(Req) of + {ok, AdminId, Req1} -> + case admin_utils:is_superadmin(AdminId) of + true -> {ok, AdminId, Req1}; + false -> {error, 403, <<"Only superadmin allowed">>, Req1} + end; + Error -> Error + end. + %% @doc Проверяет, что запрос содержит валидный токен пользователя. -spec auth_user(cowboy_req:req()) -> {ok, binary(), cowboy_req:req()} | {error, integer(), binary(), cowboy_req:req()}. diff --git a/src/handlers/swagger_docs_handler.erl b/src/handlers/swagger_docs_handler.erl index 0e52499..24cf3cf 100644 --- a/src/handlers/swagger_docs_handler.erl +++ b/src/handlers/swagger_docs_handler.erl @@ -7,8 +7,8 @@ %%% GET / – индексная страница с выбором API %%% GET /admin/ – Swagger UI для административного API %%% GET /admin/swagger.json – OpenAPI-спецификация (admin) -%%% GET /client/ – Swagger UI для клиентского API -%%% GET /client/swagger.json – OpenAPI-спецификация (client) +%%% GET /user/ – Swagger UI для клиентского API +%%% GET /user/swagger.json – OpenAPI-спецификация (user) %%% @end %%%------------------------------------------------------------------- -module(swagger_docs_handler). @@ -35,12 +35,12 @@ handle(<<"/admin/">>, Req) -> serve_ui(admin, Req); handle(<<"/admin/swagger.json">>, Req) -> serve_json(admin, Req); -handle(<<"/client">>, Req) -> - redirect_to_slash(<<"/client/">>, Req); -handle(<<"/client/">>, Req) -> - serve_ui(client, Req); -handle(<<"/client/swagger.json">>, Req) -> - serve_json(client, Req); +handle(<<"/user">>, Req) -> + redirect_to_slash(<<"/user/">>, Req); +handle(<<"/user/">>, Req) -> + serve_ui(user, Req); +handle(<<"/user/swagger.json">>, Req) -> + serve_json(user, Req); handle(_, Req) -> cowboy_req:reply(404, #{}, <<"Not Found">>, Req), {ok, [], []}. @@ -58,7 +58,7 @@ serve_index(Req) ->