Рефакторинг обработчиков. Часть 1 #21
This commit is contained in:
@@ -1,117 +1,103 @@
|
||||
-module(admin_handler_admins).
|
||||
-behaviour(cowboy_handler).
|
||||
|
||||
-include("records.hrl").
|
||||
|
||||
-export([init/2]).
|
||||
-export([trails/0]).
|
||||
|
||||
-include("records.hrl").
|
||||
|
||||
init(Req, _Opts) ->
|
||||
case cowboy_req:method(Req) of
|
||||
<<"GET">> -> list_admins(Req);
|
||||
<<"POST">> -> create_admin(Req);
|
||||
<<"PUT">> -> update_admin_role(Req);
|
||||
_ -> send_error(Req, 405, <<"Method not allowed">>)
|
||||
_ -> handler_utils:send_error(Req, 405, <<"Method not allowed">>)
|
||||
end.
|
||||
|
||||
list_admins(Req) ->
|
||||
case handler_auth:authenticate(Req) of
|
||||
{ok, AdminId, Req1} ->
|
||||
case admin_utils:check_role(AdminId, superadmin) of
|
||||
true ->
|
||||
Admins = core_admin:list_all(),
|
||||
Json = [admin_to_json(A) || A <- Admins],
|
||||
send_json(Req1, 200, Json);
|
||||
false ->
|
||||
send_error(Req1, 403, <<"Superadmin access required">>)
|
||||
end;
|
||||
{error, Code, Message, Req1} ->
|
||||
send_error(Req1, Code, Message)
|
||||
end.
|
||||
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()
|
||||
}}}
|
||||
}
|
||||
}
|
||||
}
|
||||
].
|
||||
|
||||
create_admin(Req) ->
|
||||
case handler_auth:authenticate(Req) of
|
||||
{ok, AdminId, Req1} ->
|
||||
case admin_utils:check_role(AdminId, superadmin) of
|
||||
true ->
|
||||
{ok, Body, Req2} = cowboy_req:read_body(Req1),
|
||||
try jsx:decode(Body, [return_maps]) of
|
||||
#{<<"email">> := Email, <<"password">> := Password, <<"role">> := RoleBin} ->
|
||||
Role = binary_to_atom(RoleBin, utf8),
|
||||
case core_admin:create(Email, Password, Role) of
|
||||
{ok, Admin} ->
|
||||
send_json(Req2, 201, admin_to_json(Admin));
|
||||
{error, email_exists} ->
|
||||
send_error(Req2, 409, <<"Email already exists">>);
|
||||
{error, Reason} ->
|
||||
send_error(Req2, 500, Reason)
|
||||
end;
|
||||
_ ->
|
||||
send_error(Req2, 400, <<"Missing required fields (email, password, role)">>)
|
||||
catch
|
||||
_:_ -> send_error(Req2, 400, <<"Invalid JSON">>)
|
||||
end;
|
||||
false ->
|
||||
send_error(Req1, 403, <<"Superadmin access required">>)
|
||||
end;
|
||||
{error, Code, Message, Req1} ->
|
||||
send_error(Req1, Code, Message)
|
||||
end.
|
||||
|
||||
update_admin_role(Req) ->
|
||||
case handler_auth:authenticate(Req) of
|
||||
{ok, AdminId, Req1} ->
|
||||
case admin_utils:check_role(AdminId, superadmin) of
|
||||
true ->
|
||||
AdminIdToUpdate = cowboy_req:binding(id, Req1),
|
||||
{ok, Body, Req2} = cowboy_req:read_body(Req1),
|
||||
try jsx:decode(Body, [return_maps]) of
|
||||
#{<<"role">> := RoleBin} ->
|
||||
NewRole = binary_to_atom(RoleBin, utf8),
|
||||
case core_admin:update_role(AdminIdToUpdate, NewRole) of
|
||||
{ok, Admin} ->
|
||||
send_json(Req2, 200, admin_to_json(Admin));
|
||||
{error, not_found} ->
|
||||
send_error(Req2, 404, <<"Admin not found">>);
|
||||
{error, Reason} ->
|
||||
send_error(Req2, 500, Reason)
|
||||
end;
|
||||
_ ->
|
||||
send_error(Req2, 400, <<"Missing 'role' field">>)
|
||||
catch
|
||||
_:_ -> send_error(Req2, 400, <<"Invalid JSON">>)
|
||||
end;
|
||||
false ->
|
||||
send_error(Req1, 403, <<"Superadmin access required">>)
|
||||
end;
|
||||
{error, Code, Message, Req1} ->
|
||||
send_error(Req1, Code, Message)
|
||||
end.
|
||||
|
||||
admin_to_json(A) ->
|
||||
admin_schema() ->
|
||||
#{
|
||||
id => A#admin.id,
|
||||
email => A#admin.email,
|
||||
role => A#admin.role,
|
||||
status => A#admin.status,
|
||||
created_at => datetime_to_iso8601(A#admin.created_at),
|
||||
updated_at => datetime_to_iso8601(A#admin.updated_at)
|
||||
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">>}
|
||||
}
|
||||
}.
|
||||
|
||||
datetime_to_iso8601({{Y,M,D},{H,Min,S}}) ->
|
||||
iolist_to_binary(io_lib:format("~4..0B-~2..0B-~2..0BT~2..0B:~2..0B:~2..0BZ", [Y,M,D,H,Min,S]));
|
||||
datetime_to_iso8601(_) -> null.
|
||||
list_admins(Req) ->
|
||||
case handler_utils:auth_admin(Req) of
|
||||
{ok, _AdminId, Req1} ->
|
||||
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),
|
||||
handler_utils:send_json(Req1, 200, Json, ExtraHeaders);
|
||||
{error, Code, Msg, Req1} ->
|
||||
handler_utils:send_error(Req1, Code, Msg)
|
||||
end.
|
||||
|
||||
send_json(Req, Status, Data) ->
|
||||
Headers = #{
|
||||
<<"content-type">> => <<"application/json">>,
|
||||
<<"access-control-allow-origin">> => <<"*">>,
|
||||
<<"access-control-expose-headers">> => <<"Content-Range">>
|
||||
},
|
||||
Body = jsx:encode(Data),
|
||||
cowboy_req:reply(Status, Headers, Body, Req),
|
||||
{ok, Body, []}.
|
||||
parse_admin_filters(Req) ->
|
||||
Qs = cowboy_req:parse_qs(Req),
|
||||
#{
|
||||
role => proplists:get_value(<<"role">>, Qs),
|
||||
status => proplists:get_value(<<"status">>, Qs)
|
||||
}.
|
||||
|
||||
send_error(Req, Code, Message) ->
|
||||
Body = jsx:encode(#{error => Message}),
|
||||
Req2 = cowboy_req:reply(Code, #{<<"content-type">> => <<"application/json">>}, Body, Req),
|
||||
{ok, Req2, []}.
|
||||
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: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)
|
||||
}.
|
||||
|
||||
pagination_headers(#{limit := Limit, offset := Offset}, Total) ->
|
||||
RangeEnd = min(Offset + Limit - 1, Total - 1),
|
||||
#{
|
||||
<<"content-range">> => iolist_to_binary(io_lib:format("items ~B-~B/~B", [Offset, RangeEnd, Total])),
|
||||
<<"x-total-count">> => integer_to_binary(Total),
|
||||
<<"access-control-expose-headers">> => <<"Content-Range, X-Total-Count">>
|
||||
}.
|
||||
@@ -1,63 +1,173 @@
|
||||
%%%-------------------------------------------------------------------
|
||||
%%% @doc Административный обработчик журнала аудита.
|
||||
%%% GET – список записей аудита с пагинацией и фильтрацией.
|
||||
%%% @end
|
||||
%%%-------------------------------------------------------------------
|
||||
-module(admin_handler_audit).
|
||||
-behaviour(cowboy_handler).
|
||||
|
||||
-export([init/2]).
|
||||
-export([trails/0]).
|
||||
|
||||
-include("records.hrl").
|
||||
|
||||
-export([init/2]).
|
||||
|
||||
%%% cowboy_handler callback
|
||||
-spec init(cowboy_req:req(), any()) -> {ok, cowboy_req:req(), any()}.
|
||||
init(Req, _Opts) ->
|
||||
case cowboy_req:method(Req) of
|
||||
<<"GET">> ->
|
||||
case handler_auth:authenticate(Req) of
|
||||
{ok, AdminId, Req1} ->
|
||||
case admin_utils:check_role(AdminId, superadmin) of
|
||||
true ->
|
||||
Filters = parse_filters(Req1),
|
||||
Entries = core_admin_audit:list(Filters),
|
||||
Json = [audit_to_json(E) || E <- Entries],
|
||||
send_json(Req1, 200, Json);
|
||||
false ->
|
||||
send_error(Req1, 403, <<"Superadmin access required">>)
|
||||
end;
|
||||
{error, Code, Message, Req1} ->
|
||||
send_error(Req1, Code, Message)
|
||||
end;
|
||||
_ ->
|
||||
send_error(Req, 405, <<"Method not allowed">>)
|
||||
<<"GET">> -> list_audit(Req);
|
||||
_ -> handler_utils:send_error(Req, 405, <<"Method not allowed">>)
|
||||
end.
|
||||
|
||||
parse_filters(Req) ->
|
||||
Qs = cowboy_req:parse_qs(Req),
|
||||
lists:filtermap(fun
|
||||
({<<"admin_id">>, Val}) -> {true, {admin_id, Val}};
|
||||
({<<"action">>, Val}) -> {true, {action, Val}};
|
||||
(_) -> false
|
||||
end, Qs).
|
||||
%%% Swagger metadata
|
||||
-spec trails() -> [map()].
|
||||
trails() ->
|
||||
[
|
||||
#{
|
||||
path => <<"/v1/admin/audit">>,
|
||||
method => <<"GET">>,
|
||||
description => <<"List audit records (admin)">>,
|
||||
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">>}
|
||||
],
|
||||
responses => #{
|
||||
200 => #{
|
||||
description => <<"Array of audit records">>,
|
||||
content => #{<<"application/json">> => #{schema => #{
|
||||
type => array,
|
||||
items => audit_schema()
|
||||
}}}
|
||||
}
|
||||
}
|
||||
}
|
||||
].
|
||||
|
||||
audit_to_json(E) ->
|
||||
audit_schema() ->
|
||||
#{
|
||||
id => E#admin_audit.id,
|
||||
admin_id => E#admin_audit.admin_id,
|
||||
email => E#admin_audit.email,
|
||||
role => E#admin_audit.role,
|
||||
action => E#admin_audit.action,
|
||||
entity_type => E#admin_audit.entity_type,
|
||||
entity_id => E#admin_audit.entity_id,
|
||||
timestamp => datetime_to_iso8601(E#admin_audit.timestamp),
|
||||
ip => E#admin_audit.ip,
|
||||
reason => E#admin_audit.reason
|
||||
type => object,
|
||||
properties => #{
|
||||
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}
|
||||
}
|
||||
}.
|
||||
|
||||
datetime_to_iso8601({{Y,M,D},{H,Min,S}}) ->
|
||||
iolist_to_binary(io_lib:format("~4..0B-~2..0B-~2..0BT~2..0B:~2..0B:~2..0BZ", [Y,M,D,H,Min,S]));
|
||||
datetime_to_iso8601(_) -> null.
|
||||
%%% Internal functions
|
||||
|
||||
send_json(Req, Status, Data) ->
|
||||
Body = jsx:encode(Data),
|
||||
Req2 = cowboy_req:reply(Status, #{<<"content-type">> => <<"application/json">>}, Body, Req),
|
||||
{ok, Req2, []}.
|
||||
%% @doc Получить список записей аудита с пагинацией и фильтрацией.
|
||||
-spec list_audit(cowboy_req:req()) -> {ok, binary(), cowboy_req:req()}.
|
||||
list_audit(Req) ->
|
||||
case handler_utils:auth_admin(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)),
|
||||
Json = [audit_to_json(R) || R <- Page],
|
||||
ExtraHeaders = pagination_headers(Pagination, Total),
|
||||
handler_utils:send_json(Req1, 200, Json, ExtraHeaders);
|
||||
{error, Code, Msg, Req1} ->
|
||||
handler_utils:send_error(Req1, Code, Msg)
|
||||
end.
|
||||
|
||||
send_error(Req, Code, Message) ->
|
||||
Body = jsx:encode(#{error => Message}),
|
||||
Req2 = cowboy_req:reply(Code, #{<<"content-type">> => <<"application/json">>}, Body, Req),
|
||||
{ok, Req2, []}.
|
||||
%% @private Извлечь фильтры из query string.
|
||||
-spec parse_audit_filters(cowboy_req:req()) -> map().
|
||||
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))
|
||||
}.
|
||||
|
||||
%% @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]
|
||||
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) ->
|
||||
#{
|
||||
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
|
||||
}.
|
||||
|
||||
%% @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">>
|
||||
}.
|
||||
@@ -1,156 +1,177 @@
|
||||
%%%-------------------------------------------------------------------
|
||||
%%% @doc Административный обработчик бан-слов.
|
||||
%%% GET – список всех слов с пагинацией.
|
||||
%%% POST – добавить новое слово.
|
||||
%%% DELETE – удалить слово по :word.
|
||||
%%% @end
|
||||
%%%-------------------------------------------------------------------
|
||||
-module(admin_handler_banned_words).
|
||||
-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:binding(word, Req) of
|
||||
undefined -> handle_collection(Req);
|
||||
Word -> handle_item(Word, Req)
|
||||
end.
|
||||
|
||||
handle_collection(Req) ->
|
||||
case cowboy_req:method(Req) of
|
||||
<<"GET">> -> list_banned_words(Req);
|
||||
<<"POST">> -> add_banned_word(Req);
|
||||
_ -> send_error(Req, 405, <<"Method not allowed">>)
|
||||
<<"GET">> -> list_words(Req);
|
||||
<<"POST">> -> add_word(Req);
|
||||
<<"DELETE">> -> delete_word(Req);
|
||||
_ -> handler_utils:send_error(Req, 405, <<"Method not allowed">>)
|
||||
end.
|
||||
|
||||
handle_item(Word, Req) ->
|
||||
case cowboy_req:method(Req) of
|
||||
<<"DELETE">> -> delete_banned_word(Word, Req);
|
||||
<<"PUT">> -> update_banned_word(Word, Req);
|
||||
_ -> send_error(Req, 405, <<"Method not allowed">>)
|
||||
end.
|
||||
-spec trails() -> [map()].
|
||||
trails() ->
|
||||
[
|
||||
#{ % GET list
|
||||
path => <<"/v1/admin/banned-words">>,
|
||||
method => <<"GET">>,
|
||||
description => <<"List all banned words (admin)">>,
|
||||
tags => [<<"Banned Words">>],
|
||||
parameters => [
|
||||
#{name => <<"limit">>, in => <<"query">>, schema => #{type => integer}, description => <<"Page size">>},
|
||||
#{name => <<"offset">>, in => <<"query">>, schema => #{type => integer}, description => <<"Offset">>}
|
||||
],
|
||||
responses => #{
|
||||
200 => #{
|
||||
description => <<"Array of banned words">>,
|
||||
content => #{<<"application/json">> => #{schema => #{
|
||||
type => array,
|
||||
items => banned_word_schema()
|
||||
}}}
|
||||
}
|
||||
}
|
||||
},
|
||||
#{ % POST add
|
||||
path => <<"/v1/admin/banned-words">>,
|
||||
method => <<"POST">>,
|
||||
description => <<"Add a new banned word">>,
|
||||
tags => [<<"Banned Words">>],
|
||||
requestBody => #{
|
||||
required => true,
|
||||
content => #{<<"application/json">> => #{schema => #{
|
||||
type => object,
|
||||
required => [<<"word">>],
|
||||
properties => #{
|
||||
word => #{type => string}
|
||||
}
|
||||
}}}
|
||||
},
|
||||
responses => #{
|
||||
201 => #{description => <<"Word added">>},
|
||||
409 => #{description => <<"Word already exists">>}
|
||||
}
|
||||
},
|
||||
#{ % DELETE by word
|
||||
path => <<"/v1/admin/banned-words/:word">>,
|
||||
method => <<"DELETE">>,
|
||||
description => <<"Remove a banned word">>,
|
||||
tags => [<<"Banned Words">>],
|
||||
parameters => [
|
||||
#{
|
||||
name => <<"word">>,
|
||||
in => <<"path">>,
|
||||
description => <<"The word to remove">>,
|
||||
required => true,
|
||||
schema => #{type => string}
|
||||
}
|
||||
],
|
||||
responses => #{
|
||||
200 => #{description => <<"Word removed">>},
|
||||
404 => #{description => <<"Word not found">>}
|
||||
}
|
||||
}
|
||||
].
|
||||
|
||||
%% ================== GET /banned-words ==================
|
||||
list_banned_words(Req) ->
|
||||
case auth_admin(Req) of
|
||||
{ok, _AdminId, Req1} ->
|
||||
Words = core_banned_words:list_banned_words(),
|
||||
send_json(Req1, 200, [banned_word_to_json(W) || W <- Words]);
|
||||
{error, Code, Message, Req1} ->
|
||||
send_error(Req1, Code, Message)
|
||||
end.
|
||||
|
||||
%% ================== POST /banned-words ==================
|
||||
add_banned_word(Req) ->
|
||||
case auth_admin(Req) of
|
||||
{ok, AdminId, Req1} ->
|
||||
{ok, Body, Req2} = cowboy_req:read_body(Req1),
|
||||
try jsx:decode(Body, [return_maps]) of
|
||||
#{<<"word">> := Word} when byte_size(Word) > 0 ->
|
||||
case core_banned_words:add_banned_word(Word, AdminId) of
|
||||
{ok, BW} ->
|
||||
log_audit(AdminId, <<"add_banned_word">>, <<"banned_word">>, BW#banned_word.id, <<"">>),
|
||||
send_json(Req2, 201, banned_word_to_json(BW));
|
||||
{error, already_exists} ->
|
||||
send_error(Req2, 409, <<"Word already exists">>);
|
||||
{error, _} ->
|
||||
send_error(Req2, 500, <<"Internal server error">>)
|
||||
end;
|
||||
_ ->
|
||||
send_error(Req2, 400, <<"Missing or empty 'word'">>)
|
||||
catch
|
||||
_:_ -> send_error(Req2, 400, <<"Invalid JSON">>)
|
||||
end;
|
||||
{error, Code, Message, Req1} ->
|
||||
send_error(Req1, Code, Message)
|
||||
end.
|
||||
|
||||
%% ================== DELETE /banned-words/:word ==================
|
||||
delete_banned_word(Word, Req) ->
|
||||
case auth_admin(Req) of
|
||||
{ok, AdminId, Req1} ->
|
||||
case core_banned_words:remove_banned_word(Word) of
|
||||
{ok, deleted} ->
|
||||
log_audit(AdminId, <<"delete_banned_word">>, <<"banned_word">>, Word, <<"">>),
|
||||
send_json(Req1, 200, #{status => <<"deleted">>});
|
||||
{error, not_found} ->
|
||||
send_error(Req1, 404, <<"Word not found">>)
|
||||
end;
|
||||
{error, Code, Message, Req1} ->
|
||||
send_error(Req1, Code, Message)
|
||||
end.
|
||||
|
||||
%% ================== PUT /banned-words/:word ==================
|
||||
update_banned_word(OldWord, Req) ->
|
||||
case auth_admin(Req) of
|
||||
{ok, AdminId, Req1} ->
|
||||
{ok, Body, Req2} = cowboy_req:read_body(Req1),
|
||||
try jsx:decode(Body, [return_maps]) of
|
||||
#{<<"word">> := NewWord} when byte_size(NewWord) > 0 ->
|
||||
case core_banned_words:update_banned_word(OldWord, NewWord) of
|
||||
{ok, BW} ->
|
||||
log_audit(AdminId, <<"update_banned_word">>, <<"banned_word">>, BW#banned_word.id, <<"">>),
|
||||
send_json(Req2, 200, banned_word_to_json(BW));
|
||||
{error, not_found} ->
|
||||
send_error(Req2, 404, <<"Word not found">>);
|
||||
{error, _} ->
|
||||
send_error(Req2, 500, <<"Internal server error">>)
|
||||
end;
|
||||
_ ->
|
||||
send_error(Req2, 400, <<"Missing or empty 'word'">>)
|
||||
catch
|
||||
_:_ -> send_error(Req2, 400, <<"Invalid JSON">>)
|
||||
end;
|
||||
{error, Code, Message, Req1} ->
|
||||
send_error(Req1, Code, Message)
|
||||
end.
|
||||
|
||||
%% ── Аудит ──────────────────────────────────────────────
|
||||
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, <<"127.0.0.1">>, Reason);
|
||||
_ -> ok
|
||||
end.
|
||||
|
||||
%% ================== Аутентификация ==================
|
||||
auth_admin(Req) ->
|
||||
case handler_auth:authenticate(Req) of
|
||||
{ok, AdminId, Req1} ->
|
||||
case admin_utils:is_admin(AdminId) of
|
||||
true -> {ok, AdminId, Req1};
|
||||
false -> {error, 403, <<"Admin access required">>, Req1}
|
||||
end;
|
||||
{error, Code, Message, Req1} ->
|
||||
{error, Code, Message, Req1}
|
||||
end.
|
||||
|
||||
%% ================== Сериализация ==================
|
||||
banned_word_to_json(BW) ->
|
||||
banned_word_schema() ->
|
||||
#{
|
||||
id => BW#banned_word.id,
|
||||
word => BW#banned_word.word,
|
||||
added_by => BW#banned_word.added_by,
|
||||
added_at => datetime_to_iso8601(BW#banned_word.added_at)
|
||||
type => object,
|
||||
properties => #{
|
||||
id => #{type => string},
|
||||
word => #{type => string},
|
||||
added_by => #{type => string, nullable => true},
|
||||
added_at => #{type => string, format => <<"date-time">>, nullable => true}
|
||||
}
|
||||
}.
|
||||
|
||||
%%% Internal functions
|
||||
|
||||
list_words(Req) ->
|
||||
case handler_utils:auth_admin(Req) of
|
||||
{ok, _AdminId, Req1} ->
|
||||
Pagination = handler_utils:parse_pagination_params(Req1),
|
||||
%% core_banned_words:list_banned_words() возвращает список, а не {ok, List}
|
||||
AllWords = core_banned_words:list_banned_words(),
|
||||
BannedWords = lists:sort(AllWords),
|
||||
Total = length(BannedWords),
|
||||
Page = lists:sublist(BannedWords, maps:get(offset, Pagination) + 1, maps:get(limit, Pagination)),
|
||||
Json = [word_to_map(W) || W <- Page],
|
||||
ExtraHeaders = pagination_headers(Pagination, Total),
|
||||
handler_utils:send_json(Req1, 200, Json, ExtraHeaders);
|
||||
{error, Code, Msg, Req1} ->
|
||||
handler_utils:send_error(Req1, Code, Msg)
|
||||
end.
|
||||
|
||||
add_word(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
|
||||
#{<<"word">> := Word} ->
|
||||
case core_banned_words:add_banned_word(Word, AdminId) of
|
||||
{ok, _} ->
|
||||
handler_utils:send_json(Req2, 201, #{status => <<"added">>});
|
||||
{error, already_exists} ->
|
||||
handler_utils:send_error(Req2, 409, <<"Word already exists">>);
|
||||
{error, _} ->
|
||||
handler_utils:send_error(Req2, 500, <<"Internal server error">>)
|
||||
end;
|
||||
_ ->
|
||||
handler_utils:send_error(Req2, 400, <<"Missing 'word' field">>)
|
||||
catch
|
||||
_:_ -> handler_utils:send_error(Req1, 400, <<"Invalid JSON">>)
|
||||
end;
|
||||
{error, Code, Msg, Req1} ->
|
||||
handler_utils:send_error(Req1, Code, Msg)
|
||||
end.
|
||||
|
||||
delete_word(Req) ->
|
||||
case handler_utils:auth_admin(Req) of
|
||||
{ok, _AdminId, Req1} ->
|
||||
Word = cowboy_req:binding(word, Req1),
|
||||
case core_banned_words:remove_banned_word(Word) of
|
||||
{ok, _} ->
|
||||
handler_utils:send_json(Req1, 200, #{status => <<"deleted">>});
|
||||
{error, not_found} ->
|
||||
handler_utils:send_error(Req1, 404, <<"Word 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 Преобразование записи 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)
|
||||
}.
|
||||
|
||||
%% @private Форматирование datetime в ISO8601 строку.
|
||||
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]));
|
||||
datetime_to_iso8601(undefined) -> undefined.
|
||||
[Year, Month, Day, Hour, Minute, Second])).
|
||||
|
||||
%% ================== HTTP-ответы ==================
|
||||
send_json(Req, Status, Data) ->
|
||||
Headers = #{
|
||||
<<"content-type">> => <<"application/json">>,
|
||||
<<"access-control-allow-origin">> => <<"*">>,
|
||||
<<"access-control-expose-headers">> => <<"Content-Range">>
|
||||
},
|
||||
Body = jsx:encode(Data),
|
||||
cowboy_req:reply(Status, Headers, Body, Req),
|
||||
{ok, Body, []}.
|
||||
|
||||
send_error(Req, Code, Message) ->
|
||||
Headers = #{
|
||||
<<"content-type">> => <<"application/json">>,
|
||||
<<"access-control-allow-origin">> => <<"*">>,
|
||||
<<"access-control-expose-headers">> => <<"Content-Range">>
|
||||
},
|
||||
Body = jsx:encode(#{error => Message}),
|
||||
cowboy_req:reply(Code, Headers, Body, Req),
|
||||
{ok, Body, []}.
|
||||
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">>
|
||||
}.
|
||||
@@ -15,73 +15,58 @@ init(Req, _Opts) ->
|
||||
<<"GET">> -> get_event(Req);
|
||||
<<"PUT">> -> update_event(Req);
|
||||
<<"DELETE">> -> delete_event(Req);
|
||||
_ -> send_error(Req, 405, <<"Method not allowed">>)
|
||||
_ -> handler_utils:send_error(Req, 405, <<"Method not allowed">>)
|
||||
end.
|
||||
|
||||
%%%===================================================================
|
||||
%%% Swagger / Trails metadata
|
||||
%%% Swagger metadata
|
||||
%%%===================================================================
|
||||
|
||||
trails() ->
|
||||
Path = <<"/v1/admin/events/:id">>,
|
||||
BaseParams = [
|
||||
#{
|
||||
name => <<"id">>,
|
||||
in => <<"path">>,
|
||||
name => <<"id">>,
|
||||
in => <<"path">>,
|
||||
description => <<"Event ID">>,
|
||||
required => true,
|
||||
schema => #{type => string}
|
||||
required => true,
|
||||
schema => #{type => string}
|
||||
}
|
||||
],
|
||||
[
|
||||
%% GET
|
||||
#{
|
||||
path => Path,
|
||||
method => <<"GET">>,
|
||||
handler => ?MODULE,
|
||||
tags => [<<"Events: id">>],
|
||||
#{ % GET
|
||||
path => <<"/v1/admin/events/:id">>,
|
||||
method => <<"GET">>,
|
||||
description => <<"Get event by ID (admin)">>,
|
||||
parameters => BaseParams,
|
||||
responses => #{
|
||||
tags => [<<"Events">>],
|
||||
parameters => BaseParams,
|
||||
responses => #{
|
||||
200 => #{
|
||||
description => <<"Event details">>,
|
||||
content => #{
|
||||
<<"application/json">> => #{
|
||||
schema => event_schema()
|
||||
}
|
||||
}
|
||||
content => #{<<"application/json">> => #{schema => event_schema()}}
|
||||
}
|
||||
}
|
||||
},
|
||||
%% PUT
|
||||
#{
|
||||
path => Path,
|
||||
method => <<"PUT">>,
|
||||
handler => ?MODULE,
|
||||
tags => [<<"Events: id">>],
|
||||
#{ % PUT
|
||||
path => <<"/v1/admin/events/:id">>,
|
||||
method => <<"PUT">>,
|
||||
description => <<"Update event (admin)">>,
|
||||
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 => Path,
|
||||
method => <<"DELETE">>,
|
||||
handler => ?MODULE,
|
||||
tags => [<<"Events: id">>],
|
||||
#{ % DELETE
|
||||
path => <<"/v1/admin/events/:id">>,
|
||||
method => <<"DELETE">>,
|
||||
description => <<"Soft-delete event (admin)">>,
|
||||
parameters => BaseParams,
|
||||
responses => #{
|
||||
tags => [<<"Events">>],
|
||||
parameters => BaseParams,
|
||||
responses => #{
|
||||
200 => #{description => <<"Event status set to deleted">>}
|
||||
}
|
||||
}
|
||||
@@ -89,52 +74,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">>]},
|
||||
rating_avg => #{type => number, format => float},
|
||||
rating_count => #{type => integer},
|
||||
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}
|
||||
}
|
||||
}.
|
||||
|
||||
@@ -142,26 +130,24 @@ event_update_schema() ->
|
||||
%%% Internal functions
|
||||
%%%===================================================================
|
||||
|
||||
%% GET /v1/admin/events/:id
|
||||
get_event(Req) ->
|
||||
case auth_admin(Req) of
|
||||
case handler_utils:auth_admin(Req) of
|
||||
{ok, _AdminId, Req1} ->
|
||||
EventId = cowboy_req:binding(id, Req1),
|
||||
case logic_event:get_event_admin(EventId) of
|
||||
{ok, Event} ->
|
||||
send_json(Req1, 200, event_to_json(Event));
|
||||
handler_utils:send_json(Req1, 200, handler_utils:event_to_json(Event));
|
||||
{error, not_found} ->
|
||||
send_error(Req1, 404, <<"Event not found">>);
|
||||
handler_utils:send_error(Req1, 404, <<"Event not found">>);
|
||||
{error, _} ->
|
||||
send_error(Req1, 500, <<"Internal server error">>)
|
||||
handler_utils:send_error(Req1, 500, <<"Internal server error">>)
|
||||
end;
|
||||
{error, Code, Msg, Req1} ->
|
||||
send_error(Req1, Code, Msg)
|
||||
handler_utils:send_error(Req1, Code, Msg)
|
||||
end.
|
||||
|
||||
%% PUT /v1/admin/events/:id
|
||||
update_event(Req) ->
|
||||
case auth_admin(Req) of
|
||||
case handler_utils:auth_admin(Req) of
|
||||
{ok, _AdminId, Req1} ->
|
||||
EventId = cowboy_req:binding(id, Req1),
|
||||
{ok, Body, Req2} = cowboy_req:read_body(Req1),
|
||||
@@ -171,171 +157,66 @@ update_event(Req) ->
|
||||
UpdatesWithTypes = convert_fields(Updates),
|
||||
case logic_event:update_event_admin(EventId, UpdatesWithTypes) of
|
||||
{ok, Event} ->
|
||||
send_json(Req2, 200, event_to_json(Event));
|
||||
handler_utils:send_json(Req2, 200, handler_utils:event_to_json(Event));
|
||||
{error, not_found} ->
|
||||
send_error(Req2, 404, <<"Event not found">>);
|
||||
handler_utils:send_error(Req2, 404, <<"Event not found">>);
|
||||
{error, _} ->
|
||||
send_error(Req2, 500, <<"Internal server error">>)
|
||||
handler_utils:send_error(Req2, 500, <<"Internal server error">>)
|
||||
end;
|
||||
_ ->
|
||||
send_error(Req2, 400, <<"Invalid JSON">>)
|
||||
handler_utils:send_error(Req2, 400, <<"Invalid JSON">>)
|
||||
catch
|
||||
_:_ -> send_error(Req1, 400, <<"Invalid JSON format">>)
|
||||
_:_ -> handler_utils:send_error(Req1, 400, <<"Invalid JSON format">>)
|
||||
end;
|
||||
{error, Code, Msg, Req1} ->
|
||||
send_error(Req1, Code, Msg)
|
||||
handler_utils:send_error(Req1, Code, Msg)
|
||||
end.
|
||||
|
||||
%% DELETE /v1/admin/events/:id
|
||||
delete_event(Req) ->
|
||||
case auth_admin(Req) of
|
||||
case handler_utils:auth_admin(Req) of
|
||||
{ok, _AdminId, Req1} ->
|
||||
EventId = cowboy_req:binding(id, Req1),
|
||||
case logic_event:delete_event_admin(EventId) of
|
||||
{ok, _} ->
|
||||
send_json(Req1, 200, #{status => <<"deleted">>});
|
||||
handler_utils:send_json(Req1, 200, #{status => <<"deleted">>});
|
||||
{error, not_found} ->
|
||||
send_error(Req1, 404, <<"Event not found">>);
|
||||
handler_utils:send_error(Req1, 404, <<"Event not found">>);
|
||||
{error, _} ->
|
||||
send_error(Req1, 500, <<"Internal server error">>)
|
||||
handler_utils:send_error(Req1, 500, <<"Internal server error">>)
|
||||
end;
|
||||
{error, Code, Msg, Req1} ->
|
||||
send_error(Req1, Code, Msg)
|
||||
handler_utils:send_error(Req1, Code, Msg)
|
||||
end.
|
||||
|
||||
%%--------------------------------------------------------------------
|
||||
%% Auth helpers
|
||||
%%--------------------------------------------------------------------
|
||||
|
||||
auth_admin(Req) ->
|
||||
case handler_auth:authenticate(Req) of
|
||||
{ok, AdminId, Req1} ->
|
||||
case admin_utils:is_admin(AdminId) of
|
||||
true -> {ok, AdminId, Req1};
|
||||
false -> {error, 403, <<"Admin access required">>, Req1}
|
||||
end;
|
||||
{error, Code, Msg, Req1} ->
|
||||
{error, Code, Msg, Req1}
|
||||
end.
|
||||
|
||||
%%--------------------------------------------------------------------
|
||||
%% Field conversion (from binary keys/values to internal atoms)
|
||||
%%--------------------------------------------------------------------
|
||||
|
||||
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}) ->
|
||||
case parse_datetime(Val) of
|
||||
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}) ->
|
||||
RuleJson = jsx:encode(Val),
|
||||
{recurrence_rule, RuleJson};
|
||||
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.
|
||||
|
||||
%%--------------------------------------------------------------------
|
||||
%% JSON / datetime helpers
|
||||
%%--------------------------------------------------------------------
|
||||
|
||||
event_to_json(Event) ->
|
||||
LocationJson = case Event#event.location of
|
||||
undefined -> null;
|
||||
#location{address = Addr, lat = Lat, lon = Lon} ->
|
||||
#{address => Addr, lat => Lat, lon => Lon}
|
||||
end,
|
||||
RecurrenceJson = case Event#event.recurrence_rule of
|
||||
undefined -> null;
|
||||
Rule ->
|
||||
try jsx:decode(Rule, [return_maps]) of
|
||||
Map when is_map(Map) -> Map;
|
||||
_ -> null
|
||||
catch _:_ -> null
|
||||
end
|
||||
end,
|
||||
#{
|
||||
id => Event#event.id,
|
||||
calendar_id => Event#event.calendar_id,
|
||||
title => Event#event.title,
|
||||
description => Event#event.description,
|
||||
event_type => Event#event.event_type,
|
||||
start_time => datetime_to_iso8601(Event#event.start_time),
|
||||
duration => Event#event.duration,
|
||||
recurrence => RecurrenceJson,
|
||||
master_id => Event#event.master_id,
|
||||
is_instance => Event#event.is_instance,
|
||||
specialist_id => Event#event.specialist_id,
|
||||
location => LocationJson,
|
||||
tags => Event#event.tags,
|
||||
capacity => Event#event.capacity,
|
||||
online_link => Event#event.online_link,
|
||||
status => Event#event.status,
|
||||
rating_avg => Event#event.rating_avg,
|
||||
rating_count => Event#event.rating_count,
|
||||
created_at => datetime_to_iso8601(Event#event.created_at),
|
||||
updated_at => datetime_to_iso8601(Event#event.updated_at)
|
||||
}.
|
||||
|
||||
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]
|
||||
)
|
||||
);
|
||||
datetime_to_iso8601(undefined) ->
|
||||
undefined.
|
||||
|
||||
parse_datetime(Str) ->
|
||||
try
|
||||
[DateStr, TimeStr] = string:split(Str, "T"),
|
||||
TimeStrNoZ = string:trim(TimeStr, trailing, "Z"),
|
||||
[YearStr, MonthStr, DayStr] = string:split(DateStr, "-", all),
|
||||
[HourStr, MinuteStr, SecondStr] = string:split(TimeStrNoZ, ":", all),
|
||||
Year = binary_to_integer(list_to_binary(YearStr)),
|
||||
Month = binary_to_integer(list_to_binary(MonthStr)),
|
||||
Day = binary_to_integer(list_to_binary(DayStr)),
|
||||
Hour = binary_to_integer(list_to_binary(HourStr)),
|
||||
Minute = binary_to_integer(list_to_binary(MinuteStr)),
|
||||
Second = binary_to_integer(list_to_binary(SecondStr)),
|
||||
{ok, {{Year, Month, Day}, {Hour, Minute, Second}}}
|
||||
catch _:_ -> {error, invalid_format}
|
||||
end.
|
||||
|
||||
%%--------------------------------------------------------------------
|
||||
%% Response helpers
|
||||
%%--------------------------------------------------------------------
|
||||
|
||||
send_json(Req, Status, Data) ->
|
||||
Body = jsx:encode(Data),
|
||||
Headers = #{<<"content-type">> => <<"application/json">>},
|
||||
cowboy_req:reply(Status, Headers, Body, Req),
|
||||
{ok, Body, []}.
|
||||
|
||||
send_error(Req, Status, Message) ->
|
||||
Body = jsx:encode(#{error => Message}),
|
||||
cowboy_req:reply(Status, #{<<"content-type">> => <<"application/json">>}, Body, Req),
|
||||
{ok, Body, []}.
|
||||
convert_field(Other) -> Other.
|
||||
@@ -7,50 +7,45 @@
|
||||
-include("records.hrl").
|
||||
|
||||
%%%===================================================================
|
||||
%%% cowboy_handler callbacks
|
||||
%%% cowboy_handler callback
|
||||
%%%===================================================================
|
||||
|
||||
init(Req, _Opts) ->
|
||||
case cowboy_req:method(Req) of
|
||||
<<"GET">> -> list_all_events(Req);
|
||||
_ -> send_error(Req, 405, <<"Method not allowed">>)
|
||||
_ -> handler_utils:send_error(Req, 405, <<"Method not allowed">>)
|
||||
end.
|
||||
|
||||
%%%===================================================================
|
||||
%%% Swagger / Trails metadata
|
||||
%%% Swagger metadata
|
||||
%%%===================================================================
|
||||
|
||||
trails() ->
|
||||
[
|
||||
#{
|
||||
path => <<"/v1/admin/events">>,
|
||||
method => <<"GET">>,
|
||||
handler => ?MODULE,
|
||||
tags => [<<"Events">>],
|
||||
path => <<"/v1/admin/events">>,
|
||||
method => <<"GET">>,
|
||||
description => <<"Search and list events (admin)">>,
|
||||
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">>]}}
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
content => #{<<"application/json">> => #{schema => #{
|
||||
type => array,
|
||||
items => event_schema()
|
||||
}}}
|
||||
},
|
||||
405 => #{description => <<"Method not allowed">>}
|
||||
}
|
||||
@@ -59,28 +54,31 @@ 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">>]},
|
||||
rating_avg => #{type => number, format => float},
|
||||
rating_count => #{type => integer},
|
||||
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">>}
|
||||
}
|
||||
}.
|
||||
|
||||
@@ -89,131 +87,36 @@ event_schema() ->
|
||||
%%%===================================================================
|
||||
|
||||
list_all_events(Req) ->
|
||||
case auth_admin(Req) of
|
||||
case handler_utils:auth_admin(Req) of
|
||||
{ok, _AdminId, Req1} ->
|
||||
Params = parse_admin_event_search(Req1),
|
||||
{ok, Total, Events} = logic_event:search_events(Params),
|
||||
Json = [event_to_json(E) || E <- Events],
|
||||
Limit = maps:get(limit, Params, 50),
|
||||
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),
|
||||
<<"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">>
|
||||
},
|
||||
send_json(Req1, 200, Json, Headers);
|
||||
handler_utils:send_json(Req1, 200, Json, Headers);
|
||||
{error, Code, Msg, Req1} ->
|
||||
send_error(Req1, Code, Msg)
|
||||
handler_utils:send_error(Req1, Code, Msg)
|
||||
end.
|
||||
|
||||
parse_admin_event_search(Req) ->
|
||||
Qs = cowboy_req:parse_qs(Req),
|
||||
#{
|
||||
from => parse_datetime_qs(proplists:get_value(<<"from">>, Qs)),
|
||||
to => 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 => parse_int_qs(proplists:get_value(<<"limit">>, Qs), 50),
|
||||
offset => parse_int_qs(proplists:get_value(<<"offset">>, Qs), 0),
|
||||
sort => proplists:get_value(<<"sort">>, Qs, <<"created_at">>),
|
||||
order => proplists:get_value(<<"order">>, Qs, <<"desc">>)
|
||||
}.
|
||||
|
||||
auth_admin(Req) ->
|
||||
case handler_auth:authenticate(Req) of
|
||||
{ok, AdminId, Req1} ->
|
||||
case admin_utils:is_admin(AdminId) of
|
||||
true -> {ok, AdminId, Req1};
|
||||
false -> {error, 403, <<"Admin access required">>, Req1}
|
||||
end;
|
||||
{error, Code, Msg, Req1} ->
|
||||
{error, Code, Msg, Req1}
|
||||
end.
|
||||
|
||||
parse_int_qs(undefined, Default) -> Default;
|
||||
parse_int_qs(Bin, Default) ->
|
||||
try binary_to_integer(Bin) catch _:_ -> Default end.
|
||||
|
||||
parse_datetime_qs(undefined) -> undefined;
|
||||
parse_datetime_qs(Bin) ->
|
||||
case parse_datetime(Bin) of {ok, Dt} -> Dt; _ -> undefined end.
|
||||
|
||||
parse_datetime(Str) ->
|
||||
try
|
||||
[DateStr, TimeStr] = string:split(Str, "T"),
|
||||
TimeStrNoZ = string:trim(TimeStr, trailing, "Z"),
|
||||
[YearStr, MonthStr, DayStr] = string:split(DateStr, "-", all),
|
||||
[HourStr, MinuteStr, SecondStr] = string:split(TimeStrNoZ, ":", all),
|
||||
Year = binary_to_integer(list_to_binary(YearStr)),
|
||||
Month = binary_to_integer(list_to_binary(MonthStr)),
|
||||
Day = binary_to_integer(list_to_binary(DayStr)),
|
||||
Hour = binary_to_integer(list_to_binary(HourStr)),
|
||||
Minute = binary_to_integer(list_to_binary(MinuteStr)),
|
||||
Second = binary_to_integer(list_to_binary(SecondStr)),
|
||||
{ok, {{Year, Month, Day}, {Hour, Minute, Second}}}
|
||||
catch _:_ -> {error, invalid_format}
|
||||
end.
|
||||
|
||||
event_to_json(Event) ->
|
||||
LocationJson = case Event#event.location of
|
||||
undefined -> null;
|
||||
#location{address = Addr, lat = Lat, lon = Lon} ->
|
||||
#{address => Addr, lat => Lat, lon => Lon}
|
||||
end,
|
||||
RecurrenceJson = case Event#event.recurrence_rule of
|
||||
undefined -> null;
|
||||
Rule ->
|
||||
try jsx:decode(Rule, [return_maps]) of
|
||||
Map when is_map(Map) -> Map;
|
||||
_ -> null
|
||||
catch _:_ -> null
|
||||
end
|
||||
end,
|
||||
#{
|
||||
id => Event#event.id,
|
||||
calendar_id => Event#event.calendar_id,
|
||||
title => Event#event.title,
|
||||
description => Event#event.description,
|
||||
event_type => Event#event.event_type,
|
||||
start_time => datetime_to_iso8601(Event#event.start_time),
|
||||
duration => Event#event.duration,
|
||||
recurrence => RecurrenceJson,
|
||||
master_id => Event#event.master_id,
|
||||
is_instance => Event#event.is_instance,
|
||||
specialist_id => Event#event.specialist_id,
|
||||
location => LocationJson,
|
||||
tags => Event#event.tags,
|
||||
capacity => Event#event.capacity,
|
||||
online_link => Event#event.online_link,
|
||||
status => Event#event.status,
|
||||
rating_avg => Event#event.rating_avg,
|
||||
rating_count => Event#event.rating_count,
|
||||
created_at => datetime_to_iso8601(Event#event.created_at),
|
||||
updated_at => datetime_to_iso8601(Event#event.updated_at)
|
||||
}.
|
||||
|
||||
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]
|
||||
)
|
||||
);
|
||||
datetime_to_iso8601(undefined) ->
|
||||
undefined.
|
||||
|
||||
send_json(Req, Status, Data, ExtraHeaders) ->
|
||||
Body = jsx:encode(Data),
|
||||
Headers = maps:merge(#{<<"content-type">> => <<"application/json">>}, ExtraHeaders),
|
||||
cowboy_req:reply(Status, Headers, Body, Req),
|
||||
{ok, Body, []}.
|
||||
|
||||
send_error(Req, Status, Message) ->
|
||||
Body = jsx:encode(#{error => Message}),
|
||||
cowboy_req:reply(Status, #{<<"content-type">> => <<"application/json">>}, Body, Req),
|
||||
{ok, Body, []}.
|
||||
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">>)
|
||||
}.
|
||||
@@ -1,18 +1,36 @@
|
||||
-module(admin_handler_health).
|
||||
-behaviour(cowboy_handler).
|
||||
-export([init/2]).
|
||||
|
||||
init(Req, State) ->
|
||||
-export([init/2]).
|
||||
-export([trails/0]).
|
||||
|
||||
%%% cowboy_handler callback
|
||||
init(Req, _State) ->
|
||||
case cowboy_req:method(Req) of
|
||||
<<"GET">> ->
|
||||
Body = jsx:encode(#{status => <<"ok">>}),
|
||||
Req2 = cowboy_req:reply(200, #{<<"content-type">> => <<"application/json">>}, Body, Req),
|
||||
{ok, Req2, State};
|
||||
handler_utils:send_json(Req, 200, #{status => <<"ok">>});
|
||||
_ ->
|
||||
send_error(Req, 405, <<"Method not allowed">>)
|
||||
handler_utils:send_error(Req, 405, <<"Method not allowed">>)
|
||||
end.
|
||||
|
||||
send_error(Req, Status, Message) ->
|
||||
Body = jsx:encode(#{error => Message}),
|
||||
Req2 = cowboy_req:reply(Status, #{<<"content-type">> => <<"application/json">>}, Body, Req),
|
||||
{ok, Req2, []}.
|
||||
%%% Swagger metadata
|
||||
trails() ->
|
||||
[
|
||||
#{
|
||||
path => <<"/v1/admin/health">>,
|
||||
method => <<"GET">>,
|
||||
description => <<"Admin API health check">>,
|
||||
tags => [<<"Health">>],
|
||||
responses => #{
|
||||
200 => #{
|
||||
description => <<"API is healthy">>,
|
||||
content => #{<<"application/json">> => #{schema => #{
|
||||
type => object,
|
||||
properties => #{
|
||||
status => #{type => string}
|
||||
}
|
||||
}}}
|
||||
}
|
||||
}
|
||||
}
|
||||
].
|
||||
@@ -1,8 +1,17 @@
|
||||
%%%-------------------------------------------------------------------
|
||||
%%% @doc Административный обработчик аутентификации.
|
||||
%%% POST – выполняет вход администратора, возвращает токены и данные пользователя.
|
||||
%%% @end
|
||||
%%%-------------------------------------------------------------------
|
||||
-module(admin_handler_login).
|
||||
-behaviour(cowboy_handler).
|
||||
-export([init/2]).
|
||||
|
||||
init(Req0, State) ->
|
||||
-export([init/2]).
|
||||
-export([trails/0]).
|
||||
|
||||
%%% cowboy_handler callback
|
||||
-spec init(cowboy_req:req(), any()) -> {ok, cowboy_req:req(), any()}.
|
||||
init(Req0, _State) ->
|
||||
case cowboy_req:method(Req0) of
|
||||
<<"POST">> ->
|
||||
case cowboy_req:has_body(Req0) of
|
||||
@@ -12,48 +21,67 @@ init(Req0, State) ->
|
||||
#{<<"email">> := Email, <<"password">> := Password} ->
|
||||
case eventhub_auth:authenticate_admin_request(Req1, Email, Password) of
|
||||
{ok, Token, User} ->
|
||||
% Генерация refresh-токена для администратора
|
||||
{RefreshToken, _ExpiresAt} = eventhub_auth:generate_refresh_token(maps:get(id, User)),
|
||||
% Сохранение refresh-токена в admin_session
|
||||
core_admin_session:create(maps:get(id, User), RefreshToken),
|
||||
core_admin:update_last_login(maps:get(id, User)),
|
||||
Resp = jsx:encode(#{
|
||||
UserId = maps:get(id, User),
|
||||
{RefreshToken, _ExpiresAt} = eventhub_auth:generate_refresh_token(UserId),
|
||||
core_admin_session:create(UserId, RefreshToken),
|
||||
core_admin:update_last_login(UserId),
|
||||
Resp = #{
|
||||
<<"token">> => Token,
|
||||
<<"user">> => #{
|
||||
<<"id">> => maps:get(id, User),
|
||||
<<"id">> => UserId,
|
||||
<<"email">> => maps:get(email, User),
|
||||
<<"role">> => maps:get(role, User)
|
||||
},
|
||||
<<"refresh_token">> => RefreshToken
|
||||
}),
|
||||
Req2 = cowboy_req:reply(200, #{
|
||||
<<"content-type">> => <<"application/json">>,
|
||||
<<"access-control-allow-origin">> => <<"*">>
|
||||
}, Resp, Req1),
|
||||
{ok, Req2, State};
|
||||
},
|
||||
handler_utils:send_json(Req1, 200, Resp);
|
||||
{error, insufficient_permissions} ->
|
||||
error_response(403, <<"insufficient_permissions">>, Req1, State);
|
||||
handler_utils:send_error(Req1, 403, <<"insufficient_permissions">>);
|
||||
{error, Reason} when is_atom(Reason) ->
|
||||
error_response(401, atom_to_binary(Reason, utf8), Req1, State);
|
||||
handler_utils:send_error(Req1, 401, atom_to_binary(Reason, utf8));
|
||||
{error, Reason} ->
|
||||
error_response(401, Reason, Req1, State)
|
||||
handler_utils:send_error(Req1, 401, Reason)
|
||||
end;
|
||||
_ ->
|
||||
error_response(400, <<"Missing email or password">>, Req1, State)
|
||||
handler_utils:send_error(Req1, 400, <<"Missing email or password">>)
|
||||
catch
|
||||
_:_ -> error_response(400, <<"Invalid JSON">>, Req1, State)
|
||||
_:_ -> handler_utils:send_error(Req1, 400, <<"Invalid JSON">>)
|
||||
end;
|
||||
false ->
|
||||
error_response(400, <<"Missing request body">>, Req0, State)
|
||||
handler_utils:send_error(Req0, 400, <<"Missing request body">>)
|
||||
end;
|
||||
_ ->
|
||||
error_response(405, <<"Method not allowed">>, Req0, State)
|
||||
handler_utils:send_error(Req0, 405, <<"Method not allowed">>)
|
||||
end.
|
||||
|
||||
error_response(Code, Reason, Req, State) ->
|
||||
Body = jsx:encode(#{<<"error">> => Reason}),
|
||||
Req2 = cowboy_req:reply(Code, #{
|
||||
<<"content-type">> => <<"application/json">>,
|
||||
<<"access-control-allow-origin">> => <<"*">>
|
||||
}, Body, Req),
|
||||
{ok, Req2, 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">>}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
responses => #{
|
||||
200 => #{description => <<"Login successful, returns token and user info">>},
|
||||
401 => #{description => <<"Invalid credentials">>},
|
||||
403 => #{description => <<"Insufficient permissions">>}
|
||||
}
|
||||
}
|
||||
].
|
||||
@@ -1,37 +1,137 @@
|
||||
-module(admin_handler_me).
|
||||
-behaviour(cowboy_handler).
|
||||
|
||||
-include("records.hrl").
|
||||
|
||||
-export([init/2]).
|
||||
-export([trails/0]).
|
||||
|
||||
-include("records.hrl").
|
||||
|
||||
init(Req, _Opts) ->
|
||||
case cowboy_req:method(Req) of
|
||||
<<"GET">> ->
|
||||
case handler_auth:authenticate(Req) of
|
||||
{ok, AdminId, Req1} ->
|
||||
case core_admin:get_by_id(AdminId) of
|
||||
{ok, Admin} ->
|
||||
Permissions = admin_utils:get_permissions(Admin#admin.role),
|
||||
Resp = jsx:encode(#{
|
||||
id => Admin#admin.id,
|
||||
email => Admin#admin.email,
|
||||
role => Admin#admin.role,
|
||||
permissions => Permissions
|
||||
}),
|
||||
Req2 = cowboy_req:reply(200, #{<<"content-type">> => <<"application/json">>}, Resp, Req1),
|
||||
{ok, Req2, []};
|
||||
{error, not_found} ->
|
||||
send_error(Req1, 404, <<"Admin not found">>)
|
||||
end;
|
||||
{error, Code, Message, Req1} ->
|
||||
send_error(Req1, Code, Message)
|
||||
end;
|
||||
_ ->
|
||||
send_error(Req, 405, <<"Method not allowed">>)
|
||||
<<"GET">> -> get_me(Req);
|
||||
<<"PUT">> -> update_me(Req);
|
||||
_ -> handler_utils:send_error(Req, 405, <<"Method not allowed">>)
|
||||
end.
|
||||
|
||||
send_error(Req, Code, Message) ->
|
||||
Body = jsx:encode(#{error => Message}),
|
||||
Req2 = cowboy_req:reply(Code, #{<<"content-type">> => <<"application/json">>}, Body, Req),
|
||||
{ok, Req2, []}.
|
||||
trails() ->
|
||||
[
|
||||
#{ % GET
|
||||
path => <<"/v1/admin/me">>,
|
||||
method => <<"GET">>,
|
||||
description => <<"Get current admin profile">>,
|
||||
tags => [<<"Admins">>],
|
||||
responses => #{
|
||||
200 => #{
|
||||
description => <<"Admin profile">>,
|
||||
content => #{<<"application/json">> => #{schema => admin_schema()}}
|
||||
}
|
||||
}
|
||||
},
|
||||
#{ % PUT
|
||||
path => <<"/v1/admin/me">>,
|
||||
method => <<"PUT">>,
|
||||
description => <<"Update current admin profile">>,
|
||||
tags => [<<"Admins">>],
|
||||
requestBody => #{
|
||||
required => true,
|
||||
content => #{<<"application/json">> => #{schema => admin_update_schema()}}
|
||||
},
|
||||
responses => #{
|
||||
200 => #{description => <<"Updated profile">>}
|
||||
}
|
||||
}
|
||||
].
|
||||
|
||||
admin_schema() ->
|
||||
#{
|
||||
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">>}
|
||||
}
|
||||
}.
|
||||
|
||||
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}
|
||||
}
|
||||
}.
|
||||
|
||||
get_me(Req) ->
|
||||
case handler_utils:auth_admin(Req) of
|
||||
{ok, AdminId, Req1} ->
|
||||
case logic_admin:get_admin(AdminId) 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.
|
||||
|
||||
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),
|
||||
case logic_admin:update_admin(AdminId, 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.
|
||||
|
||||
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 => 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])).
|
||||
@@ -1,19 +1,85 @@
|
||||
%%%-------------------------------------------------------------------
|
||||
%%% @doc Административный обработчик модерации.
|
||||
%%% PUT – применяет действие модерации к указанной сущности.
|
||||
%%% @end
|
||||
%%%-------------------------------------------------------------------
|
||||
-module(admin_handler_moderation).
|
||||
-behaviour(cowboy_handler).
|
||||
|
||||
-export([init/2]).
|
||||
-export([trails/0]).
|
||||
|
||||
-include("records.hrl").
|
||||
|
||||
-define(VALID_TARGETS, [<<"calendar">>, <<"event">>, <<"review">>, <<"user">>]).
|
||||
|
||||
%%% cowboy_handler callback
|
||||
-spec init(cowboy_req:req(), any()) -> {ok, cowboy_req:req(), any()}.
|
||||
init(Req, _Opts) ->
|
||||
case cowboy_req:method(Req) of
|
||||
<<"PUT">> -> moderate(Req);
|
||||
_ -> send_error(Req, 405, <<"Method not allowed">>)
|
||||
_ -> handler_utils:send_error(Req, 405, <<"Method not allowed">>)
|
||||
end.
|
||||
|
||||
%%% Swagger metadata
|
||||
-spec trails() -> [map()].
|
||||
trails() ->
|
||||
Targets = [<<"calendar">>, <<"event">>, <<"review">>, <<"user">>],
|
||||
Actions = #{
|
||||
<<"calendar">> => [<<"freeze">>, <<"unfreeze">>],
|
||||
<<"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">>,
|
||||
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}
|
||||
}
|
||||
],
|
||||
requestBody => #{
|
||||
required => true,
|
||||
content => #{<<"application/json">> => #{schema => #{
|
||||
type => object,
|
||||
required => [<<"action">>],
|
||||
properties => #{
|
||||
action => #{type => string, enum => ActionList},
|
||||
reason => #{type => string}
|
||||
}
|
||||
}}}
|
||||
},
|
||||
responses => #{
|
||||
200 => #{description => <<"Moderation applied successfully">>},
|
||||
400 => #{description => <<"Bad request">>},
|
||||
404 => #{description => <<"Entity not found">>}
|
||||
}
|
||||
}
|
||||
end, ActionList)
|
||||
end, Targets).
|
||||
|
||||
%%% Internal functions
|
||||
|
||||
moderate(Req) ->
|
||||
case authenticate_and_check_admin(Req) of
|
||||
case handler_utils:auth_admin(Req) of
|
||||
{ok, AdminId, Req1} ->
|
||||
TargetType = cowboy_req:binding(target_type, Req1),
|
||||
TargetId = cowboy_req:binding(id, Req1),
|
||||
@@ -25,15 +91,15 @@ moderate(Req) ->
|
||||
Reason = maps:get(<<"reason">>, BodyMap, <<"">>),
|
||||
apply_moderation(TargetType, TargetId, Action, Reason, Req2, AdminId);
|
||||
_ ->
|
||||
send_error(Req2, 400, <<"Missing 'action' field">>)
|
||||
handler_utils:send_error(Req2, 400, <<"Missing 'action' field">>)
|
||||
catch
|
||||
_:_ -> send_error(Req2, 400, <<"Invalid JSON">>)
|
||||
_:_ -> handler_utils:send_error(Req2, 400, <<"Invalid JSON">>)
|
||||
end;
|
||||
false ->
|
||||
send_error(Req1, 400, <<"Invalid target_type">>)
|
||||
handler_utils:send_error(Req1, 400, <<"Invalid target_type">>)
|
||||
end;
|
||||
{error, Code, Message, Req1} ->
|
||||
send_error(Req1, Code, Message)
|
||||
handler_utils:send_error(Req1, Code, Message)
|
||||
end.
|
||||
|
||||
apply_moderation(<<"calendar">>, Id, Action, Reason, Req, AdminId) ->
|
||||
@@ -49,131 +115,86 @@ handle_calendar(Id, <<"freeze">>, Reason, Req, AdminId) ->
|
||||
case core_calendar:freeze(Id, Reason) of
|
||||
{ok, Calendar} ->
|
||||
log_audit(AdminId, <<"freeze_calendar">>, <<"calendar">>, Id, Reason),
|
||||
send_json(Req, 200, calendar_to_json(Calendar));
|
||||
{error, not_found} -> send_error(Req, 404, <<"Calendar not found">>)
|
||||
handler_utils:send_json(Req, 200, handler_utils:calendar_to_json(Calendar));
|
||||
{error, not_found} ->
|
||||
handler_utils:send_error(Req, 404, <<"Calendar not found">>)
|
||||
end;
|
||||
handle_calendar(Id, <<"unfreeze">>, Reason, Req, AdminId) ->
|
||||
case core_calendar:unfreeze(Id, Reason) of
|
||||
{ok, Calendar} ->
|
||||
log_audit(AdminId, <<"unfreeze_calendar">>, <<"calendar">>, Id, Reason),
|
||||
send_json(Req, 200, calendar_to_json(Calendar));
|
||||
{error, not_found} -> send_error(Req, 404, <<"Calendar not found">>)
|
||||
handler_utils:send_json(Req, 200, handler_utils:calendar_to_json(Calendar));
|
||||
{error, not_found} ->
|
||||
handler_utils:send_error(Req, 404, <<"Calendar not found">>)
|
||||
end;
|
||||
handle_calendar(_Id, _Action, _Reason, Req, _AdminId) ->
|
||||
send_error(Req, 400, <<"Invalid action for calendar">>).
|
||||
handler_utils:send_error(Req, 400, <<"Invalid action for calendar">>).
|
||||
|
||||
handle_event(Id, <<"freeze">>, Reason, Req, AdminId) ->
|
||||
case core_event:freeze(Id, Reason) of
|
||||
{ok, Event} ->
|
||||
log_audit(AdminId, <<"freeze_event">>, <<"event">>, Id, Reason),
|
||||
send_json(Req, 200, event_to_json(Event));
|
||||
{error, not_found} -> send_error(Req, 404, <<"Event not found">>)
|
||||
handler_utils:send_json(Req, 200, handler_utils:event_to_json(Event));
|
||||
{error, not_found} ->
|
||||
handler_utils:send_error(Req, 404, <<"Event not found">>)
|
||||
end;
|
||||
handle_event(Id, <<"unfreeze">>, Reason, Req, AdminId) ->
|
||||
case core_event:unfreeze(Id, Reason) of
|
||||
{ok, Event} ->
|
||||
log_audit(AdminId, <<"unfreeze_event">>, <<"event">>, Id, Reason),
|
||||
send_json(Req, 200, event_to_json(Event));
|
||||
{error, not_found} -> send_error(Req, 404, <<"Event not found">>)
|
||||
handler_utils:send_json(Req, 200, handler_utils:event_to_json(Event));
|
||||
{error, not_found} ->
|
||||
handler_utils:send_error(Req, 404, <<"Event not found">>)
|
||||
end;
|
||||
handle_event(_Id, _Action, _Reason, Req, _AdminId) ->
|
||||
send_error(Req, 400, <<"Invalid action for event">>).
|
||||
handler_utils:send_error(Req, 400, <<"Invalid action for event">>).
|
||||
|
||||
handle_review(Id, <<"hide">>, Reason, Req, AdminId) ->
|
||||
case core_review:hide(Id, Reason) of
|
||||
{ok, Review} ->
|
||||
log_audit(AdminId, <<"hide_review">>, <<"review">>, Id, Reason),
|
||||
send_json(Req, 200, review_to_json(Review));
|
||||
{error, not_found} -> send_error(Req, 404, <<"Review not found">>)
|
||||
handler_utils:send_json(Req, 200, handler_utils:review_to_json(Review));
|
||||
{error, not_found} ->
|
||||
handler_utils:send_error(Req, 404, <<"Review not found">>)
|
||||
end;
|
||||
handle_review(Id, <<"unhide">>, Reason, Req, AdminId) ->
|
||||
case core_review:unhide(Id, Reason) of
|
||||
{ok, Review} ->
|
||||
log_audit(AdminId, <<"unhide_review">>, <<"review">>, Id, Reason),
|
||||
send_json(Req, 200, review_to_json(Review));
|
||||
{error, not_found} -> send_error(Req, 404, <<"Review not found">>)
|
||||
handler_utils:send_json(Req, 200, handler_utils:review_to_json(Review));
|
||||
{error, not_found} ->
|
||||
handler_utils:send_error(Req, 404, <<"Review not found">>)
|
||||
end;
|
||||
handle_review(_Id, _Action, _Reason, Req, _AdminId) ->
|
||||
send_error(Req, 400, <<"Invalid action for review">>).
|
||||
handler_utils:send_error(Req, 400, <<"Invalid action for review">>).
|
||||
|
||||
handle_user(Id, <<"block">>, Reason, Req, AdminId) ->
|
||||
case core_user:block(Id, Reason) of
|
||||
{ok, User} ->
|
||||
log_audit(AdminId, <<"block_user">>, <<"user">>, Id, Reason),
|
||||
send_json(Req, 200, user_to_json(User));
|
||||
{error, not_found} -> send_error(Req, 404, <<"User not found">>)
|
||||
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, <<"unblock">>, Reason, Req, AdminId) ->
|
||||
case core_user:unblock(Id, Reason) of
|
||||
{ok, User} ->
|
||||
log_audit(AdminId, <<"unblock_user">>, <<"user">>, Id, Reason),
|
||||
send_json(Req, 200, user_to_json(User));
|
||||
{error, not_found} -> send_error(Req, 404, <<"User not found">>)
|
||||
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) ->
|
||||
send_error(Req, 400, <<"Invalid action for user">>).
|
||||
handler_utils:send_error(Req, 400, <<"Invalid action for user">>).
|
||||
|
||||
%% ── АУДИТ ──────────────────────────────────────────────────
|
||||
log_audit(AdminId, Action, EntityType, EntityId, Reason) ->
|
||||
case core_admin:get_by_id(AdminId) of
|
||||
{ok, Admin} ->
|
||||
core_admin_audit:log(AdminId, Admin#admin.email, Admin#admin.role,
|
||||
Action, EntityType, EntityId,
|
||||
client_ip(), Reason);
|
||||
Action, EntityType, EntityId, client_ip(), Reason);
|
||||
_ -> ok
|
||||
end.
|
||||
|
||||
%% ── ВСПОМОГАТЕЛЬНЫЕ ФУНКЦИИ ────────────────────────────────
|
||||
authenticate_and_check_admin(Req) ->
|
||||
case handler_auth:authenticate(Req) of
|
||||
{ok, AdminId, Req1} ->
|
||||
case admin_utils:is_admin(AdminId) of
|
||||
true -> {ok, AdminId, Req1};
|
||||
false -> {error, 403, <<"Admin access required">>, Req1}
|
||||
end;
|
||||
{error, Code, Message, Req1} ->
|
||||
{error, Code, Message, Req1}
|
||||
end.
|
||||
|
||||
client_ip() -> <<"127.0.0.1">>.
|
||||
|
||||
calendar_to_json(C) ->
|
||||
#{
|
||||
id => C#calendar.id,
|
||||
title => C#calendar.title,
|
||||
status => atom_to_binary(C#calendar.status, utf8),
|
||||
reason => C#calendar.reason
|
||||
}.
|
||||
|
||||
event_to_json(E) ->
|
||||
#{
|
||||
id => E#event.id,
|
||||
title => E#event.title,
|
||||
status => atom_to_binary(E#event.status, utf8),
|
||||
reason => E#event.reason
|
||||
}.
|
||||
|
||||
review_to_json(R) ->
|
||||
#{
|
||||
id => R#review.id,
|
||||
status => atom_to_binary(R#review.status, utf8),
|
||||
reason => R#review.reason
|
||||
}.
|
||||
|
||||
user_to_json(U) ->
|
||||
#{
|
||||
id => U#user.id,
|
||||
email => U#user.email,
|
||||
status => atom_to_binary(U#user.status, utf8),
|
||||
reason => U#user.reason
|
||||
}.
|
||||
|
||||
send_json(Req, Status, Data) ->
|
||||
Body = jsx:encode(Data),
|
||||
cowboy_req:reply(Status, #{<<"content-type">> => <<"application/json">>}, Body, Req),
|
||||
{ok, Body, []}.
|
||||
|
||||
send_error(Req, Status, Message) ->
|
||||
Body = jsx:encode(#{error => Message}),
|
||||
cowboy_req:reply(Status, #{<<"content-type">> => <<"application/json">>}, Body, Req),
|
||||
{ok, Body, []}.
|
||||
client_ip() -> <<"127.0.0.1">>.
|
||||
@@ -1,109 +1,130 @@
|
||||
%%%-------------------------------------------------------------------
|
||||
%%% @doc Административный обработчик конкретной жалобы.
|
||||
%%% GET – получить жалобу по ID.
|
||||
%%% PUT – обновить статус жалобы.
|
||||
%%% @end
|
||||
%%%-------------------------------------------------------------------
|
||||
-module(admin_handler_report_by_id).
|
||||
-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">> -> get_report(Req);
|
||||
<<"PUT">> -> update_report(Req);
|
||||
_ -> send_error(Req, 405, <<"Method not allowed">>)
|
||||
<<"GET">> -> get_report(Req);
|
||||
<<"PUT">> -> update_report(Req);
|
||||
_ -> 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}
|
||||
}
|
||||
],
|
||||
[
|
||||
#{ % 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()}}
|
||||
},
|
||||
404 => #{description => <<"Report not found">>}
|
||||
}
|
||||
},
|
||||
#{ % PUT
|
||||
path => <<"/v1/admin/reports/:id">>,
|
||||
method => <<"PUT">>,
|
||||
description => <<"Update report status (admin)">>,
|
||||
tags => [<<"Reports">>],
|
||||
parameters => BaseParams,
|
||||
requestBody => #{
|
||||
required => true,
|
||||
content => #{<<"application/json">> => #{schema => report_update_schema()}}
|
||||
},
|
||||
responses => #{
|
||||
200 => #{description => <<"Updated report">>},
|
||||
404 => #{description => <<"Report not found">>}
|
||||
}
|
||||
}
|
||||
].
|
||||
|
||||
report_schema() ->
|
||||
#{
|
||||
type => object,
|
||||
properties => #{
|
||||
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">>},
|
||||
resolved_at => #{type => string, format => <<"date-time">>, nullable => true},
|
||||
resolved_by => #{type => string, nullable => true}
|
||||
}
|
||||
}.
|
||||
|
||||
report_update_schema() ->
|
||||
#{
|
||||
type => object,
|
||||
properties => #{
|
||||
status => #{type => string, enum => [<<"reviewed">>, <<"dismissed">>]}
|
||||
}
|
||||
}.
|
||||
|
||||
%%% Internal functions
|
||||
|
||||
get_report(Req) ->
|
||||
case auth_admin(Req) of
|
||||
case handler_utils:auth_admin(Req) of
|
||||
{ok, AdminId, Req1} ->
|
||||
case admin_utils:is_admin(AdminId) of
|
||||
true ->
|
||||
ReportId = cowboy_req:binding(id, Req1),
|
||||
case core_report:get_by_id(ReportId) of
|
||||
{ok, Report} ->
|
||||
send_json(Req1, 200, report_to_json(Report));
|
||||
{error, not_found} ->
|
||||
send_error(Req1, 404, <<"Report not found">>)
|
||||
end;
|
||||
false ->
|
||||
send_error(Req1, 403, <<"Admin access required">>)
|
||||
ReportId = cowboy_req:binding(id, Req1),
|
||||
case logic_report:get_report(AdminId, ReportId) of
|
||||
{ok, Report} ->
|
||||
handler_utils:send_json(Req1, 200, handler_utils:report_to_json(Report));
|
||||
{error, not_found} ->
|
||||
handler_utils:send_error(Req1, 404, <<"Report not found">>);
|
||||
{error, _} ->
|
||||
handler_utils:send_error(Req1, 500, <<"Internal server error">>)
|
||||
end;
|
||||
{error, Code, Message, Req1} ->
|
||||
send_error(Req1, Code, Message)
|
||||
{error, Code, Msg, Req1} ->
|
||||
handler_utils:send_error(Req1, Code, Msg)
|
||||
end.
|
||||
|
||||
update_report(Req) ->
|
||||
case auth_admin(Req) of
|
||||
case handler_utils:auth_admin(Req) of
|
||||
{ok, AdminId, Req1} ->
|
||||
case admin_utils:is_admin(AdminId) of
|
||||
true ->
|
||||
ReportId = cowboy_req:binding(id, Req1),
|
||||
{ok, Body, Req2} = cowboy_req:read_body(Req1),
|
||||
try jsx:decode(Body, [return_maps]) of
|
||||
#{<<"status">> := NewStatus, <<"reason">> := Reason} ->
|
||||
StatusAtom = binary_to_atom(NewStatus, utf8),
|
||||
case core_report:update_status(ReportId, StatusAtom, AdminId) of
|
||||
{ok, Report} ->
|
||||
log_audit(AdminId, <<"update_report_status">>, <<"report">>, ReportId, Reason),
|
||||
send_json(Req2, 200, report_to_json(Report));
|
||||
{error, not_found} ->
|
||||
send_error(Req2, 404, <<"Report not found">>);
|
||||
{error, _} ->
|
||||
send_error(Req2, 500, <<"Internal server error">>)
|
||||
end;
|
||||
_ ->
|
||||
send_error(Req2, 400, <<"Missing status or reason">>)
|
||||
catch
|
||||
_:_ -> send_error(Req2, 400, <<"Invalid JSON">>)
|
||||
ReportId = cowboy_req:binding(id, Req1),
|
||||
{ok, Body, Req2} = cowboy_req:read_body(Req1),
|
||||
try jsx:decode(Body, [return_maps]) of
|
||||
#{<<"status">> := Status} ->
|
||||
case logic_report:update_report_status(AdminId, ReportId, Status) of
|
||||
{ok, Report} ->
|
||||
handler_utils:send_json(Req2, 200, handler_utils:report_to_json(Report));
|
||||
{error, not_found} ->
|
||||
handler_utils:send_error(Req2, 404, <<"Report not found">>);
|
||||
{error, _} ->
|
||||
handler_utils:send_error(Req2, 500, <<"Internal server error">>)
|
||||
end;
|
||||
false ->
|
||||
send_error(Req1, 403, <<"Admin access required">>)
|
||||
_ ->
|
||||
handler_utils:send_error(Req2, 400, <<"Missing status field">>)
|
||||
catch
|
||||
_:_ -> handler_utils:send_error(Req1, 400, <<"Invalid JSON">>)
|
||||
end;
|
||||
{error, Code, Message, Req1} ->
|
||||
send_error(Req1, Code, Message)
|
||||
end.
|
||||
|
||||
auth_admin(Req) ->
|
||||
case handler_auth:authenticate(Req) of
|
||||
{ok, AdminId, Req1} ->
|
||||
case admin_utils:is_admin(AdminId) of
|
||||
true -> {ok, AdminId, Req1};
|
||||
false -> {error, 403, <<"Admin access required">>, Req1}
|
||||
end;
|
||||
{error, Code, Message, Req1} ->
|
||||
{error, Code, Message, Req1}
|
||||
end.
|
||||
|
||||
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,
|
||||
<<"127.0.0.1">>, Reason);
|
||||
_ -> ok
|
||||
end.
|
||||
|
||||
report_to_json(R) ->
|
||||
#{
|
||||
id => R#report.id,
|
||||
reporter_id => R#report.reporter_id,
|
||||
target_type => R#report.target_type,
|
||||
target_id => R#report.target_id,
|
||||
reason => R#report.reason,
|
||||
status => R#report.status,
|
||||
created_at => datetime_to_iso8601(R#report.created_at),
|
||||
resolved_at => datetime_to_iso8601(R#report.resolved_at)
|
||||
}.
|
||||
|
||||
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]));
|
||||
datetime_to_iso8601(undefined) -> undefined.
|
||||
|
||||
send_json(Req, Status, Data) ->
|
||||
Body = jsx:encode(Data),
|
||||
cowboy_req:reply(Status, #{<<"content-type">> => <<"application/json">>}, Body, Req),
|
||||
{ok, Body, []}.
|
||||
|
||||
send_error(Req, Status, Message) ->
|
||||
Body = jsx:encode(#{error => Message}),
|
||||
cowboy_req:reply(Status, #{<<"content-type">> => <<"application/json">>}, Body, Req),
|
||||
{ok, Body, []}.
|
||||
{error, Code, Msg, Req1} ->
|
||||
handler_utils:send_error(Req1, Code, Msg)
|
||||
end.
|
||||
@@ -1,109 +1,135 @@
|
||||
%%%-------------------------------------------------------------------
|
||||
%%% @doc Административный обработчик списка жалоб.
|
||||
%%% GET – список с пагинацией, фильтрацией и сортировкой.
|
||||
%%% @end
|
||||
%%%-------------------------------------------------------------------
|
||||
-module(admin_handler_reports).
|
||||
-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_reports(Req);
|
||||
<<"PUT">> -> update_report(Req);
|
||||
_ -> send_error(Req, 405, <<"Method not allowed">>)
|
||||
_ -> handler_utils:send_error(Req, 405, <<"Method not allowed">>)
|
||||
end.
|
||||
|
||||
list_reports(Req) ->
|
||||
case auth_admin(Req) of
|
||||
{ok, AdminId, Req1} ->
|
||||
case admin_utils:is_admin(AdminId) of
|
||||
true ->
|
||||
{ok, Reports} = core_report:list_all(),
|
||||
send_json(Req1, 200, [report_to_json(R) || R <- Reports]);
|
||||
false ->
|
||||
send_error(Req1, 403, <<"Admin access required">>)
|
||||
end;
|
||||
{error, Code, Message, Req1} ->
|
||||
send_error(Req1, Code, Message)
|
||||
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()
|
||||
}}}
|
||||
}
|
||||
}
|
||||
}
|
||||
].
|
||||
|
||||
update_report(Req) ->
|
||||
case auth_admin(Req) of
|
||||
{ok, AdminId, Req1} ->
|
||||
case admin_utils:is_admin(AdminId) of
|
||||
true ->
|
||||
ReportId = cowboy_req:binding(id, Req1),
|
||||
{ok, Body, Req2} = cowboy_req:read_body(Req1),
|
||||
try jsx:decode(Body, [return_maps]) of
|
||||
#{<<"status">> := NewStatus, <<"reason">> := Reason} ->
|
||||
StatusAtom = binary_to_atom(NewStatus, utf8),
|
||||
case core_report:update_status(ReportId, StatusAtom, AdminId) of
|
||||
{ok, Report} ->
|
||||
log_audit(AdminId, <<"update_report_status">>, <<"report">>, ReportId, Reason),
|
||||
send_json(Req2, 200, report_to_json(Report));
|
||||
{error, not_found} ->
|
||||
send_error(Req2, 404, <<"Report not found">>);
|
||||
{error, _} ->
|
||||
send_error(Req2, 500, <<"Internal server error">>)
|
||||
end;
|
||||
_ ->
|
||||
send_error(Req2, 400, <<"Missing status or reason">>)
|
||||
catch
|
||||
_:_ -> send_error(Req2, 400, <<"Invalid JSON">>)
|
||||
end;
|
||||
false ->
|
||||
send_error(Req1, 403, <<"Admin access required">>)
|
||||
end;
|
||||
{error, Code, Message, Req1} ->
|
||||
send_error(Req1, Code, Message)
|
||||
end.
|
||||
|
||||
auth_admin(Req) ->
|
||||
case handler_auth:authenticate(Req) of
|
||||
{ok, AdminId, Req1} ->
|
||||
case admin_utils:is_admin(AdminId) of
|
||||
true -> {ok, AdminId, Req1};
|
||||
false -> {error, 403, <<"Admin access required">>, Req1}
|
||||
end;
|
||||
{error, Code, Message, Req1} ->
|
||||
{error, Code, Message, Req1}
|
||||
end.
|
||||
|
||||
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,
|
||||
<<"127.0.0.1">>, Reason);
|
||||
_ -> ok
|
||||
end.
|
||||
|
||||
report_to_json(R) ->
|
||||
report_schema() ->
|
||||
#{
|
||||
id => R#report.id,
|
||||
reporter_id => R#report.reporter_id,
|
||||
target_type => R#report.target_type,
|
||||
target_id => R#report.target_id,
|
||||
reason => R#report.reason,
|
||||
status => R#report.status,
|
||||
created_at => datetime_to_iso8601(R#report.created_at),
|
||||
resolved_at => datetime_to_iso8601(R#report.resolved_at)
|
||||
type => object,
|
||||
properties => #{
|
||||
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">>},
|
||||
resolved_at => #{type => string, format => <<"date-time">>, nullable => true},
|
||||
resolved_by => #{type => string, nullable => true}
|
||||
}
|
||||
}.
|
||||
|
||||
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]));
|
||||
datetime_to_iso8601(undefined) -> undefined.
|
||||
%%% Internal functions
|
||||
|
||||
send_json(Req, Status, Data) ->
|
||||
Headers = #{
|
||||
<<"content-type">> => <<"application/json">>,
|
||||
<<"access-control-allow-origin">> => <<"*">>,
|
||||
<<"access-control-expose-headers">> => <<"Content-Range">>
|
||||
},
|
||||
Body = jsx:encode(Data),
|
||||
cowboy_req:reply(Status, Headers, Body, Req),
|
||||
{ok, Body, []}.
|
||||
-spec list_reports(cowboy_req:req()) -> {ok, binary(), cowboy_req:req()}.
|
||||
list_reports(Req) ->
|
||||
case handler_utils:auth_admin(Req) of
|
||||
{ok, AdminId, Req1} ->
|
||||
Filters = parse_report_filters(Req1),
|
||||
Pagination = handler_utils:parse_pagination_params(Req1),
|
||||
case logic_report:list_reports(AdminId) of
|
||||
{ok, AllReports} ->
|
||||
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)),
|
||||
Json = [handler_utils:report_to_json(R) || R <- Page],
|
||||
ExtraHeaders = pagination_headers(Pagination, Total),
|
||||
handler_utils:send_json(Req1, 200, Json, ExtraHeaders);
|
||||
{error, access_denied} ->
|
||||
handler_utils:send_error(Req1, 403, <<"Admin access required">>)
|
||||
end;
|
||||
{error, Code, Msg, Req1} ->
|
||||
handler_utils:send_error(Req1, Code, Msg)
|
||||
end.
|
||||
|
||||
send_error(Req, Status, Message) ->
|
||||
Body = jsx:encode(#{error => Message}),
|
||||
cowboy_req:reply(Status, #{<<"content-type">> => <<"application/json">>}, Body, Req),
|
||||
{ok, Body, []}.
|
||||
parse_report_filters(Req) ->
|
||||
Qs = cowboy_req:parse_qs(Req),
|
||||
#{
|
||||
status => proplists:get_value(<<"status">>, Qs),
|
||||
target_type => proplists:get_value(<<"target_type">>, Qs),
|
||||
q => proplists:get_value(<<"q">>, Qs)
|
||||
}.
|
||||
|
||||
apply_report_filters(Reports, Filters) ->
|
||||
Status = maps:get(status, Filters, undefined),
|
||||
TargetType = maps:get(target_type, Filters, undefined),
|
||||
Q = maps:get(q, Filters, undefined),
|
||||
F1 = case Status of
|
||||
undefined -> Reports;
|
||||
_ -> [R || R <- Reports, R#report.status =:= Status]
|
||||
end,
|
||||
F2 = case TargetType of
|
||||
undefined -> F1;
|
||||
_ -> [R || R <- F1, R#report.target_type =:= TargetType]
|
||||
end,
|
||||
case Q of
|
||||
undefined -> F2;
|
||||
_ -> [R || R <- F2,
|
||||
string:str(binary_to_list(R#report.reason), binary_to_list(Q)) > 0]
|
||||
end.
|
||||
|
||||
sort_reports(Reports, #{sort := Sort, order := Order}) ->
|
||||
Field = binary_to_existing_atom(Sort, utf8),
|
||||
lists:sort(
|
||||
fun(A, B) ->
|
||||
ValA = report_field(A, Field),
|
||||
ValB = report_field(B, Field),
|
||||
if Order == <<"asc">> -> ValA =< ValB;
|
||||
true -> ValA >= ValB
|
||||
end
|
||||
end, Reports).
|
||||
|
||||
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">>
|
||||
}.
|
||||
@@ -1,101 +1,136 @@
|
||||
-module(admin_handler_reviews).
|
||||
-behaviour(cowboy_handler).
|
||||
|
||||
-export([init/2]).
|
||||
-export([trails/0]).
|
||||
|
||||
-include("records.hrl").
|
||||
|
||||
-export([init/2]).
|
||||
|
||||
%%% 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);
|
||||
_ -> 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">>,
|
||||
description => <<"Bulk update review statuses">>,
|
||||
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">>]}
|
||||
}
|
||||
}
|
||||
}}}
|
||||
},
|
||||
responses => #{
|
||||
200 => #{description => <<"Number of updated reviews">>}
|
||||
}
|
||||
}
|
||||
].
|
||||
|
||||
review_schema() ->
|
||||
#{
|
||||
type => object,
|
||||
properties => #{
|
||||
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">>}
|
||||
}
|
||||
}.
|
||||
|
||||
%%% Internal functions
|
||||
|
||||
list_reviews(Req) ->
|
||||
case auth_admin(Req) of
|
||||
case handler_utils:auth_admin(Req) of
|
||||
{ok, _AdminId, Req1} ->
|
||||
Filters = parse_filters(Req1),
|
||||
Reviews = logic_review:list_admin_reviews(Filters),
|
||||
Json = [review_to_json(R) || R <- Reviews],
|
||||
send_json(Req1, 200, Json);
|
||||
Filters = parse_review_filters(Req1),
|
||||
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],
|
||||
ExtraHeaders = pagination_headers(Pagination, Total),
|
||||
handler_utils:send_json(Req1, 200, Json, ExtraHeaders);
|
||||
{error, Code, Msg, Req1} ->
|
||||
send_error(Req1, Code, Msg)
|
||||
handler_utils:send_error(Req1, Code, Msg)
|
||||
end.
|
||||
|
||||
bulk_update_reviews(Req) ->
|
||||
case auth_admin(Req) of
|
||||
case handler_utils:auth_admin(Req) of
|
||||
{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, Count} ->
|
||||
send_json(Req2, 200, #{updated_count => Count});
|
||||
{ok, UpdatedCount} ->
|
||||
handler_utils:send_json(Req2, 200, #{updated_count => UpdatedCount});
|
||||
{error, Reason} ->
|
||||
send_error(Req2, 400, Reason)
|
||||
handler_utils:send_error(Req2, 400, Reason)
|
||||
end
|
||||
catch
|
||||
_:_ -> send_error(Req1, 400, <<"Invalid JSON body">>)
|
||||
_:_ -> handler_utils:send_error(Req1, 400, <<"Invalid JSON body">>)
|
||||
end;
|
||||
{error, Code, Msg, Req1} ->
|
||||
send_error(Req1, Code, Msg)
|
||||
handler_utils:send_error(Req1, Code, Msg)
|
||||
end.
|
||||
|
||||
auth_admin(Req) ->
|
||||
case handler_auth:authenticate(Req) of
|
||||
{ok, AdminId, Req1} ->
|
||||
case admin_utils:is_admin(AdminId) of
|
||||
true -> {ok, AdminId, Req1};
|
||||
false -> {error, 403, <<"Admin access required">>, Req1}
|
||||
end;
|
||||
{error, Code, Msg, Req1} ->
|
||||
{error, Code, Msg, Req1}
|
||||
end.
|
||||
|
||||
%% Извлечение параметров фильтрации из query string.
|
||||
%% Например: ?target_type=event&target_id=...&user_id=...
|
||||
parse_filters(Req) ->
|
||||
parse_review_filters(Req) ->
|
||||
Qs = cowboy_req:parse_qs(Req),
|
||||
lists:filtermap(
|
||||
fun
|
||||
({<<"target_type">>, Val}) -> {true, {target_type, Val}};
|
||||
({<<"target_id">>, Val}) -> {true, {target_id, Val}};
|
||||
({<<"user_id">>, Val}) -> {true, {user_id, Val}};
|
||||
(_) -> false
|
||||
end,
|
||||
Qs
|
||||
).
|
||||
|
||||
review_to_json(R) ->
|
||||
#{
|
||||
id => R#review.id,
|
||||
user_id => R#review.user_id,
|
||||
target_type => R#review.target_type,
|
||||
target_id => R#review.target_id,
|
||||
rating => R#review.rating,
|
||||
comment => R#review.comment,
|
||||
status => R#review.status,
|
||||
created_at => datetime_to_iso8601(R#review.created_at),
|
||||
updated_at => datetime_to_iso8601(R#review.updated_at)
|
||||
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)
|
||||
}.
|
||||
|
||||
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]));
|
||||
datetime_to_iso8601(undefined) -> undefined.
|
||||
|
||||
send_json(Req, Status, Data) ->
|
||||
Headers = #{
|
||||
<<"content-type">> => <<"application/json">>,
|
||||
<<"access-control-allow-origin">> => <<"*">>,
|
||||
<<"access-control-expose-headers">> => <<"Content-Range">>
|
||||
},
|
||||
Body = jsx:encode(Data),
|
||||
cowboy_req:reply(Status, Headers, Body, Req),
|
||||
{ok, Body, []}.
|
||||
|
||||
send_error(Req, Status, Message) ->
|
||||
Body = jsx:encode(#{error => Message}),
|
||||
cowboy_req:reply(Status, #{<<"content-type">> => <<"application/json">>}, Body, Req),
|
||||
{ok, Body, []}.
|
||||
pagination_headers(#{limit := Limit, offset := Offset}, Total) ->
|
||||
RangeEnd = min(Offset + Limit - 1, Total - 1),
|
||||
#{
|
||||
<<"content-range">> => iolist_to_binary(io_lib:format("items ~B-~B/~B", [Offset, RangeEnd, Total])),
|
||||
<<"x-total-count">> => integer_to_binary(Total),
|
||||
<<"access-control-expose-headers">> => <<"Content-Range, X-Total-Count">>
|
||||
}.
|
||||
@@ -1,93 +1,127 @@
|
||||
-module(admin_handler_reviews_by_id).
|
||||
-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">> -> get_review(Req);
|
||||
<<"PUT">> -> update_review(Req);
|
||||
_ -> 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 => <<"Review ID">>,
|
||||
required => true,
|
||||
schema => #{type => string}
|
||||
}],
|
||||
[
|
||||
#{ % GET by id
|
||||
path => <<"/v1/admin/reviews/:id">>,
|
||||
method => <<"GET">>,
|
||||
description => <<"Get review by ID (admin)">>,
|
||||
tags => [<<"Reviews">>],
|
||||
parameters => BaseParams,
|
||||
responses => #{
|
||||
200 => #{
|
||||
description => <<"Review details">>,
|
||||
content => #{<<"application/json">> => #{schema => review_schema()}}
|
||||
}
|
||||
}
|
||||
},
|
||||
#{ % PUT update
|
||||
path => <<"/v1/admin/reviews/:id">>,
|
||||
method => <<"PUT">>,
|
||||
description => <<"Update review (admin)">>,
|
||||
tags => [<<"Reviews">>],
|
||||
parameters => BaseParams,
|
||||
requestBody => #{
|
||||
required => true,
|
||||
content => #{<<"application/json">> => #{schema => review_update_schema()}}
|
||||
},
|
||||
responses => #{
|
||||
200 => #{description => <<"Updated review">>}
|
||||
}
|
||||
}
|
||||
].
|
||||
|
||||
review_schema() ->
|
||||
#{
|
||||
type => object,
|
||||
properties => #{
|
||||
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">>}
|
||||
}
|
||||
}.
|
||||
|
||||
review_update_schema() ->
|
||||
#{
|
||||
type => object,
|
||||
properties => #{
|
||||
status => #{type => string, enum => [<<"visible">>, <<"hidden">>, <<"deleted">>]},
|
||||
reason => #{type => string},
|
||||
comment => #{type => string},
|
||||
rating => #{type => integer, minimum => 1, maximum => 5}
|
||||
}
|
||||
}.
|
||||
|
||||
%%% Internal functions
|
||||
|
||||
get_review(Req) ->
|
||||
case handler_auth:authenticate(Req) of
|
||||
{ok, AdminId, Req1} ->
|
||||
case admin_utils:is_admin(AdminId) of
|
||||
true ->
|
||||
ReviewId = cowboy_req:binding(id, Req1),
|
||||
case core_review:get_by_id(ReviewId) of
|
||||
{ok, Review} ->
|
||||
send_json(Req1, 200, review_to_json(Review));
|
||||
{error, not_found} ->
|
||||
send_error(Req1, 404, <<"Review not found">>)
|
||||
end;
|
||||
false ->
|
||||
send_error(Req1, 403, <<"Admin access required">>)
|
||||
case handler_utils:auth_admin(Req) of
|
||||
{ok, _AdminId, Req1} ->
|
||||
ReviewId = cowboy_req:binding(id, Req1),
|
||||
case logic_review:get_review_admin(ReviewId) of
|
||||
{ok, Review} ->
|
||||
handler_utils:send_json(Req1, 200, handler_utils:review_to_json(Review));
|
||||
{error, not_found} ->
|
||||
handler_utils:send_error(Req1, 404, <<"Review not found">>);
|
||||
{error, _} ->
|
||||
handler_utils:send_error(Req1, 500, <<"Internal server error">>)
|
||||
end;
|
||||
{error, Code, Message, Req1} ->
|
||||
send_error(Req1, Code, Message)
|
||||
{error, Code, Msg, Req1} ->
|
||||
handler_utils:send_error(Req1, Code, Msg)
|
||||
end.
|
||||
|
||||
update_review(Req) ->
|
||||
case handler_auth:authenticate(Req) of
|
||||
{ok, AdminId, Req1} ->
|
||||
case admin_utils:is_admin(AdminId) of
|
||||
true ->
|
||||
ReviewId = cowboy_req:binding(id, Req1),
|
||||
{ok, Body, Req2} = cowboy_req:read_body(Req1),
|
||||
try jsx:decode(Body, [return_maps]) of
|
||||
#{<<"status">> := NewStatus} ->
|
||||
case core_review:update_status(ReviewId, NewStatus) of
|
||||
{ok, Review} ->
|
||||
send_json(Req2, 200, review_to_json(Review));
|
||||
{error, not_found} ->
|
||||
send_error(Req2, 404, <<"Review not found">>);
|
||||
{error, _} ->
|
||||
send_error(Req2, 500, <<"Internal server error">>)
|
||||
end;
|
||||
_ ->
|
||||
send_error(Req2, 400, <<"Missing status field">>)
|
||||
catch
|
||||
_:_ -> send_error(Req2, 400, <<"Invalid JSON">>)
|
||||
case handler_utils:auth_admin(Req) of
|
||||
{ok, _AdminId, Req1} ->
|
||||
ReviewId = 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) ->
|
||||
Updates = maps:to_list(UpdatesMap),
|
||||
case logic_review:update_review_admin(ReviewId, Updates) of
|
||||
{ok, Review} ->
|
||||
handler_utils:send_json(Req2, 200, handler_utils:review_to_json(Review));
|
||||
{error, not_found} ->
|
||||
handler_utils:send_error(Req2, 404, <<"Review not found">>);
|
||||
{error, _} ->
|
||||
handler_utils:send_error(Req2, 500, <<"Internal server error">>)
|
||||
end;
|
||||
false ->
|
||||
send_error(Req1, 403, <<"Admin access required">>)
|
||||
_ ->
|
||||
handler_utils:send_error(Req2, 400, <<"Invalid JSON">>)
|
||||
catch
|
||||
_:_ -> handler_utils:send_error(Req1, 400, <<"Invalid JSON format">>)
|
||||
end;
|
||||
{error, Code, Message, Req1} ->
|
||||
send_error(Req1, Code, Message)
|
||||
end.
|
||||
|
||||
review_to_json(R) ->
|
||||
#{
|
||||
id => R#review.id,
|
||||
user_id => R#review.user_id,
|
||||
target_type => R#review.target_type,
|
||||
target_id => R#review.target_id,
|
||||
rating => R#review.rating,
|
||||
comment => R#review.comment,
|
||||
status => R#review.status,
|
||||
created_at => datetime_to_iso8601(R#review.created_at),
|
||||
updated_at => datetime_to_iso8601(R#review.updated_at)
|
||||
}.
|
||||
|
||||
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]));
|
||||
datetime_to_iso8601(undefined) -> undefined.
|
||||
|
||||
send_json(Req, Status, Data) ->
|
||||
Headers = #{
|
||||
<<"content-type">> => <<"application/json">>,
|
||||
<<"access-control-allow-origin">> => <<"*">>,
|
||||
<<"access-control-expose-headers">> => <<"Content-Range">>
|
||||
},
|
||||
Body = jsx:encode(Data),
|
||||
cowboy_req:reply(Status, Headers, Body, Req),
|
||||
{ok, Body, []}.
|
||||
|
||||
send_error(Req, Status, Message) ->
|
||||
Body = jsx:encode(#{error => Message}),
|
||||
cowboy_req:reply(Status, #{<<"content-type">> => <<"application/json">>}, Body, Req),
|
||||
{ok, Body, []}.
|
||||
{error, Code, Msg, Req1} ->
|
||||
handler_utils:send_error(Req1, Code, Msg)
|
||||
end.
|
||||
@@ -1,63 +1,102 @@
|
||||
%%%-------------------------------------------------------------------
|
||||
%%% @doc Административный обработчик для получения статистики.
|
||||
%%% GET – возвращает агрегированную статистику для дашборда.
|
||||
%%% Поддерживает фильтрацию по диапазону дат (from, to).
|
||||
%%% @end
|
||||
%%%-------------------------------------------------------------------
|
||||
-module(admin_handler_stats).
|
||||
-include("records.hrl").
|
||||
-export([init/2]).
|
||||
-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_stats(Req);
|
||||
_ -> send_error(Req, 405, <<"Method not allowed">>)
|
||||
_ -> handler_utils:send_error(Req, 405, <<"Method not allowed">>)
|
||||
end.
|
||||
|
||||
%%% Swagger metadata
|
||||
-spec trails() -> [map()].
|
||||
trails() ->
|
||||
[
|
||||
#{
|
||||
path => <<"/v1/admin/stats">>,
|
||||
method => <<"GET">>,
|
||||
description => <<"Get admin dashboard statistics">>,
|
||||
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)">>}
|
||||
],
|
||||
responses => #{
|
||||
200 => #{
|
||||
description => <<"Statistics object">>,
|
||||
content => #{<<"application/json">> => #{schema => #{
|
||||
type => object,
|
||||
properties => stats_schema()
|
||||
}}}
|
||||
},
|
||||
403 => #{description => <<"Admin access required">>}
|
||||
}
|
||||
}
|
||||
].
|
||||
|
||||
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},
|
||||
<<"active_subscriptions">> => #{type => integer}
|
||||
}.
|
||||
|
||||
%%% Internal functions
|
||||
|
||||
%% @doc Получить статистику с учетом параметров запроса.
|
||||
-spec get_stats(cowboy_req:req()) -> {ok, binary(), cowboy_req:req()}.
|
||||
get_stats(Req) ->
|
||||
case handler_auth:authenticate(Req) of
|
||||
case handler_utils:auth_admin(Req) of
|
||||
{ok, AdminId, Req1} ->
|
||||
case admin_utils:is_admin(AdminId) of
|
||||
true ->
|
||||
{ok, Admin} = core_admin:get_by_id(AdminId),
|
||||
Role = Admin#admin.role,
|
||||
% Извлекаем параметры from и to из запроса
|
||||
Stats = case parse_date_range(Req1) of
|
||||
{ok, From, To} ->
|
||||
logic_stats:get_stats(Role, AdminId, From, To);
|
||||
_ ->
|
||||
logic_stats:get_stats(Role, AdminId)
|
||||
end,
|
||||
send_json(Req1, 200, Stats);
|
||||
false ->
|
||||
send_error(Req1, 403, <<"Admin access required">>)
|
||||
end;
|
||||
{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)
|
||||
end,
|
||||
handler_utils:send_json(Req1, 200, Stats);
|
||||
{error, Code, Message, Req1} ->
|
||||
send_error(Req1, Code, Message)
|
||||
handler_utils:send_error(Req1, Code, Message)
|
||||
end.
|
||||
|
||||
%% @private Разбирает параметры 'from' и 'to' из строки запроса.
|
||||
%% В случае успеха возвращает {ok, FromDT, ToDT}.
|
||||
-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),
|
||||
To = 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
|
||||
{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}}.
|
||||
|
||||
send_json(Req, Status, Data) ->
|
||||
Body = jsx:encode(Data),
|
||||
cowboy_req:reply(Status, #{<<"content-type">> => <<"application/json">>}, Body, Req),
|
||||
{ok, Body, []}.
|
||||
|
||||
send_error(Req, Status, Message) ->
|
||||
Body = jsx:encode(#{error => Message}),
|
||||
cowboy_req:reply(Status, #{<<"content-type">> => <<"application/json">>}, Body, Req),
|
||||
{ok, Body, []}.
|
||||
{{Y, M, D}, {H, Min, S}}.
|
||||
@@ -1,197 +1,122 @@
|
||||
%%%-------------------------------------------------------------------
|
||||
%%% @doc Административный обработчик подписок.
|
||||
%%% GET – список с пагинацией и фильтрацией.
|
||||
%%% @end
|
||||
%%%-------------------------------------------------------------------
|
||||
-module(admin_handler_subscriptions).
|
||||
-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:binding(id, Req) of
|
||||
undefined -> handle_collection(Req);
|
||||
_SubId -> handle_item(Req)
|
||||
end.
|
||||
|
||||
%% ================== Коллекция ==================
|
||||
handle_collection(Req) ->
|
||||
case cowboy_req:method(Req) of
|
||||
<<"GET">> -> list_subscriptions(Req);
|
||||
<<"POST">> -> create_subscription(Req);
|
||||
_ -> send_error(Req, 405, <<"Method not allowed">>)
|
||||
<<"GET">> -> list_subscriptions(Req);
|
||||
_ -> handler_utils:send_error(Req, 405, <<"Method not allowed">>)
|
||||
end.
|
||||
|
||||
%% ================== Элемент ==================
|
||||
handle_item(Req) ->
|
||||
SubId = cowboy_req:binding(id, Req),
|
||||
case cowboy_req:method(Req) of
|
||||
<<"GET">> -> get_subscription(SubId, Req);
|
||||
<<"PUT">> -> update_subscription(SubId, Req);
|
||||
<<"DELETE">> -> delete_subscription(SubId, Req);
|
||||
_ -> send_error(Req, 405, <<"Method not allowed">>)
|
||||
end.
|
||||
-spec trails() -> [map()].
|
||||
trails() ->
|
||||
[
|
||||
#{
|
||||
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">>},
|
||||
#{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 => <<"offset">>, in => <<"query">>, schema => #{type => integer}, description => <<"Offset">>}
|
||||
],
|
||||
responses => #{
|
||||
200 => #{
|
||||
description => <<"Array of subscriptions">>,
|
||||
content => #{<<"application/json">> => #{schema => #{
|
||||
type => array,
|
||||
items => subscription_schema()
|
||||
}}}
|
||||
}
|
||||
}
|
||||
}
|
||||
].
|
||||
|
||||
%% ================== GET /subscriptions ==================
|
||||
list_subscriptions(Req) ->
|
||||
case auth_admin(Req) of
|
||||
{ok, _AdminId, Req1} ->
|
||||
Subs = core_subscription:list_subscriptions(),
|
||||
send_json(Req1, 200, [subscription_to_json(S) || S <- Subs]);
|
||||
{error, Code, Message, Req1} ->
|
||||
send_error(Req1, Code, Message)
|
||||
end.
|
||||
|
||||
%% ================== GET /subscriptions/:id ==================
|
||||
get_subscription(Id, Req) ->
|
||||
case auth_admin(Req) of
|
||||
{ok, _AdminId, Req1} ->
|
||||
case core_subscription:get_by_id(Id) of
|
||||
{ok, Sub} ->
|
||||
send_json(Req1, 200, subscription_to_json(Sub));
|
||||
{error, not_found} ->
|
||||
send_error(Req1, 404, <<"Subscription not found">>)
|
||||
end;
|
||||
{error, Code, Message, Req1} ->
|
||||
send_error(Req1, Code, Message)
|
||||
end.
|
||||
|
||||
%% ================== POST /subscriptions ==================
|
||||
create_subscription(Req) ->
|
||||
case auth_admin(Req) of
|
||||
{ok, AdminId, Req1} ->
|
||||
{ok, Body, Req2} = cowboy_req:read_body(Req1),
|
||||
try
|
||||
Decoded = jsx:decode(Body, [return_maps]),
|
||||
case Decoded of
|
||||
#{<<"user_id">> := UserId, <<"plan">> := Plan} ->
|
||||
case validate_plan(Plan) of
|
||||
true ->
|
||||
SubData = maps:merge(#{
|
||||
<<"status">> => <<"active">>,
|
||||
<<"trial_used">> => false
|
||||
}, maps:without([<<"id">>], Decoded)), % ← исправлено: Decoded, а не Body
|
||||
case core_subscription:create_subscription(SubData) of
|
||||
{ok, Sub} ->
|
||||
log_audit(AdminId, <<"create_subscription">>, <<"subscription">>, Sub#subscription.id, UserId),
|
||||
send_json(Req2, 201, subscription_to_json(Sub));
|
||||
{error, Reason} ->
|
||||
send_error(Req2, 500, Reason)
|
||||
end;
|
||||
false ->
|
||||
send_error(Req2, 400, <<"Invalid plan value">>)
|
||||
end;
|
||||
_ ->
|
||||
send_error(Req2, 400, <<"Missing 'user_id' or 'plan' field">>)
|
||||
end
|
||||
catch
|
||||
_:_ -> send_error(Req2, 400, <<"Invalid JSON">>)
|
||||
end;
|
||||
{error, Code, Message, Req1} ->
|
||||
send_error(Req1, Code, Message)
|
||||
end.
|
||||
|
||||
%% ================== PUT /subscriptions/:id ==================
|
||||
update_subscription(Id, Req) ->
|
||||
case auth_admin(Req) of
|
||||
{ok, AdminId, Req1} ->
|
||||
{ok, Body, Req2} = cowboy_req:read_body(Req1),
|
||||
try
|
||||
Updates = jsx:decode(Body, [return_maps]),
|
||||
case map_size(Updates) > 0 of
|
||||
true ->
|
||||
case core_subscription:update_subscription(Id, Updates) of
|
||||
{ok, Sub} ->
|
||||
log_audit(AdminId, <<"update_subscription">>, <<"subscription">>, Id, <<"">>),
|
||||
send_json(Req2, 200, subscription_to_json(Sub));
|
||||
{error, not_found} ->
|
||||
send_error(Req2, 404, <<"Subscription not found">>);
|
||||
{error, Reason} ->
|
||||
send_error(Req2, 500, Reason)
|
||||
end;
|
||||
false ->
|
||||
send_error(Req2, 400, <<"Request body is empty">>)
|
||||
end
|
||||
catch
|
||||
_:_ -> send_error(Req2, 400, <<"Invalid JSON">>)
|
||||
end;
|
||||
{error, Code, Message, Req1} ->
|
||||
send_error(Req1, Code, Message)
|
||||
end.
|
||||
|
||||
%% ================== DELETE /subscriptions/:id ==================
|
||||
delete_subscription(Id, Req) ->
|
||||
case auth_admin(Req) of
|
||||
{ok, AdminId, Req1} ->
|
||||
case core_subscription:delete_subscription(Id) of
|
||||
{ok, deleted} ->
|
||||
log_audit(AdminId, <<"delete_subscription">>, <<"subscription">>, Id, <<"">>),
|
||||
send_json(Req1, 200, #{status => <<"deleted">>});
|
||||
{error, not_found} ->
|
||||
send_error(Req1, 404, <<"Subscription not found">>)
|
||||
end;
|
||||
{error, Code, Message, Req1} ->
|
||||
send_error(Req1, Code, Message)
|
||||
end.
|
||||
|
||||
%% ================== Аутентификация и роли ==================
|
||||
auth_admin(Req) ->
|
||||
case handler_auth:authenticate(Req) of
|
||||
{ok, AdminId, Req1} ->
|
||||
case admin_utils:is_admin(AdminId) of
|
||||
true -> {ok, AdminId, Req1};
|
||||
false -> {error, 403, <<"Admin access required">>, Req1}
|
||||
end;
|
||||
{error, Code, Message, Req1} ->
|
||||
{error, Code, Message, Req1}
|
||||
end.
|
||||
|
||||
%% ================== Аудит ==================
|
||||
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, <<"127.0.0.1">>, Reason);
|
||||
_ -> ok
|
||||
end.
|
||||
|
||||
%% ================== Сериализация ==================
|
||||
subscription_to_json(S) ->
|
||||
subscription_schema() ->
|
||||
#{
|
||||
id => S#subscription.id,
|
||||
user_id => S#subscription.user_id,
|
||||
plan => atom_to_binary(S#subscription.plan, utf8),
|
||||
status => atom_to_binary(S#subscription.status, utf8),
|
||||
trial_used => S#subscription.trial_used,
|
||||
started_at => datetime_to_iso8601(S#subscription.started_at),
|
||||
expires_at => datetime_to_iso8601(S#subscription.expires_at),
|
||||
created_at => datetime_to_iso8601(S#subscription.created_at),
|
||||
updated_at => datetime_to_iso8601(S#subscription.updated_at)
|
||||
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">>}
|
||||
}
|
||||
}.
|
||||
|
||||
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]));
|
||||
datetime_to_iso8601(undefined) -> undefined.
|
||||
%%% Internal functions
|
||||
|
||||
%% ================== Валидация ==================
|
||||
validate_plan(Plan) when is_binary(Plan) ->
|
||||
lists:member(Plan, [<<"monthly">>, <<"yearly">>, <<"quarterly">>, <<"biannual">>, <<"annual">>]);
|
||||
validate_plan(_) -> false.
|
||||
list_subscriptions(Req) ->
|
||||
case handler_utils:auth_admin(Req) of
|
||||
{ok, _AdminId, Req1} ->
|
||||
Filters = parse_subscription_filters(Req1),
|
||||
Pagination = handler_utils:parse_pagination_params(Req1),
|
||||
{ok, AllSubscriptions} = core_subscription:list_all(),
|
||||
Filtered = apply_filters(AllSubscriptions, Filters),
|
||||
Sorted = sort_subscriptions(Filtered, Pagination),
|
||||
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),
|
||||
handler_utils:send_json(Req1, 200, Json, ExtraHeaders);
|
||||
{error, Code, Msg, Req1} ->
|
||||
handler_utils:send_error(Req1, Code, Msg)
|
||||
end.
|
||||
|
||||
%% ================== HTTP-ответы ==================
|
||||
send_json(Req, Status, Data) ->
|
||||
Headers = #{
|
||||
<<"content-type">> => <<"application/json">>,
|
||||
<<"access-control-allow-origin">> => <<"*">>,
|
||||
<<"access-control-expose-headers">> => <<"Content-Range">>
|
||||
},
|
||||
Body = jsx:encode(Data),
|
||||
cowboy_req:reply(Status, Headers, Body, Req),
|
||||
{ok, Body, []}.
|
||||
parse_subscription_filters(Req) ->
|
||||
Qs = cowboy_req:parse_qs(Req),
|
||||
#{
|
||||
plan => proplists:get_value(<<"plan">>, Qs),
|
||||
status => proplists:get_value(<<"status">>, Qs)
|
||||
}.
|
||||
|
||||
send_error(Req, Code, Message) ->
|
||||
Headers = #{
|
||||
<<"content-type">> => <<"application/json">>,
|
||||
<<"access-control-allow-origin">> => <<"*">>,
|
||||
<<"access-control-expose-headers">> => <<"Content-Range">>
|
||||
},
|
||||
Body = jsx:encode(#{error => Message}),
|
||||
cowboy_req:reply(Code, Headers, Body, Req),
|
||||
{ok, Body, []}.
|
||||
apply_filters(Subs, Filters) ->
|
||||
Plan = maps:get(plan, Filters, undefined),
|
||||
Status = maps:get(status, Filters, undefined),
|
||||
F1 = case Plan of
|
||||
undefined -> Subs;
|
||||
_ -> [S || S <- Subs, S#subscription.plan =:= Plan]
|
||||
end,
|
||||
case Status of
|
||||
undefined -> F1;
|
||||
_ -> [S || S <- F1, S#subscription.status =:= Status]
|
||||
end.
|
||||
|
||||
sort_subscriptions(Subs, #{sort := Sort, order := Order}) ->
|
||||
Field = binary_to_existing_atom(Sort, utf8),
|
||||
lists:sort(
|
||||
fun(A, B) ->
|
||||
ValA = sub_field(A, Field),
|
||||
ValB = sub_field(B, Field),
|
||||
if Order == <<"asc">> -> ValA =< ValB;
|
||||
true -> ValA >= ValB
|
||||
end
|
||||
end, Subs).
|
||||
|
||||
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">>
|
||||
}.
|
||||
@@ -0,0 +1,163 @@
|
||||
%%%-------------------------------------------------------------------
|
||||
%%% @doc Административный обработчик конкретной подписки.
|
||||
%%% GET – получить подписку по ID.
|
||||
%%% PUT – обновить подписку (статус, план, дата окончания).
|
||||
%%% DELETE – удалить подписку.
|
||||
%%% @end
|
||||
%%%-------------------------------------------------------------------
|
||||
-module(admin_handler_subscriptions_by_id).
|
||||
-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">> -> get_subscription(Req);
|
||||
<<"PUT">> -> update_subscription(Req);
|
||||
<<"DELETE">> -> delete_subscription(Req);
|
||||
_ -> 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}
|
||||
}
|
||||
],
|
||||
[
|
||||
#{ % 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()}}
|
||||
},
|
||||
404 => #{description => <<"Subscription not found">>}
|
||||
}
|
||||
},
|
||||
#{ % PUT update
|
||||
path => <<"/v1/admin/subscriptions/:id">>,
|
||||
method => <<"PUT">>,
|
||||
description => <<"Update subscription (admin)">>,
|
||||
tags => [<<"Subscriptions">>],
|
||||
parameters => BaseParams,
|
||||
requestBody => #{
|
||||
required => true,
|
||||
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">>,
|
||||
description => <<"Delete subscription (admin)">>,
|
||||
tags => [<<"Subscriptions">>],
|
||||
parameters => BaseParams,
|
||||
responses => #{
|
||||
200 => #{description => <<"Subscription deleted">>},
|
||||
404 => #{description => <<"Subscription not found">>}
|
||||
}
|
||||
}
|
||||
].
|
||||
|
||||
subscription_schema() ->
|
||||
#{
|
||||
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">>}
|
||||
}
|
||||
}.
|
||||
|
||||
subscription_update_schema() ->
|
||||
#{
|
||||
type => object,
|
||||
properties => #{
|
||||
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">>}
|
||||
}
|
||||
}.
|
||||
|
||||
%%% Internal functions
|
||||
|
||||
get_subscription(Req) ->
|
||||
case handler_utils:auth_admin(Req) of
|
||||
{ok, _AdminId, Req1} ->
|
||||
Id = cowboy_req:binding(id, Req1),
|
||||
case core_subscription:get_by_id(Id) of
|
||||
{ok, Sub} ->
|
||||
handler_utils:send_json(Req1, 200, handler_utils:subscription_to_json(Sub));
|
||||
{error, not_found} ->
|
||||
handler_utils:send_error(Req1, 404, <<"Subscription 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.
|
||||
|
||||
update_subscription(Req) ->
|
||||
case handler_utils:auth_admin(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
|
||||
Data when is_map(Data) ->
|
||||
% Передаём карту напрямую, как ожидает core_subscription
|
||||
case core_subscription:update_subscription(Id, Data) of
|
||||
{ok, Updated} ->
|
||||
handler_utils:send_json(Req2, 200, handler_utils:subscription_to_json(Updated));
|
||||
{error, not_found} ->
|
||||
handler_utils:send_error(Req2, 404, <<"Subscription 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.
|
||||
|
||||
delete_subscription(Req) ->
|
||||
case handler_utils:auth_admin(Req) of
|
||||
{ok, _AdminId, Req1} ->
|
||||
Id = cowboy_req:binding(id, Req1),
|
||||
case core_subscription:delete_subscription(Id) of
|
||||
{ok, _} ->
|
||||
handler_utils:send_json(Req1, 200, #{status => <<"deleted">>});
|
||||
{error, not_found} ->
|
||||
handler_utils:send_error(Req1, 404, <<"Subscription 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.
|
||||
@@ -1,106 +1,206 @@
|
||||
%%%-------------------------------------------------------------------
|
||||
%%% @doc Административный обработчик конкретного тикета.
|
||||
%%% GET – получить тикет по ID.
|
||||
%%% PUT – обновить тикет.
|
||||
%%% DELETE – удалить тикет.
|
||||
%%% @end
|
||||
%%%-------------------------------------------------------------------
|
||||
-module(admin_handler_ticket_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_ticket(Req);
|
||||
<<"PUT">> -> update_ticket(Req);
|
||||
<<"DELETE">> -> delete_ticket(Req);
|
||||
_ -> 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}
|
||||
}
|
||||
],
|
||||
[
|
||||
#{ % 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()}}
|
||||
},
|
||||
404 => #{description => <<"Ticket not found">>}
|
||||
}
|
||||
},
|
||||
#{ % PUT update
|
||||
path => <<"/v1/admin/tickets/:id">>,
|
||||
method => <<"PUT">>,
|
||||
description => <<"Update ticket (admin)">>,
|
||||
tags => [<<"Tickets">>],
|
||||
parameters => BaseParams,
|
||||
requestBody => #{
|
||||
required => true,
|
||||
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">>,
|
||||
description => <<"Delete ticket (admin)">>,
|
||||
tags => [<<"Tickets">>],
|
||||
parameters => BaseParams,
|
||||
responses => #{
|
||||
200 => #{description => <<"Ticket deleted">>},
|
||||
404 => #{description => <<"Ticket not found">>}
|
||||
}
|
||||
}
|
||||
].
|
||||
|
||||
ticket_schema() ->
|
||||
#{
|
||||
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},
|
||||
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 Получить тикет по ID.
|
||||
-spec get_ticket(cowboy_req:req()) -> {ok, binary(), cowboy_req:req()}.
|
||||
get_ticket(Req) ->
|
||||
case auth_admin(Req) of
|
||||
{ok, _AdminId, Req1} ->
|
||||
case handler_utils:auth_admin(Req) of
|
||||
{ok, AdminId, Req1} ->
|
||||
TicketId = cowboy_req:binding(id, Req1),
|
||||
case core_ticket:get_by_id(TicketId) of
|
||||
case logic_ticket:get_ticket(AdminId, TicketId) of
|
||||
{ok, Ticket} ->
|
||||
send_json(Req1, 200, ticket_to_json(Ticket));
|
||||
handler_utils:send_json(Req1, 200, handler_utils:ticket_to_json(Ticket));
|
||||
{error, not_found} ->
|
||||
send_error(Req1, 404, <<"Ticket not found">>)
|
||||
handler_utils:send_error(Req1, 404, <<"Ticket not found">>);
|
||||
{error, access_denied} ->
|
||||
handler_utils:send_error(Req1, 403, <<"Admin access required">>);
|
||||
{error, _} ->
|
||||
handler_utils:send_error(Req1, 500, <<"Internal server error">>)
|
||||
end;
|
||||
{error, Code, Message, Req1} ->
|
||||
send_error(Req1, Code, Message)
|
||||
{error, Code, Msg, Req1} ->
|
||||
handler_utils:send_error(Req1, Code, Msg)
|
||||
end.
|
||||
|
||||
%% @doc Обновить тикет.
|
||||
-spec update_ticket(cowboy_req:req()) -> {ok, binary(), cowboy_req:req()}.
|
||||
update_ticket(Req) ->
|
||||
case auth_admin(Req) of
|
||||
{ok, _AdminId, Req1} ->
|
||||
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
|
||||
UpdatesMap when is_map(UpdatesMap) ->
|
||||
case core_ticket:update_ticket(TicketId, UpdatesMap) of
|
||||
Data when is_map(Data) ->
|
||||
Result = apply_ticket_changes(AdminId, TicketId, Data),
|
||||
case Result of
|
||||
{ok, Ticket} ->
|
||||
send_json(Req2, 200, ticket_to_json(Ticket));
|
||||
handler_utils:send_json(Req2, 200, handler_utils:ticket_to_json(Ticket));
|
||||
{error, not_found} ->
|
||||
send_error(Req2, 404, <<"Ticket not found">>);
|
||||
{error, Reason} ->
|
||||
send_error(Req2, 500, Reason)
|
||||
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;
|
||||
_ ->
|
||||
send_error(Req2, 400, <<"Invalid JSON">>)
|
||||
handler_utils:send_error(Req2, 400, <<"Invalid JSON">>)
|
||||
catch
|
||||
_:_ -> send_error(Req2, 400, <<"Invalid JSON">>)
|
||||
_:_ -> handler_utils:send_error(Req1, 400, <<"Invalid JSON format">>)
|
||||
end;
|
||||
{error, Code, Message, Req1} ->
|
||||
send_error(Req1, Code, Message)
|
||||
{error, Code, Msg, Req1} ->
|
||||
handler_utils:send_error(Req1, Code, Msg)
|
||||
end.
|
||||
|
||||
%% @doc Удалить тикет.
|
||||
-spec delete_ticket(cowboy_req:req()) -> {ok, binary(), cowboy_req:req()}.
|
||||
delete_ticket(Req) ->
|
||||
case auth_admin(Req) of
|
||||
{ok, _AdminId, Req1} ->
|
||||
TicketId = cowboy_req:binding(id, Req1),
|
||||
case core_ticket:delete_ticket(TicketId) of
|
||||
{ok, deleted} ->
|
||||
send_json(Req1, 200, #{status => <<"deleted">>});
|
||||
{error, not_found} ->
|
||||
send_error(Req1, 404, <<"Ticket not found">>)
|
||||
end;
|
||||
{error, Code, Message, Req1} ->
|
||||
send_error(Req1, Code, Message)
|
||||
end.
|
||||
|
||||
auth_admin(Req) ->
|
||||
case handler_auth:authenticate(Req) of
|
||||
case handler_utils:auth_admin(Req) of
|
||||
{ok, AdminId, Req1} ->
|
||||
case admin_utils:is_admin(AdminId) of
|
||||
true -> {ok, AdminId, Req1};
|
||||
false -> {error, 403, <<"Admin access required">>, Req1}
|
||||
TicketId = cowboy_req:binding(id, Req1),
|
||||
case logic_ticket:delete_ticket(AdminId, TicketId) of
|
||||
{ok, _} ->
|
||||
handler_utils:send_json(Req1, 200, #{status => <<"deleted">>});
|
||||
{error, not_found} ->
|
||||
handler_utils:send_error(Req1, 404, <<"Ticket not found">>);
|
||||
{error, access_denied} ->
|
||||
handler_utils:send_error(Req1, 403, <<"Admin access required">>);
|
||||
{error, _} ->
|
||||
handler_utils:send_error(Req1, 500, <<"Internal server error">>)
|
||||
end;
|
||||
{error, Code, Message, Req1} ->
|
||||
{error, Code, Message, Req1}
|
||||
{error, Code, Msg, Req1} ->
|
||||
handler_utils:send_error(Req1, Code, Msg)
|
||||
end.
|
||||
|
||||
ticket_to_json(T) ->
|
||||
#{
|
||||
id => T#ticket.id,
|
||||
error_hash => T#ticket.error_hash,
|
||||
error_message => T#ticket.error_message,
|
||||
stacktrace => T#ticket.stacktrace,
|
||||
context => T#ticket.context,
|
||||
count => T#ticket.count,
|
||||
first_seen => datetime_to_iso8601(T#ticket.first_seen),
|
||||
last_seen => datetime_to_iso8601(T#ticket.last_seen),
|
||||
status => T#ticket.status,
|
||||
assigned_to => T#ticket.assigned_to,
|
||||
resolution_note => T#ticket.resolution_note
|
||||
}.
|
||||
|
||||
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]));
|
||||
datetime_to_iso8601(undefined) -> undefined.
|
||||
|
||||
send_json(Req, Status, Data) ->
|
||||
Body = jsx:encode(Data),
|
||||
cowboy_req:reply(Status, #{<<"content-type">> => <<"application/json">>}, Body, Req),
|
||||
{ok, Body, []}.
|
||||
|
||||
send_error(Req, Status, Message) ->
|
||||
Body = jsx:encode(#{error => Message}),
|
||||
cowboy_req:reply(Status, #{<<"content-type">> => <<"application/json">>}, Body, Req),
|
||||
{ok, Body, []}.
|
||||
%% @private Применить изменения (аналогично admin_handler_tickets).
|
||||
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.
|
||||
@@ -1,46 +1,62 @@
|
||||
%%%-------------------------------------------------------------------
|
||||
%%% @doc Административный обработчик для получения статистики по тикетам.
|
||||
%%% GET – возвращает агрегированную статистику тикетов
|
||||
%%% (количество по статусам: open, in_progress, resolved, closed).
|
||||
%%% @end
|
||||
%%%-------------------------------------------------------------------
|
||||
-module(admin_handler_ticket_stats).
|
||||
-behaviour(cowboy_handler).
|
||||
|
||||
-export([init/2]).
|
||||
-export([trails/0]).
|
||||
|
||||
-include("records.hrl"). % ← добавлено
|
||||
-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_stats(Req);
|
||||
_ -> send_error(Req, 405, <<"Method not allowed">>)
|
||||
_ -> handler_utils:send_error(Req, 405, <<"Method not allowed">>)
|
||||
end.
|
||||
|
||||
%%% Swagger metadata
|
||||
-spec trails() -> [map()].
|
||||
trails() ->
|
||||
[
|
||||
#{
|
||||
path => <<"/v1/admin/tickets/stats">>,
|
||||
method => <<"GET">>,
|
||||
description => <<"Get ticket statistics (admin)">>,
|
||||
tags => [<<"Tickets">>],
|
||||
responses => #{
|
||||
200 => #{
|
||||
description => <<"Ticket statistics">>,
|
||||
content => #{<<"application/json">> => #{schema => #{
|
||||
type => object,
|
||||
properties => #{
|
||||
open => #{type => integer, description => <<"Number of open tickets">>},
|
||||
in_progress => #{type => integer, description => <<"Number of tickets in progress">>},
|
||||
resolved => #{type => integer, description => <<"Number of resolved tickets">>},
|
||||
closed => #{type => integer, description => <<"Number of closed tickets">>},
|
||||
total => #{type => integer, description => <<"Total number of tickets">>}
|
||||
}
|
||||
}}}
|
||||
},
|
||||
403 => #{description => <<"Admin access required">>}
|
||||
}
|
||||
}
|
||||
].
|
||||
|
||||
%%% Internal functions
|
||||
|
||||
%% @doc Получить статистику тикетов. Доступно только администраторам.
|
||||
-spec get_stats(cowboy_req:req()) -> {ok, binary(), cowboy_req:req()}.
|
||||
get_stats(Req) ->
|
||||
case auth_admin(Req) of
|
||||
case handler_utils:auth_admin(Req) of
|
||||
{ok, _AdminId, Req1} ->
|
||||
Stats = core_ticket:stats(),
|
||||
send_json(Req1, 200, Stats);
|
||||
handler_utils:send_json(Req1, 200, Stats);
|
||||
{error, Code, Message, Req1} ->
|
||||
send_error(Req1, Code, Message)
|
||||
end.
|
||||
|
||||
auth_admin(Req) ->
|
||||
case handler_auth:authenticate(Req) of
|
||||
{ok, AdminId, Req1} ->
|
||||
case admin_utils:is_admin(AdminId) of
|
||||
true -> {ok, AdminId, Req1};
|
||||
false -> {error, 403, <<"Admin access required">>, Req1}
|
||||
end;
|
||||
{error, Code, Message, Req1} ->
|
||||
{error, Code, Message, Req1}
|
||||
end.
|
||||
|
||||
send_json(Req, Status, Data) ->
|
||||
Headers = #{
|
||||
<<"content-type">> => <<"application/json">>,
|
||||
<<"access-control-allow-origin">> => <<"*">>,
|
||||
<<"access-control-expose-headers">> => <<"Content-Range">>
|
||||
},
|
||||
Body = jsx:encode(Data),
|
||||
cowboy_req:reply(Status, Headers, Body, Req),
|
||||
{ok, Body, []}.
|
||||
|
||||
send_error(Req, Status, Message) ->
|
||||
Body = jsx:encode(#{error => Message}),
|
||||
cowboy_req:reply(Status, #{<<"content-type">> => <<"application/json">>}, Body, Req),
|
||||
{ok, Body, []}.
|
||||
handler_utils:send_error(Req1, Code, Message)
|
||||
end.
|
||||
@@ -1,188 +1,150 @@
|
||||
%%%-------------------------------------------------------------------
|
||||
%%% @doc Административный обработчик списка тикетов.
|
||||
%%% GET – список с пагинацией, фильтрацией и сортировкой.
|
||||
%%% @end
|
||||
%%%-------------------------------------------------------------------
|
||||
-module(admin_handler_tickets).
|
||||
-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:binding(id, Req) of
|
||||
undefined -> handle_collection(Req);
|
||||
TicketId -> handle_item(TicketId, Req)
|
||||
end.
|
||||
|
||||
handle_collection(Req) ->
|
||||
case cowboy_req:method(Req) of
|
||||
<<"GET">> -> list_tickets(Req);
|
||||
<<"POST">> -> create_ticket(Req);
|
||||
_ -> send_error(Req, 405, <<"Method not allowed">>)
|
||||
_ -> handler_utils:send_error(Req, 405, <<"Method not allowed">>)
|
||||
end.
|
||||
|
||||
handle_item(TicketId, Req) ->
|
||||
case cowboy_req:method(Req) of
|
||||
<<"GET">> -> get_ticket(TicketId, Req);
|
||||
<<"PUT">> -> update_ticket(TicketId, Req);
|
||||
<<"DELETE">> -> delete_ticket(TicketId, Req);
|
||||
_ -> 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">>}
|
||||
],
|
||||
responses => #{
|
||||
200 => #{
|
||||
description => <<"Array of tickets">>,
|
||||
content => #{<<"application/json">> => #{schema => #{
|
||||
type => array,
|
||||
items => ticket_schema()
|
||||
}}}
|
||||
}
|
||||
}
|
||||
}
|
||||
].
|
||||
|
||||
%% ── Список тикетов ──────────────────────────────────────
|
||||
list_tickets(Req) ->
|
||||
case auth_admin(Req) of
|
||||
{ok, _AdminId, Req1} ->
|
||||
Tickets = core_ticket:list_all(),
|
||||
send_json(Req1, 200, [ticket_to_json(T) || T <- Tickets]);
|
||||
{error, Code, Message, Req1} ->
|
||||
send_error(Req1, Code, Message)
|
||||
end.
|
||||
|
||||
%% ── Создание тикета ──────────────────────────────────────
|
||||
create_ticket(Req) ->
|
||||
case auth_admin(Req) of
|
||||
{ok, AdminId, Req1} ->
|
||||
{ok, Body, Req2} = cowboy_req:read_body(Req1),
|
||||
try
|
||||
Decoded = jsx:decode(Body, [return_maps]),
|
||||
case Decoded of
|
||||
#{<<"error_message">> := ErrorMsg} when byte_size(ErrorMsg) > 0 ->
|
||||
TicketData = maps:merge(#{
|
||||
<<"reporter_id">> => AdminId,
|
||||
<<"status">> => <<"open">>
|
||||
}, maps:without([<<"id">>], Decoded)),
|
||||
case core_ticket:create_ticket(TicketData) of
|
||||
{ok, Ticket} ->
|
||||
log_audit(AdminId, <<"create_ticket">>, <<"ticket">>, Ticket#ticket.id, <<"">>),
|
||||
send_json(Req2, 201, ticket_to_json(Ticket));
|
||||
{error, Reason} ->
|
||||
send_error(Req2, 500, Reason)
|
||||
end;
|
||||
_ ->
|
||||
send_error(Req2, 400, <<"Missing or empty 'error_message'">>)
|
||||
end
|
||||
catch
|
||||
_:_ -> send_error(Req2, 400, <<"Invalid JSON">>)
|
||||
end;
|
||||
{error, Code, Message, Req1} ->
|
||||
send_error(Req1, Code, Message)
|
||||
end.
|
||||
|
||||
%% ── Получение тикета по ID ─────────────────────────────
|
||||
get_ticket(TicketId, Req) ->
|
||||
case auth_admin(Req) of
|
||||
{ok, _AdminId, Req1} ->
|
||||
case core_ticket:get_by_id(TicketId) of
|
||||
{ok, Ticket} ->
|
||||
send_json(Req1, 200, ticket_to_json(Ticket));
|
||||
{error, not_found} ->
|
||||
send_error(Req1, 404, <<"Ticket not found">>)
|
||||
end;
|
||||
{error, Code, Message, Req1} ->
|
||||
send_error(Req1, Code, Message)
|
||||
end.
|
||||
|
||||
%% ── Обновление тикета ───────────────────────────────────
|
||||
update_ticket(TicketId, Req) ->
|
||||
case auth_admin(Req) of
|
||||
{ok, AdminId, Req1} ->
|
||||
{ok, Body, Req2} = cowboy_req:read_body(Req1),
|
||||
try
|
||||
Updates = jsx:decode(Body, [return_maps]),
|
||||
case map_size(Updates) > 0 of
|
||||
true ->
|
||||
case core_ticket:update_ticket(TicketId, Updates) of
|
||||
{ok, Ticket} ->
|
||||
Reason = maps:get(<<"reason">>, Updates, <<"">>),
|
||||
log_audit(AdminId, <<"update_ticket">>, <<"ticket">>, TicketId, Reason),
|
||||
send_json(Req2, 200, ticket_to_json(Ticket));
|
||||
{error, not_found} ->
|
||||
send_error(Req2, 404, <<"Ticket not found">>);
|
||||
{error, Reason} ->
|
||||
send_error(Req2, 500, Reason)
|
||||
end;
|
||||
false ->
|
||||
send_error(Req2, 400, <<"Request body is empty">>)
|
||||
end
|
||||
catch
|
||||
_:_ -> send_error(Req2, 400, <<"Invalid JSON">>)
|
||||
end;
|
||||
{error, Code, Message, Req1} ->
|
||||
send_error(Req1, Code, Message)
|
||||
end.
|
||||
|
||||
%% ── Удаление тикета ─────────────────────────────────────
|
||||
delete_ticket(TicketId, Req) ->
|
||||
case auth_admin(Req) of
|
||||
{ok, AdminId, Req1} ->
|
||||
case core_ticket:delete_ticket(TicketId) of
|
||||
{ok, deleted} ->
|
||||
log_audit(AdminId, <<"delete_ticket">>, <<"ticket">>, TicketId, <<"">>),
|
||||
send_json(Req1, 200, #{status => <<"deleted">>});
|
||||
{error, not_found} ->
|
||||
send_error(Req1, 404, <<"Ticket not found">>)
|
||||
end;
|
||||
{error, Code, Message, Req1} ->
|
||||
send_error(Req1, Code, Message)
|
||||
end.
|
||||
|
||||
%% ── Аудит ──────────────────────────────────────────────
|
||||
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, <<"127.0.0.1">>, Reason);
|
||||
_ -> ok
|
||||
end.
|
||||
|
||||
%% ── Аутентификация ──────────────────────────────────────
|
||||
auth_admin(Req) ->
|
||||
case handler_auth:authenticate(Req) of
|
||||
{ok, AdminId, Req1} ->
|
||||
case admin_utils:is_admin(AdminId) of
|
||||
true -> {ok, AdminId, Req1};
|
||||
false -> {error, 403, <<"Admin access required">>, Req1}
|
||||
end;
|
||||
{error, Code, Message, Req1} ->
|
||||
{error, Code, Message, Req1}
|
||||
end.
|
||||
|
||||
%% ── Сериализация ────────────────────────────────────────
|
||||
ticket_to_json(T) ->
|
||||
ticket_schema() ->
|
||||
#{
|
||||
id => T#ticket.id,
|
||||
reporter_id => T#ticket.reporter_id,
|
||||
error_hash => T#ticket.error_hash,
|
||||
error_message => T#ticket.error_message,
|
||||
stacktrace => T#ticket.stacktrace,
|
||||
context => T#ticket.context,
|
||||
count => T#ticket.count,
|
||||
first_seen => datetime_to_iso8601(T#ticket.first_seen),
|
||||
last_seen => datetime_to_iso8601(T#ticket.last_seen),
|
||||
status => T#ticket.status,
|
||||
assigned_to => T#ticket.assigned_to,
|
||||
resolution_note => T#ticket.resolution_note
|
||||
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},
|
||||
resolution_note => #{type => string, nullable => true}
|
||||
}
|
||||
}.
|
||||
|
||||
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]));
|
||||
datetime_to_iso8601(undefined) -> undefined.
|
||||
%%% Internal functions
|
||||
|
||||
%% ── HTTP-ответы ─────────────────────────────────────────
|
||||
send_json(Req, Status, Data) ->
|
||||
Headers = #{
|
||||
<<"content-type">> => <<"application/json">>,
|
||||
<<"access-control-allow-origin">> => <<"*">>,
|
||||
<<"access-control-expose-headers">> => <<"Content-Range">>
|
||||
},
|
||||
Body = jsx:encode(Data),
|
||||
cowboy_req:reply(Status, Headers, Body, Req),
|
||||
{ok, Body, []}.
|
||||
%% @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);
|
||||
Status -> logic_ticket:list_tickets_by_status(AdminId, Status)
|
||||
end,
|
||||
case TicketsResult of
|
||||
Tickets when is_list(Tickets) ->
|
||||
Filtered = apply_ticket_filters(Tickets, Filters),
|
||||
Sorted = sort_tickets(Filtered, Pagination),
|
||||
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),
|
||||
handler_utils:send_json(Req1, 200, Json, ExtraHeaders);
|
||||
{error, access_denied} ->
|
||||
handler_utils:send_error(Req1, 403, <<"Admin access required">>)
|
||||
end;
|
||||
{error, Code, Msg, Req1} ->
|
||||
handler_utils:send_error(Req1, Code, Msg)
|
||||
end.
|
||||
|
||||
send_error(Req, Code, Message) ->
|
||||
Headers = #{
|
||||
<<"content-type">> => <<"application/json">>,
|
||||
<<"access-control-allow-origin">> => <<"*">>,
|
||||
<<"access-control-expose-headers">> => <<"Content-Range">>
|
||||
},
|
||||
Body = jsx:encode(#{error => Message}),
|
||||
cowboy_req:reply(Code, Headers, Body, Req),
|
||||
{ok, Body, []}.
|
||||
%% @private Извлечь фильтры из query string.
|
||||
-spec parse_ticket_filters(cowboy_req:req()) -> map().
|
||||
parse_ticket_filters(Req) ->
|
||||
Qs = cowboy_req:parse_qs(Req),
|
||||
#{
|
||||
status => proplists:get_value(<<"status">>, Qs),
|
||||
assigned_to => proplists:get_value(<<"assigned_to">>, Qs),
|
||||
q => proplists:get_value(<<"q">>, Qs)
|
||||
}.
|
||||
|
||||
%% @private Дополнительная фильтрация (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),
|
||||
F1 = case Assigned of
|
||||
undefined -> Tickets;
|
||||
_ -> [T || T <- Tickets, T#ticket.assigned_to =:= Assigned]
|
||||
end,
|
||||
case Q of
|
||||
undefined -> F1;
|
||||
_ -> [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(
|
||||
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.
|
||||
|
||||
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">>
|
||||
}.
|
||||
@@ -1,152 +1,151 @@
|
||||
-module(admin_handler_user_by_id).
|
||||
-include("records.hrl").
|
||||
-behaviour(cowboy_handler).
|
||||
|
||||
-export([init/2]).
|
||||
-export([trails/0]).
|
||||
|
||||
-include("records.hrl").
|
||||
|
||||
init(Req, _Opts) ->
|
||||
case cowboy_req:method(Req) of
|
||||
<<"GET">> -> get_user(Req);
|
||||
<<"PUT">> -> update_user(Req);
|
||||
<<"DELETE">> -> delete_user(Req);
|
||||
_ -> send_error(Req, 405, <<"Method not allowed">>)
|
||||
_ -> handler_utils:send_error(Req, 405, <<"Method not allowed">>)
|
||||
end.
|
||||
|
||||
trails() ->
|
||||
BaseParams = [#{name => <<"id">>, in => <<"path">>, required => true, schema => #{type => string}}],
|
||||
[
|
||||
#{ % GET
|
||||
path => <<"/v1/admin/users/:id">>,
|
||||
method => <<"GET">>,
|
||||
description => <<"Get user by ID (admin)">>,
|
||||
tags => [<<"Users">>],
|
||||
parameters => BaseParams,
|
||||
responses => #{
|
||||
200 => #{
|
||||
description => <<"User details">>,
|
||||
content => #{<<"application/json">> => #{schema => user_schema()}}
|
||||
}
|
||||
}
|
||||
},
|
||||
#{ % PUT
|
||||
path => <<"/v1/admin/users/:id">>,
|
||||
method => <<"PUT">>,
|
||||
description => <<"Update user (admin)">>,
|
||||
tags => [<<"Users">>],
|
||||
parameters => BaseParams,
|
||||
requestBody => #{
|
||||
required => true,
|
||||
content => #{<<"application/json">> => #{schema => user_update_schema()}}
|
||||
},
|
||||
responses => #{
|
||||
200 => #{description => <<"Updated user">>}
|
||||
}
|
||||
},
|
||||
#{ % DELETE
|
||||
path => <<"/v1/admin/users/:id">>,
|
||||
method => <<"DELETE">>,
|
||||
description => <<"Soft-delete user (admin)">>,
|
||||
tags => [<<"Users">>],
|
||||
parameters => BaseParams,
|
||||
responses => #{
|
||||
200 => #{description => <<"User status set to deleted">>}
|
||||
}
|
||||
}
|
||||
].
|
||||
|
||||
user_schema() ->
|
||||
#{
|
||||
type => object,
|
||||
properties => #{
|
||||
id => #{type => string},
|
||||
email => #{type => string},
|
||||
role => #{type => string},
|
||||
status => #{type => string},
|
||||
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}},
|
||||
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">>}
|
||||
}
|
||||
}.
|
||||
|
||||
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}
|
||||
}
|
||||
}.
|
||||
|
||||
get_user(Req) ->
|
||||
case handler_auth:authenticate(Req) of
|
||||
{ok, AdminId, Req1} ->
|
||||
case admin_utils:is_admin(AdminId) of
|
||||
true ->
|
||||
UserId = cowboy_req:binding(id, Req1),
|
||||
case core_user:get_by_id(UserId) of
|
||||
{ok, User} ->
|
||||
send_json(Req1, 200, user_to_json(User));
|
||||
{error, not_found} ->
|
||||
send_error(Req1, 404, <<"User not found">>)
|
||||
end;
|
||||
false ->
|
||||
send_error(Req1, 403, <<"Admin access required">>)
|
||||
case handler_utils:auth_admin(Req) of
|
||||
{ok, _AdminId, Req1} ->
|
||||
UserId = cowboy_req:binding(id, Req1),
|
||||
case logic_user:get_user_admin(UserId) of
|
||||
{ok, User} ->
|
||||
handler_utils:send_json(Req1, 200, handler_utils:user_to_json(User));
|
||||
{error, not_found} ->
|
||||
handler_utils:send_error(Req1, 404, <<"User not found">>);
|
||||
{error, _} ->
|
||||
handler_utils:send_error(Req1, 500, <<"Internal server error">>)
|
||||
end;
|
||||
{error, Code, Message, Req1} ->
|
||||
send_error(Req1, Code, Message)
|
||||
{error, Code, Msg, Req1} ->
|
||||
handler_utils:send_error(Req1, Code, Msg)
|
||||
end.
|
||||
|
||||
update_user(Req) ->
|
||||
case handler_auth:authenticate(Req) of
|
||||
{ok, AdminId, Req1} ->
|
||||
case admin_utils:is_admin(AdminId) of
|
||||
true ->
|
||||
UserId = cowboy_req:binding(id, Req1),
|
||||
{ok, Body, Req2} = cowboy_req:read_body(Req1),
|
||||
try jsx:decode(Body, [return_maps]) of
|
||||
Updates when map_size(Updates) > 0 ->
|
||||
% Проверка на наличие reason при изменении статуса
|
||||
case maps:find(<<"status">>, Updates) of
|
||||
{ok, NewStatus} when NewStatus =:= <<"blocked">> orelse NewStatus =:= <<"active">> ->
|
||||
case maps:find(<<"reason">>, Updates) of
|
||||
{ok, Reason} when byte_size(Reason) > 0 ->
|
||||
apply_updates(UserId, Updates, AdminId, Reason, Req2);
|
||||
_ ->
|
||||
send_error(Req2, 400, <<"Missing or empty reason">>)
|
||||
end;
|
||||
_ ->
|
||||
apply_updates(UserId, Updates, AdminId, undefined, Req2)
|
||||
end;
|
||||
_ ->
|
||||
send_error(Req2, 400, <<"Request body is empty">>)
|
||||
catch
|
||||
_:_ -> send_error(Req2, 400, <<"Invalid JSON">>)
|
||||
case handler_utils:auth_admin(Req) of
|
||||
{ok, _AdminId, Req1} ->
|
||||
UserId = 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) ->
|
||||
Updates = maps:to_list(UpdatesMap),
|
||||
case logic_user:update_user_admin(UserId, Updates) of
|
||||
{ok, User} ->
|
||||
handler_utils:send_json(Req2, 200, handler_utils:user_to_json(User));
|
||||
{error, not_found} ->
|
||||
handler_utils:send_error(Req2, 404, <<"User not found">>);
|
||||
{error, _} ->
|
||||
handler_utils:send_error(Req2, 500, <<"Internal server error">>)
|
||||
end;
|
||||
false ->
|
||||
send_error(Req1, 403, <<"Admin access required">>)
|
||||
_ ->
|
||||
handler_utils:send_error(Req2, 400, <<"Invalid JSON">>)
|
||||
catch
|
||||
_:_ -> handler_utils:send_error(Req1, 400, <<"Invalid JSON format">>)
|
||||
end;
|
||||
{error, Code, Message, Req1} ->
|
||||
send_error(Req1, Code, Message)
|
||||
{error, Code, Msg, Req1} ->
|
||||
handler_utils:send_error(Req1, Code, Msg)
|
||||
end.
|
||||
|
||||
delete_user(Req) ->
|
||||
case handler_auth:authenticate(Req) of
|
||||
{ok, AdminId, Req1} ->
|
||||
case admin_utils:is_admin(AdminId) of
|
||||
true ->
|
||||
UserId = cowboy_req:binding(id, Req1),
|
||||
case core_user:delete(UserId) of
|
||||
{ok, _} ->
|
||||
send_json(Req1, 200, #{status => <<"deleted">>});
|
||||
{error, not_found} ->
|
||||
send_error(Req1, 404, <<"User not found">>)
|
||||
end;
|
||||
false ->
|
||||
send_error(Req1, 403, <<"Admin access required">>)
|
||||
case handler_utils:auth_admin(Req) of
|
||||
{ok, _AdminId, Req1} ->
|
||||
UserId = cowboy_req:binding(id, Req1),
|
||||
case logic_user:delete_user_admin(UserId) of
|
||||
{ok, _} ->
|
||||
handler_utils:send_json(Req1, 200, #{status => <<"deleted">>});
|
||||
{error, not_found} ->
|
||||
handler_utils:send_error(Req1, 404, <<"User not found">>);
|
||||
{error, _} ->
|
||||
handler_utils:send_error(Req1, 500, <<"Internal server error">>)
|
||||
end;
|
||||
{error, Code, Message, Req1} ->
|
||||
send_error(Req1, Code, Message)
|
||||
end.
|
||||
|
||||
%% ── Вспомогательная функция обновления ────────────────────
|
||||
apply_updates(UserId, Updates, AdminId, Reason, Req) ->
|
||||
Converted = convert_updates(maps:to_list(Updates)),
|
||||
case core_user:update(UserId, Converted) of
|
||||
{ok, User} ->
|
||||
% Логируем, если был указан reason
|
||||
case Reason of
|
||||
undefined -> ok;
|
||||
_ ->
|
||||
case core_admin:get_by_id(AdminId) of
|
||||
{ok, Admin} ->
|
||||
Action = case maps:get(<<"status">>, Updates, undefined) of
|
||||
<<"blocked">> -> <<"block_user">>;
|
||||
<<"active">> -> <<"unblock_user">>;
|
||||
_ -> <<"update_user">>
|
||||
end,
|
||||
core_admin_audit:log(AdminId, Admin#admin.email, Admin#admin.role,
|
||||
Action, <<"user">>, UserId, <<"127.0.0.1">>, Reason);
|
||||
_ -> ok
|
||||
end
|
||||
end,
|
||||
send_json(Req, 200, user_to_json(User));
|
||||
{error, not_found} ->
|
||||
send_error(Req, 404, <<"User not found">>);
|
||||
{error, _} ->
|
||||
send_error(Req, 500, <<"Internal server error">>)
|
||||
end.
|
||||
|
||||
convert_updates(Updates) ->
|
||||
lists:map(fun({<<"status">>, Value}) -> {status, binary_to_existing_atom(Value, utf8)};
|
||||
({<<"role">>, Value}) -> {role, binary_to_existing_atom(Value, utf8)};
|
||||
({<<"reason">>, Value}) -> {reason, Value};
|
||||
(Other) -> Other
|
||||
end, Updates).
|
||||
|
||||
user_to_json(User) ->
|
||||
#{
|
||||
id => User#user.id,
|
||||
email => User#user.email,
|
||||
role => atom_to_binary(User#user.role, utf8),
|
||||
status => atom_to_binary(User#user.status, utf8),
|
||||
reason => User#user.reason,
|
||||
created_at => datetime_to_iso8601(User#user.created_at),
|
||||
updated_at => datetime_to_iso8601(User#user.updated_at)
|
||||
}.
|
||||
|
||||
datetime_to_iso8601({{Y,M,D},{H,Min,S}}) ->
|
||||
iolist_to_binary(io_lib:format("~4..0B-~2..0B-~2..0BT~2..0B:~2..0B:~2..0BZ", [Y,M,D,H,Min,S]));
|
||||
datetime_to_iso8601(_) -> null.
|
||||
|
||||
send_json(Req, Status, Data) ->
|
||||
Headers = #{
|
||||
<<"content-type">> => <<"application/json">>,
|
||||
<<"access-control-allow-origin">> => <<"*">>,
|
||||
<<"access-control-expose-headers">> => <<"Content-Range">>
|
||||
},
|
||||
Body = jsx:encode(Data),
|
||||
cowboy_req:reply(Status, Headers, Body, Req),
|
||||
{ok, Body, []}.
|
||||
|
||||
send_error(Req, Code, Message) ->
|
||||
Headers = #{
|
||||
<<"content-type">> => <<"application/json">>,
|
||||
<<"access-control-allow-origin">> => <<"*">>,
|
||||
<<"access-control-expose-headers">> => <<"Content-Range">>
|
||||
},
|
||||
Body = jsx:encode(#{error => Message}),
|
||||
cowboy_req:reply(Code, Headers, Body, Req),
|
||||
{ok, Body, []}.
|
||||
{error, Code, Msg, Req1} ->
|
||||
handler_utils:send_error(Req1, Code, Msg)
|
||||
end.
|
||||
@@ -1,61 +1,90 @@
|
||||
-module(admin_handler_users).
|
||||
-include("records.hrl").
|
||||
-behaviour(cowboy_handler).
|
||||
|
||||
-export([init/2]).
|
||||
-export([trails/0]).
|
||||
|
||||
-include("records.hrl").
|
||||
|
||||
init(Req, _Opts) ->
|
||||
case cowboy_req:method(Req) of
|
||||
<<"GET">> -> list_users(Req);
|
||||
_ -> send_error(Req, 405, <<"Method not allowed">>)
|
||||
_ -> handler_utils:send_error(Req, 405, <<"Method not allowed">>)
|
||||
end.
|
||||
|
||||
list_users(Req) ->
|
||||
case handler_auth:authenticate(Req) of
|
||||
{ok, AdminId, Req1} ->
|
||||
case admin_utils:is_admin(AdminId) of
|
||||
true ->
|
||||
{ok, Users} = core_user:list_users(),
|
||||
send_json(Req1, 200, [user_to_map(U) || U <- Users]);
|
||||
false ->
|
||||
send_error(Req1, 403, <<"Admin access required">>)
|
||||
end;
|
||||
{error, Code, Message, Req1} ->
|
||||
send_error(Req1, Code, Message)
|
||||
end.
|
||||
trails() ->
|
||||
[
|
||||
#{
|
||||
path => <<"/v1/admin/users">>,
|
||||
method => <<"GET">>,
|
||||
description => <<"List all users (admin)">>,
|
||||
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 => <<"offset">>, in => <<"query">>, schema => #{type => integer}}
|
||||
],
|
||||
responses => #{
|
||||
200 => #{
|
||||
description => <<"Array of users">>,
|
||||
content => #{<<"application/json">> => #{schema => #{
|
||||
type => array,
|
||||
items => user_schema()
|
||||
}}}
|
||||
}
|
||||
}
|
||||
}
|
||||
].
|
||||
|
||||
user_to_map(User) when is_map(User) ->
|
||||
user_schema() ->
|
||||
#{
|
||||
id => maps:get(id, User),
|
||||
email => maps:get(email, User),
|
||||
role => maps:get(role, User, <<"user">>),
|
||||
status => maps:get(status, User, <<"active">>),
|
||||
created_at => datetime_to_iso8601(maps:get(created_at, User)),
|
||||
updated_at => datetime_to_iso8601(maps:get(updated_at, User))
|
||||
};
|
||||
user_to_map(User) ->
|
||||
#{
|
||||
id => User#user.id,
|
||||
email => User#user.email,
|
||||
role => atom_to_binary(User#user.role, utf8),
|
||||
status => atom_to_binary(User#user.status, utf8),
|
||||
created_at => datetime_to_iso8601(User#user.created_at),
|
||||
updated_at => datetime_to_iso8601(User#user.updated_at)
|
||||
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},
|
||||
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">>}
|
||||
}
|
||||
}.
|
||||
|
||||
datetime_to_iso8601({{Y,M,D},{H,Min,S}}) ->
|
||||
iolist_to_binary(io_lib:format("~4..0B-~2..0B-~2..0BT~2..0B:~2..0B:~2..0BZ", [Y,M,D,H,Min,S]));
|
||||
datetime_to_iso8601(_) -> null.
|
||||
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),
|
||||
Json = [handler_utils:user_to_json(U) || U <- Users],
|
||||
ExtraHeaders = pagination_headers(Pagination, Total),
|
||||
handler_utils:send_json(Req1, 200, Json, ExtraHeaders);
|
||||
{error, Code, Msg, Req1} ->
|
||||
handler_utils:send_error(Req1, Code, Msg)
|
||||
end.
|
||||
|
||||
send_json(Req, Status, Data) ->
|
||||
Headers = #{
|
||||
<<"content-type">> => <<"application/json">>,
|
||||
<<"access-control-allow-origin">> => <<"*">>,
|
||||
<<"access-control-expose-headers">> => <<"Content-Range">>
|
||||
},
|
||||
Body = jsx:encode(Data),
|
||||
cowboy_req:reply(Status, Headers, Body, Req),
|
||||
{ok, Body, []}.
|
||||
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)
|
||||
}.
|
||||
|
||||
send_error(Req, Status, Message) ->
|
||||
Body = jsx:encode(#{error => Message}),
|
||||
cowboy_req:reply(Status, #{<<"content-type">> => <<"application/json">>}, Body, Req),
|
||||
{ok, Body, []}.
|
||||
pagination_headers(#{limit := Limit, offset := Offset}, Total) ->
|
||||
RangeEnd = min(Offset + Limit - 1, Total - 1),
|
||||
#{
|
||||
<<"content-range">> => iolist_to_binary(io_lib:format("items ~B-~B/~B", [Offset, RangeEnd, Total])),
|
||||
<<"x-total-count">> => integer_to_binary(Total),
|
||||
<<"access-control-expose-headers">> => <<"Content-Range, X-Total-Count">>
|
||||
}.
|
||||
@@ -1,5 +1,12 @@
|
||||
%%%-------------------------------------------------------------------
|
||||
%%% @doc Административный WebSocket-обработчик.
|
||||
%%% Устанавливает WebSocket-соединение после проверки JWT-токена
|
||||
%%% и подписывает администратора на каналы уведомлений.
|
||||
%%% @end
|
||||
%%%-------------------------------------------------------------------
|
||||
-module(admin_ws_handler).
|
||||
-behaviour(cowboy_websocket).
|
||||
|
||||
-export([init/2]).
|
||||
-export([websocket_init/1]).
|
||||
-export([websocket_handle/2]).
|
||||
@@ -10,6 +17,11 @@
|
||||
admin_id :: binary() | undefined
|
||||
}).
|
||||
|
||||
%%% cowboy_websocket callback
|
||||
|
||||
%% @doc Инициализирует соединение, проверяет токен из query-строки.
|
||||
-spec init(cowboy_req:req(), any()) ->
|
||||
{ok, cowboy_req:req(), #state{}} | {cowboy_websocket, cowboy_req:req(), #state{}}.
|
||||
init(Req, _Opts) ->
|
||||
Qs = cowboy_req:parse_qs(Req),
|
||||
case proplists:get_value(<<"token">>, Qs) of
|
||||
@@ -42,11 +54,15 @@ init(Req, _Opts) ->
|
||||
end
|
||||
end.
|
||||
|
||||
%% @doc Вызывается после установки WebSocket-соединения.
|
||||
-spec websocket_init(#state{}) -> {ok, #state{}}.
|
||||
websocket_init(State) ->
|
||||
io:format("[ADMIN_WS] WebSocket initialized for admin ~s~n", [State#state.admin_id]),
|
||||
pg:join(eventhub_admin_ws, self()),
|
||||
{ok, State}.
|
||||
|
||||
%% @doc Обрабатывает входящие текстовые сообщения (subscribe/unsubscribe/ping).
|
||||
-spec websocket_handle(term(), #state{}) -> {ok, #state{}} | {reply, {text, binary()}, #state{}}.
|
||||
websocket_handle({text, Msg}, State) ->
|
||||
io:format("[ADMIN_WS] Received: ~s~n", [Msg]),
|
||||
try jsx:decode(Msg, [return_maps]) of
|
||||
@@ -63,22 +79,25 @@ websocket_handle({text, Msg}, State) ->
|
||||
_ ->
|
||||
{ok, State}
|
||||
catch
|
||||
_:_ ->
|
||||
{ok, State}
|
||||
_:_ -> {ok, State}
|
||||
end;
|
||||
websocket_handle(_Frame, State) ->
|
||||
{ok, State}.
|
||||
|
||||
%% @doc Отправляет административное уведомление через WebSocket.
|
||||
-spec websocket_info(term(), #state{}) -> {reply, {text, binary()}, #state{}} | {ok, #state{}}.
|
||||
websocket_info({admin_notification, Type, Data}, State) ->
|
||||
Msg = jsx:encode(#{
|
||||
type => Type,
|
||||
data => Data,
|
||||
type => Type,
|
||||
data => Data,
|
||||
timestamp => os:system_time(seconds)
|
||||
}),
|
||||
{reply, {text, Msg}, State};
|
||||
websocket_info(_Info, State) ->
|
||||
{ok, State}.
|
||||
|
||||
%% @private Вызывается при закрытии соединения.
|
||||
-spec terminate(term(), cowboy_req:req(), #state{}) -> ok.
|
||||
terminate(_Reason, _Req, _State) ->
|
||||
pg:leave(eventhub_admin_ws, self()),
|
||||
ok.
|
||||
Reference in New Issue
Block a user