Рефакторинг обработчиков. Часть 1 #21
This commit is contained in:
@@ -1,14 +1,15 @@
|
||||
-module(core_admin).
|
||||
-include("records.hrl").
|
||||
-export([create/3, get_by_email/1, get_by_id/1, list_all/0,
|
||||
update_role/2, block/1, unblock/1, generate_id/0, update_last_login/1]).
|
||||
update_role/2, block/1, unblock/1, update_last_login/1]).
|
||||
-export([update/2]).
|
||||
|
||||
create(Email, Password, Role) ->
|
||||
case get_by_email(Email) of
|
||||
{ok, _} ->
|
||||
{error, email_exists};
|
||||
{error, not_found} ->
|
||||
Id = generate_id(),
|
||||
Id = infra_utils:generate_id(16),
|
||||
{ok, Hash} = argon2:hash(Password),
|
||||
Now = calendar:universal_time(),
|
||||
Admin = #admin{
|
||||
@@ -24,6 +25,22 @@ create(Email, Password, Role) ->
|
||||
{ok, Admin}
|
||||
end.
|
||||
|
||||
%% Обновление администратора (любые поля)
|
||||
update(AdminId, Updates) ->
|
||||
F = fun() ->
|
||||
case mnesia:read(admin, AdminId) of
|
||||
[] -> {error, not_found};
|
||||
[Admin] ->
|
||||
UpdatedAdmin = apply_updates(Admin, Updates),
|
||||
mnesia:write(UpdatedAdmin),
|
||||
{ok, UpdatedAdmin}
|
||||
end
|
||||
end,
|
||||
case mnesia:transaction(F) of
|
||||
{atomic, Result} -> Result;
|
||||
{aborted, Reason} -> {error, Reason}
|
||||
end.
|
||||
|
||||
get_by_email(Email) ->
|
||||
Match = #admin{email = Email, _ = '_'},
|
||||
case mnesia:dirty_match_object(Match) of
|
||||
@@ -73,5 +90,23 @@ update_status(Id, Status) ->
|
||||
Error -> Error
|
||||
end.
|
||||
|
||||
generate_id() ->
|
||||
base64:encode(crypto:strong_rand_bytes(16), #{mode => urlsafe, padding => false}).
|
||||
%%%===================================================================
|
||||
%%% ВНУТРЕННИЕ ФУНКЦИИ
|
||||
%%%===================================================================
|
||||
|
||||
apply_updates(Admin, []) -> Admin;
|
||||
apply_updates(Admin, [{Field, Value} | Rest]) ->
|
||||
NewAdmin = case Field of
|
||||
email -> Admin#admin{email = Value};
|
||||
password_hash -> Admin#admin{password_hash = Value};
|
||||
role -> Admin#admin{role = Value};
|
||||
status -> Admin#admin{status = Value};
|
||||
nickname -> Admin#admin{nickname = Value};
|
||||
avatar_url -> Admin#admin{avatar_url = Value};
|
||||
timezone -> Admin#admin{timezone = Value};
|
||||
language -> Admin#admin{language = Value};
|
||||
phone -> Admin#admin{phone = Value};
|
||||
preferences -> Admin#admin{preferences = Value};
|
||||
_ -> Admin
|
||||
end,
|
||||
apply_updates(NewAdmin#admin{updated_at = calendar:universal_time()}, Rest).
|
||||
@@ -6,7 +6,7 @@
|
||||
log(AdminId, Email, Role, Action, EntityType, EntityId, Ip) ->
|
||||
log(AdminId, Email, Role, Action, EntityType, EntityId, Ip, undefined).
|
||||
log(AdminId, Email, Role, Action, EntityType, EntityId, Ip, Reason) ->
|
||||
Id = base64:encode(crypto:strong_rand_bytes(9)),
|
||||
Id = infra_utils:generate_id(9),
|
||||
Entry = #admin_audit{
|
||||
id = Id,
|
||||
admin_id = AdminId,
|
||||
|
||||
@@ -10,7 +10,7 @@ list_banned_words() ->
|
||||
mnesia:dirty_match_object(#banned_word{_ = '_'}).
|
||||
|
||||
add_banned_word(Word, AddedBy) ->
|
||||
Id = generate_id(),
|
||||
Id = infra_utils:generate_id(9),
|
||||
Now = calendar:universal_time(),
|
||||
BW = #banned_word{id = Id, word = Word, added_by = AddedBy, added_at = Now},
|
||||
case mnesia:transaction(fun() ->
|
||||
@@ -49,6 +49,3 @@ update_banned_word(OldWord, NewWord) ->
|
||||
{atomic, {ok, UpdatedRec}} -> {ok, UpdatedRec};
|
||||
{aborted, not_found} -> {error, not_found}
|
||||
end.
|
||||
|
||||
generate_id() ->
|
||||
base64:encode(crypto:strong_rand_bytes(9)).
|
||||
@@ -3,12 +3,11 @@
|
||||
|
||||
-export([create/2, get_by_id/1, get_by_event_and_user/2, list_by_event/1, list_by_user/1]).
|
||||
-export([update_status/2, delete/1]).
|
||||
-export([generate_id/0]).
|
||||
-export([count_bookings/0]).
|
||||
|
||||
%% Создание бронирования
|
||||
create(EventId, UserId) ->
|
||||
Id = generate_id(),
|
||||
Id = infra_utils:generate_id(16),
|
||||
Booking = #booking{
|
||||
id = Id,
|
||||
event_id = EventId,
|
||||
@@ -99,7 +98,3 @@ delete(Id) ->
|
||||
end.
|
||||
|
||||
count_bookings() -> mnesia:table_info(booking, size).
|
||||
|
||||
%% Внутренние функции
|
||||
generate_id() ->
|
||||
base64:encode(crypto:strong_rand_bytes(16), #{mode => urlsafe, padding => false}).
|
||||
@@ -1,13 +1,12 @@
|
||||
-module(core_calendar).
|
||||
-include("records.hrl").
|
||||
-export([create/4, create/5, get_by_id/1, list_by_owner/1, update/2, delete/1]).
|
||||
-export([generate_id/0]).
|
||||
-export([count_calendars/0]).
|
||||
-export([freeze/2, unfreeze/2]). % ← новые функции
|
||||
|
||||
%% Создание календаря
|
||||
create(OwnerId, Title, Description, Confirmation) ->
|
||||
Id = generate_id(),
|
||||
Id = infra_utils:generate_id(16),
|
||||
Calendar = #calendar{
|
||||
id = Id,
|
||||
owner_id = OwnerId,
|
||||
@@ -30,7 +29,7 @@ create(OwnerId, Title, Description, Confirmation) ->
|
||||
|
||||
%% Создание календаря с типом и политикой
|
||||
create(OwnerId, Title, Description, Confirmation, Type) ->
|
||||
Id = generate_id(),
|
||||
Id = infra_utils:generate_id(16),
|
||||
Calendar = #calendar{
|
||||
id = Id,
|
||||
owner_id = OwnerId,
|
||||
@@ -94,10 +93,6 @@ freeze(Id, Reason) ->
|
||||
unfreeze(Id, Reason) ->
|
||||
update(Id, [{status, active}, {reason, Reason}]).
|
||||
|
||||
%% Внутренние функции
|
||||
generate_id() ->
|
||||
base64:encode(crypto:strong_rand_bytes(16), #{mode => urlsafe, padding => false}).
|
||||
|
||||
apply_updates(Calendar, Updates) ->
|
||||
Updated = lists:foldl(fun({Field, Value}, C) ->
|
||||
set_field(Field, Value, C)
|
||||
|
||||
@@ -3,14 +3,13 @@
|
||||
|
||||
-export([create/4, create_recurring/5, get_by_id/1, list_by_calendar/1,
|
||||
update/2, delete/1, materialize_occurrence/3]).
|
||||
-export([generate_id/0]).
|
||||
-export([count_events/0, count_events_by_date/2]).
|
||||
-export([freeze/2, unfreeze/2]).
|
||||
-export([list_all/0]).
|
||||
|
||||
%% Создание одиночного события
|
||||
create(CalendarId, Title, StartTime, Duration) ->
|
||||
Id = generate_id(),
|
||||
Id = infra_utils:generate_id(16),
|
||||
Event = #event{
|
||||
id = Id,
|
||||
calendar_id = CalendarId,
|
||||
@@ -46,7 +45,7 @@ create(CalendarId, Title, StartTime, Duration) ->
|
||||
|
||||
%% Создание повторяющегося события (мастер-запись)
|
||||
create_recurring(CalendarId, Title, StartTime, Duration, RRule) ->
|
||||
Id = generate_id(),
|
||||
Id = infra_utils:generate_id(16),
|
||||
Event = #event{
|
||||
id = Id,
|
||||
calendar_id = CalendarId,
|
||||
@@ -94,7 +93,7 @@ materialize_occurrence(MasterId, OccurrenceStart, SpecialistId) ->
|
||||
case Existing of
|
||||
[] ->
|
||||
% Создаём новый экземпляр
|
||||
InstanceId = generate_id(),
|
||||
InstanceId = infra_utils:generate_id(16),
|
||||
Instance = #event{
|
||||
id = InstanceId,
|
||||
calendar_id = Master#event.calendar_id,
|
||||
@@ -193,10 +192,6 @@ count_events_by_date(From, To) ->
|
||||
|
||||
date_part({{Y,M,D}, _}) -> {Y,M,D}.
|
||||
|
||||
%% Внутренние функции
|
||||
generate_id() ->
|
||||
base64:encode(crypto:strong_rand_bytes(16), #{mode => urlsafe, padding => false}).
|
||||
|
||||
apply_updates(Event, Updates) ->
|
||||
Updated = lists:foldl(fun({Field, Value}, E) ->
|
||||
set_field(Field, Value, E)
|
||||
|
||||
@@ -6,10 +6,11 @@
|
||||
-export([generate_id/0]).
|
||||
-export([count_reports_by_status/1, count_reports_by_admin/2]).
|
||||
-export([count_reports_resolved_by_admin/2, avg_resolution_time/1]).
|
||||
-export([delete/1, update/2]). % <-- добавлено
|
||||
|
||||
%% Создание жалобы
|
||||
create(ReporterId, TargetType, TargetId, Reason) ->
|
||||
Id = generate_id(),
|
||||
Id = infra_utils:generate_id(16),
|
||||
Report = #report{
|
||||
id = Id,
|
||||
reporter_id = ReporterId,
|
||||
@@ -109,6 +110,41 @@ avg_resolution_time(Status) ->
|
||||
TotalSeconds / length(Resolved) / 3600.0
|
||||
end.
|
||||
|
||||
%% Внутренние функции
|
||||
%% Мягкое удаление жалобы (просто физически удаляем запись)
|
||||
-spec delete(binary()) -> {ok, deleted} | {error, not_found}.
|
||||
delete(Id) ->
|
||||
case get_by_id(Id) of
|
||||
{ok, _} ->
|
||||
mnesia:dirty_delete(report, Id),
|
||||
{ok, deleted};
|
||||
Error -> Error
|
||||
end.
|
||||
|
||||
%% Обновление произвольных полей жалобы (для административных целей)
|
||||
-spec update(binary(), proplists:proplist()) -> {ok, #report{}} | {error, not_found}.
|
||||
update(Id, Updates) ->
|
||||
case get_by_id(Id) of
|
||||
{ok, Report} ->
|
||||
UpdatedReport = apply_updates(Report, Updates),
|
||||
mnesia:dirty_write(UpdatedReport),
|
||||
{ok, UpdatedReport};
|
||||
Error -> Error
|
||||
end.
|
||||
|
||||
%%%===================================================================
|
||||
%%% Внутренние функции
|
||||
%%%===================================================================
|
||||
|
||||
generate_id() ->
|
||||
base64:encode(crypto:strong_rand_bytes(16), #{mode => urlsafe, padding => false}).
|
||||
|
||||
apply_updates(Report, []) -> Report;
|
||||
apply_updates(Report, [{Field, Value} | Rest]) ->
|
||||
NewReport = case Field of
|
||||
status -> Report#report{status = Value};
|
||||
resolved_at -> Report#report{resolved_at = Value};
|
||||
resolved_by -> Report#report{resolved_by = Value};
|
||||
reason -> Report#report{reason = Value};
|
||||
_ -> Report
|
||||
end,
|
||||
apply_updates(NewReport, Rest).
|
||||
@@ -4,12 +4,11 @@
|
||||
-export([create/5, get_by_id/1, list_by_target/2, list_by_user/1,
|
||||
update/2, delete/1, hide/2, unhide/2]).
|
||||
-export([get_average_rating/2, has_user_reviewed/3]).
|
||||
-export([generate_id/0]).
|
||||
-export([count_reviews/0, list_all/0]).
|
||||
|
||||
%% Создание отзыва
|
||||
create(UserId, TargetType, TargetId, Rating, Comment) ->
|
||||
Id = generate_id(),
|
||||
Id = infra_utils:generate_id(16),
|
||||
Review = #review{
|
||||
id = Id,
|
||||
user_id = UserId,
|
||||
@@ -117,10 +116,6 @@ count_reviews() -> mnesia:table_info(review, size).
|
||||
|
||||
list_all() -> mnesia:dirty_match_object(#review{_ = '_'}).
|
||||
|
||||
%% Внутренние функции
|
||||
generate_id() ->
|
||||
base64:encode(crypto:strong_rand_bytes(16), #{mode => urlsafe, padding => false}).
|
||||
|
||||
apply_updates(Review, Updates) ->
|
||||
Updated = lists:foldl(fun({Field, Value}, R) ->
|
||||
set_field(Field, Value, R)
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
|
||||
-export([create/3, get_by_id/1, get_active_by_user/1, list_by_user/1, list_all/0]).
|
||||
-export([update_status/2, check_expired/0]).
|
||||
-export([generate_id/0]).
|
||||
% --------------- новые обёртки для админки ------------------
|
||||
-export([list_subscriptions/0,
|
||||
create_subscription/1,
|
||||
@@ -16,7 +15,7 @@
|
||||
|
||||
%% Создание подписки
|
||||
create(UserId, Plan, TrialUsed) ->
|
||||
Id = generate_id(),
|
||||
Id = infra_utils:generate_id(16),
|
||||
Now = calendar:universal_time(),
|
||||
|
||||
{StartDate, EndDate} = case TrialUsed of
|
||||
@@ -129,10 +128,6 @@ downgrade_user_calendars(UserId) ->
|
||||
core_calendar:update(Cal#calendar.id, [{type, personal}])
|
||||
end, Calendars).
|
||||
|
||||
%% Внутренние функции
|
||||
generate_id() ->
|
||||
base64:encode(crypto:strong_rand_bytes(16), #{mode => urlsafe, padding => false}).
|
||||
|
||||
plan_to_months(monthly) -> 1;
|
||||
plan_to_months(quarterly) -> 3;
|
||||
plan_to_months(biannual) -> 6;
|
||||
|
||||
@@ -48,7 +48,7 @@ stats() ->
|
||||
|
||||
%% ── новые функции ──────────────────────────────────────
|
||||
create_ticket(Data) ->
|
||||
Id = base64:encode(crypto:strong_rand_bytes(9), #{mode => urlsafe, padding => false}),
|
||||
Id = infra_utils:generate_id(9),
|
||||
Now = calendar:universal_time(),
|
||||
Ticket = #ticket{
|
||||
id = Id,
|
||||
|
||||
@@ -3,10 +3,9 @@
|
||||
|
||||
-export([create/2, get_by_id/1, get_by_email/1, update/2, update_status/3, delete/1, update_last_login/1]).
|
||||
-export([email_exists/1]).
|
||||
-export([generate_id/0]).
|
||||
-export([list_users/0]).
|
||||
-export([block/2, unblock/2]).
|
||||
-export([count_users/0, count_users_by_date/2]).
|
||||
-export([count_users/0, count_users_by_date/2, list_all/0]).
|
||||
-export([create_bot/2, delete_bot/1]).
|
||||
|
||||
%% Создание пользователя
|
||||
@@ -16,7 +15,7 @@ create(Email, Password) ->
|
||||
true ->
|
||||
{error, email_exists};
|
||||
false ->
|
||||
Id = generate_id(),
|
||||
Id = infra_utils:generate_id(16),
|
||||
{ok, PasswordHash} = logic_auth:hash_password(Password),
|
||||
|
||||
User = #user{
|
||||
@@ -150,6 +149,10 @@ unblock(Id, Reason) ->
|
||||
count_users() ->
|
||||
mnesia:table_info(user, size).
|
||||
|
||||
%% Административный список (все пользователи, без фильтрации)
|
||||
list_all() ->
|
||||
mnesia:dirty_match_object(#user{_ = '_'}).
|
||||
|
||||
count_users_by_date(From, To) ->
|
||||
All = mnesia:dirty_match_object(#user{_ = '_'}),
|
||||
Filtered = lists:filter(fun(U) ->
|
||||
@@ -166,10 +169,6 @@ count_users_by_date(From, To) ->
|
||||
|
||||
date_part({{Y,M,D}, _}) -> {Y,M,D}.
|
||||
|
||||
%% Внутренние функции
|
||||
generate_id() ->
|
||||
base64:encode(crypto:strong_rand_bytes(16), #{mode => urlsafe, padding => false}).
|
||||
|
||||
apply_updates(User, Updates) ->
|
||||
Updated = lists:foldl(fun({Field, Value}, U) ->
|
||||
set_field(Field, Value, U)
|
||||
@@ -196,7 +195,7 @@ create_bot(Email, Password) ->
|
||||
case mnesia:dirty_index_read(user, Email, email) of
|
||||
[] ->
|
||||
{ok, PasswordHash} = logic_auth:hash_password(Password),
|
||||
Id = generate_id(),
|
||||
Id = infra_utils:generate_id(16),
|
||||
User = #user{
|
||||
id = Id,
|
||||
email = Email,
|
||||
|
||||
@@ -87,7 +87,7 @@ start_http() ->
|
||||
{"/v1/tickets", handler_tickets, []},
|
||||
{"/v1/tickets/:id", handler_ticket_by_id, []},
|
||||
{"/v1/subscription", handler_subscription, []}
|
||||
]}
|
||||
]} %% 23
|
||||
]),
|
||||
Middlewares = [cowboy_router, cowboy_handler],
|
||||
Env = #{dispatch => Dispatch},
|
||||
@@ -126,7 +126,7 @@ start_admin_http() ->
|
||||
{"/v1/admin/tickets", admin_handler_tickets, []},
|
||||
% ================== ПОДПИСКИ ==================
|
||||
{"/v1/admin/subscriptions", admin_handler_subscriptions, []},
|
||||
{"/v1/admin/subscriptions/:id", admin_handler_subscriptions, []},
|
||||
{"/v1/admin/subscriptions/:id", admin_handler_subscriptions_by_id, []},
|
||||
% ================== МОДЕРАЦИЯ (общий маршрут) ==================
|
||||
{"/v1/admin/:target_type/:id", admin_handler_moderation, []},
|
||||
% ================== Управление ролями (только для superadmin) ==================
|
||||
|
||||
@@ -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.
|
||||
|
||||
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) ->
|
||||
trails() ->
|
||||
[
|
||||
#{
|
||||
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)
|
||||
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()
|
||||
}}}
|
||||
}
|
||||
}
|
||||
}
|
||||
].
|
||||
|
||||
admin_schema() ->
|
||||
#{
|
||||
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).
|
||||
|
||||
audit_to_json(E) ->
|
||||
%%% Swagger metadata
|
||||
-spec trails() -> [map()].
|
||||
trails() ->
|
||||
[
|
||||
#{
|
||||
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
|
||||
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_schema() ->
|
||||
#{
|
||||
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.
|
||||
|
||||
%% ================== 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) ->
|
||||
-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 => [
|
||||
#{
|
||||
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)
|
||||
name => <<"word">>,
|
||||
in => <<"path">>,
|
||||
description => <<"The word to remove">>,
|
||||
required => true,
|
||||
schema => #{type => string}
|
||||
}
|
||||
],
|
||||
responses => #{
|
||||
200 => #{description => <<"Word removed">>},
|
||||
404 => #{description => <<"Word not found">>}
|
||||
}
|
||||
}
|
||||
].
|
||||
|
||||
banned_word_schema() ->
|
||||
#{
|
||||
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,15 +15,14 @@ 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">>,
|
||||
@@ -34,52 +33,38 @@ trails() ->
|
||||
}
|
||||
],
|
||||
[
|
||||
%% GET
|
||||
#{
|
||||
path => Path,
|
||||
#{ % GET
|
||||
path => <<"/v1/admin/events/:id">>,
|
||||
method => <<"GET">>,
|
||||
handler => ?MODULE,
|
||||
tags => [<<"Events: id">>],
|
||||
description => <<"Get event by ID (admin)">>,
|
||||
tags => [<<"Events">>],
|
||||
parameters => BaseParams,
|
||||
responses => #{
|
||||
200 => #{
|
||||
description => <<"Event details">>,
|
||||
content => #{
|
||||
<<"application/json">> => #{
|
||||
schema => event_schema()
|
||||
}
|
||||
}
|
||||
content => #{<<"application/json">> => #{schema => event_schema()}}
|
||||
}
|
||||
}
|
||||
},
|
||||
%% PUT
|
||||
#{
|
||||
path => Path,
|
||||
#{ % PUT
|
||||
path => <<"/v1/admin/events/:id">>,
|
||||
method => <<"PUT">>,
|
||||
handler => ?MODULE,
|
||||
tags => [<<"Events: id">>],
|
||||
description => <<"Update event (admin)">>,
|
||||
tags => [<<"Events">>],
|
||||
parameters => BaseParams,
|
||||
requestBody => #{
|
||||
required => true,
|
||||
content => #{
|
||||
<<"application/json">> => #{
|
||||
schema => event_update_schema()
|
||||
}
|
||||
}
|
||||
content => #{<<"application/json">> => #{schema => event_update_schema()}}
|
||||
},
|
||||
responses => #{
|
||||
200 => #{description => <<"Updated event">>}
|
||||
}
|
||||
},
|
||||
%% DELETE
|
||||
#{
|
||||
path => Path,
|
||||
#{ % DELETE
|
||||
path => <<"/v1/admin/events/:id">>,
|
||||
method => <<"DELETE">>,
|
||||
handler => ?MODULE,
|
||||
tags => [<<"Events: id">>],
|
||||
description => <<"Soft-delete event (admin)">>,
|
||||
tags => [<<"Events">>],
|
||||
parameters => BaseParams,
|
||||
responses => #{
|
||||
200 => #{description => <<"Event status set to deleted">>}
|
||||
@@ -107,8 +92,11 @@ event_schema() ->
|
||||
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">>}
|
||||
}
|
||||
@@ -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,57 +157,37 @@ 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).
|
||||
|
||||
@@ -229,14 +195,12 @@ 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
|
||||
case handler_utils:parse_datetime(Val) of
|
||||
{ok, Dt} -> {start_time, Dt};
|
||||
_ -> {start_time, Val}
|
||||
end;
|
||||
convert_field({<<"duration">>, Val}) -> {duration, Val};
|
||||
convert_field({<<"recurrence">>, Val}) ->
|
||||
RuleJson = jsx:encode(Val),
|
||||
{recurrence_rule, RuleJson};
|
||||
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{
|
||||
@@ -256,86 +220,3 @@ convert_field({<<"status">>, Val}) ->
|
||||
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, []}.
|
||||
@@ -7,17 +7,17 @@
|
||||
-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() ->
|
||||
@@ -25,9 +25,8 @@ trails() ->
|
||||
#{
|
||||
path => <<"/v1/admin/events">>,
|
||||
method => <<"GET">>,
|
||||
handler => ?MODULE,
|
||||
tags => [<<"Events">>],
|
||||
description => <<"Search and list events (admin)">>,
|
||||
tags => [<<"Events">>],
|
||||
parameters => [
|
||||
#{name => <<"from">>, in => <<"query">>, description => <<"ISO8601 start datetime">>, required => false, schema => #{type => string}},
|
||||
#{name => <<"to">>, in => <<"query">>, description => <<"ISO8601 end datetime">>, required => false, schema => #{type => string}},
|
||||
@@ -43,14 +42,10 @@ trails() ->
|
||||
responses => #{
|
||||
200 => #{
|
||||
description => <<"Array of events with Content-Range header">>,
|
||||
content => #{
|
||||
<<"application/json">> => #{
|
||||
schema => #{
|
||||
content => #{<<"application/json">> => #{schema => #{
|
||||
type => array,
|
||||
items => event_schema()
|
||||
}
|
||||
}
|
||||
}
|
||||
}}}
|
||||
},
|
||||
405 => #{description => <<"Method not allowed">>}
|
||||
}
|
||||
@@ -77,8 +72,11 @@ event_schema() ->
|
||||
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],
|
||||
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])),
|
||||
<<"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)),
|
||||
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),
|
||||
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">>)
|
||||
}.
|
||||
|
||||
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, []}.
|
||||
@@ -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
|
||||
<<"GET">> -> get_me(Req);
|
||||
<<"PUT">> -> update_me(Req);
|
||||
_ -> handler_utils:send_error(Req, 405, <<"Method not allowed">>)
|
||||
end.
|
||||
|
||||
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 core_admin:get_by_id(AdminId) of
|
||||
case logic_admin:get_admin(AdminId) of
|
||||
{ok, Admin} ->
|
||||
Permissions = admin_utils:get_permissions(Admin#admin.role),
|
||||
Resp = jsx:encode(#{
|
||||
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,
|
||||
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">>)
|
||||
end.
|
||||
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)
|
||||
}.
|
||||
|
||||
send_error(Req, Code, Message) ->
|
||||
Body = jsx:encode(#{error => Message}),
|
||||
Req2 = cowboy_req:reply(Code, #{<<"content-type">> => <<"application/json">>}, Body, Req),
|
||||
{ok, Req2, []}.
|
||||
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, []}.
|
||||
@@ -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">>)
|
||||
_ -> 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
|
||||
case logic_report:get_report(AdminId, ReportId) of
|
||||
{ok, Report} ->
|
||||
send_json(Req1, 200, report_to_json(Report));
|
||||
handler_utils:send_json(Req1, 200, handler_utils:report_to_json(Report));
|
||||
{error, not_found} ->
|
||||
send_error(Req1, 404, <<"Report not found">>)
|
||||
handler_utils:send_error(Req1, 404, <<"Report not found">>);
|
||||
{error, _} ->
|
||||
handler_utils:send_error(Req1, 500, <<"Internal server error">>)
|
||||
end;
|
||||
false ->
|
||||
send_error(Req1, 403, <<"Admin access required">>)
|
||||
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
|
||||
#{<<"status">> := Status} ->
|
||||
case logic_report:update_report_status(AdminId, ReportId, Status) of
|
||||
{ok, Report} ->
|
||||
log_audit(AdminId, <<"update_report_status">>, <<"report">>, ReportId, Reason),
|
||||
send_json(Req2, 200, report_to_json(Report));
|
||||
handler_utils:send_json(Req2, 200, handler_utils:report_to_json(Report));
|
||||
{error, not_found} ->
|
||||
send_error(Req2, 404, <<"Report not found">>);
|
||||
handler_utils:send_error(Req2, 404, <<"Report not found">>);
|
||||
{error, _} ->
|
||||
send_error(Req2, 500, <<"Internal server error">>)
|
||||
handler_utils:send_error(Req2, 500, <<"Internal server error">>)
|
||||
end;
|
||||
_ ->
|
||||
send_error(Req2, 400, <<"Missing status or reason">>)
|
||||
handler_utils:send_error(Req2, 400, <<"Missing status field">>)
|
||||
catch
|
||||
_:_ -> send_error(Req2, 400, <<"Invalid JSON">>)
|
||||
_:_ -> handler_utils:send_error(Req1, 400, <<"Invalid JSON">>)
|
||||
end;
|
||||
false ->
|
||||
send_error(Req1, 403, <<"Admin access required">>)
|
||||
end;
|
||||
{error, Code, Message, Req1} ->
|
||||
send_error(Req1, Code, Message)
|
||||
{error, Code, Msg, Req1} ->
|
||||
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, 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, []}.
|
||||
@@ -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.
|
||||
|
||||
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) ->
|
||||
-spec trails() -> [map()].
|
||||
trails() ->
|
||||
[
|
||||
#{
|
||||
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)
|
||||
path => <<"/v1/admin/reports">>,
|
||||
method => <<"GET">>,
|
||||
description => <<"List all reports (admin)">>,
|
||||
tags => [<<"Reports">>],
|
||||
parameters => [
|
||||
#{name => <<"status">>, in => <<"query">>, schema => #{type => string, enum => [<<"pending">>, <<"reviewed">>, <<"dismissed">>]}, description => <<"Filter by status">>},
|
||||
#{name => <<"target_type">>, in => <<"query">>, schema => #{type => string, enum => [<<"calendar">>, <<"event">>, <<"review">>]}, description => <<"Filter by target type">>},
|
||||
#{name => <<"q">>, in => <<"query">>, schema => #{type => string}, description => <<"Search in reason">>},
|
||||
#{name => <<"limit">>, in => <<"query">>, schema => #{type => integer}, description => <<"Page size">>},
|
||||
#{name => <<"offset">>, in => <<"query">>, schema => #{type => integer}, description => <<"Offset">>}
|
||||
],
|
||||
responses => #{
|
||||
200 => #{
|
||||
description => <<"Array of reports">>,
|
||||
content => #{<<"application/json">> => #{schema => #{
|
||||
type => array,
|
||||
items => report_schema()
|
||||
}}}
|
||||
}
|
||||
}
|
||||
}
|
||||
].
|
||||
|
||||
report_schema() ->
|
||||
#{
|
||||
type => object,
|
||||
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);
|
||||
<<"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 ->
|
||||
case handler_utils:auth_admin(Req) of
|
||||
{ok, _AdminId, Req1} ->
|
||||
ReviewId = cowboy_req:binding(id, Req1),
|
||||
case core_review:get_by_id(ReviewId) of
|
||||
case logic_review:get_review_admin(ReviewId) of
|
||||
{ok, Review} ->
|
||||
send_json(Req1, 200, review_to_json(Review));
|
||||
handler_utils:send_json(Req1, 200, handler_utils:review_to_json(Review));
|
||||
{error, not_found} ->
|
||||
send_error(Req1, 404, <<"Review not found">>)
|
||||
handler_utils:send_error(Req1, 404, <<"Review not found">>);
|
||||
{error, _} ->
|
||||
handler_utils:send_error(Req1, 500, <<"Internal server error">>)
|
||||
end;
|
||||
false ->
|
||||
send_error(Req1, 403, <<"Admin access required">>)
|
||||
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 ->
|
||||
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
|
||||
#{<<"status">> := NewStatus} ->
|
||||
case core_review:update_status(ReviewId, NewStatus) of
|
||||
UpdatesMap when is_map(UpdatesMap) ->
|
||||
Updates = maps:to_list(UpdatesMap),
|
||||
case logic_review:update_review_admin(ReviewId, Updates) of
|
||||
{ok, Review} ->
|
||||
send_json(Req2, 200, review_to_json(Review));
|
||||
handler_utils:send_json(Req2, 200, handler_utils:review_to_json(Review));
|
||||
{error, not_found} ->
|
||||
send_error(Req2, 404, <<"Review not found">>);
|
||||
handler_utils:send_error(Req2, 404, <<"Review not found">>);
|
||||
{error, _} ->
|
||||
send_error(Req2, 500, <<"Internal server error">>)
|
||||
handler_utils:send_error(Req2, 500, <<"Internal server error">>)
|
||||
end;
|
||||
_ ->
|
||||
send_error(Req2, 400, <<"Missing status field">>)
|
||||
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;
|
||||
false ->
|
||||
send_error(Req1, 403, <<"Admin access required">>)
|
||||
end;
|
||||
{error, Code, Message, Req1} ->
|
||||
send_error(Req1, Code, Message)
|
||||
{error, Code, Msg, Req1} ->
|
||||
handler_utils:send_error(Req1, Code, Msg)
|
||||
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, []}.
|
||||
@@ -1,35 +1,84 @@
|
||||
%%%-------------------------------------------------------------------
|
||||
%%% @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)
|
||||
{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;
|
||||
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),
|
||||
@@ -37,27 +86,17 @@ parse_date_range(Req) ->
|
||||
case {From, To} of
|
||||
{undefined, _} -> error;
|
||||
{_, undefined} -> error;
|
||||
{F, T} ->
|
||||
try
|
||||
FromDT = iso8601_to_datetime(F),
|
||||
{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, []}.
|
||||
@@ -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">>)
|
||||
_ -> 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.
|
||||
|
||||
%% ================== 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) ->
|
||||
-spec trails() -> [map()].
|
||||
trails() ->
|
||||
[
|
||||
#{
|
||||
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)
|
||||
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()
|
||||
}}}
|
||||
}
|
||||
}
|
||||
}
|
||||
].
|
||||
|
||||
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">>}
|
||||
}
|
||||
}.
|
||||
|
||||
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">>
|
||||
}.
|
||||
163
src/handlers/admin/admin_handler_subscriptions_by_id.erl
Normal file
163
src/handlers/admin/admin_handler_subscriptions_by_id.erl
Normal file
@@ -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)
|
||||
handler_utils: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, []}.
|
||||
@@ -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 ->
|
||||
case handler_utils:auth_admin(Req) of
|
||||
{ok, _AdminId, Req1} ->
|
||||
UserId = cowboy_req:binding(id, Req1),
|
||||
case core_user:get_by_id(UserId) of
|
||||
case logic_user:get_user_admin(UserId) of
|
||||
{ok, User} ->
|
||||
send_json(Req1, 200, user_to_json(User));
|
||||
handler_utils:send_json(Req1, 200, handler_utils:user_to_json(User));
|
||||
{error, not_found} ->
|
||||
send_error(Req1, 404, <<"User not found">>)
|
||||
handler_utils:send_error(Req1, 404, <<"User not found">>);
|
||||
{error, _} ->
|
||||
handler_utils:send_error(Req1, 500, <<"Internal server error">>)
|
||||
end;
|
||||
false ->
|
||||
send_error(Req1, 403, <<"Admin access required">>)
|
||||
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 ->
|
||||
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
|
||||
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">>)
|
||||
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;
|
||||
_ ->
|
||||
apply_updates(UserId, Updates, AdminId, undefined, Req2)
|
||||
end;
|
||||
_ ->
|
||||
send_error(Req2, 400, <<"Request body is empty">>)
|
||||
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;
|
||||
false ->
|
||||
send_error(Req1, 403, <<"Admin access required">>)
|
||||
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 ->
|
||||
case handler_utils:auth_admin(Req) of
|
||||
{ok, _AdminId, Req1} ->
|
||||
UserId = cowboy_req:binding(id, Req1),
|
||||
case core_user:delete(UserId) of
|
||||
case logic_user:delete_user_admin(UserId) of
|
||||
{ok, _} ->
|
||||
send_json(Req1, 200, #{status => <<"deleted">>});
|
||||
handler_utils: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">>)
|
||||
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">>);
|
||||
handler_utils:send_error(Req1, 404, <<"User not found">>);
|
||||
{error, _} ->
|
||||
send_error(Req, 500, <<"Internal server error">>)
|
||||
handler_utils:send_error(Req1, 500, <<"Internal server error">>)
|
||||
end;
|
||||
{error, Code, Msg, Req1} ->
|
||||
handler_utils:send_error(Req1, Code, Msg)
|
||||
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, []}.
|
||||
@@ -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,12 +79,13 @@ 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,
|
||||
@@ -79,6 +96,8 @@ websocket_info({admin_notification, Type, Data}, 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.
|
||||
@@ -1,88 +0,0 @@
|
||||
-module(handler_banned_words).
|
||||
-include("records.hrl").
|
||||
|
||||
-export([init/2]).
|
||||
|
||||
init(Req, Opts) ->
|
||||
handle(Req, Opts).
|
||||
|
||||
handle(Req, _Opts) ->
|
||||
case cowboy_req:method(Req) of
|
||||
<<"GET">> -> list_banned_words(Req);
|
||||
<<"POST">> -> add_banned_word(Req);
|
||||
<<"DELETE">> -> remove_banned_word(Req);
|
||||
_ -> send_error(Req, 405, <<"Method not allowed">>)
|
||||
end.
|
||||
|
||||
%% GET /v1/admin/banned-words - список запрещённых слов
|
||||
list_banned_words(Req) ->
|
||||
case handler_auth:authenticate(Req) of
|
||||
{ok, AdminId, Req1} ->
|
||||
case logic_moderation:list_banned_words(AdminId) of
|
||||
{ok, Words} ->
|
||||
send_json(Req1, 200, Words);
|
||||
{error, access_denied} ->
|
||||
send_error(Req1, 403, <<"Admin access required">>);
|
||||
{error, _} ->
|
||||
send_error(Req1, 500, <<"Internal server error">>)
|
||||
end;
|
||||
{error, Code, Message, Req1} ->
|
||||
send_error(Req1, Code, Message)
|
||||
end.
|
||||
|
||||
%% POST /v1/admin/banned-words - добавить запрещённое слово
|
||||
add_banned_word(Req) ->
|
||||
case handler_auth:authenticate(Req) of
|
||||
{ok, AdminId, Req1} ->
|
||||
{ok, Body, Req2} = cowboy_req:read_body(Req1),
|
||||
try jsx:decode(Body, [return_maps]) of
|
||||
#{<<"word">> := Word} ->
|
||||
case logic_moderation:add_banned_word(AdminId, Word) of
|
||||
{ok, _} ->
|
||||
send_json(Req2, 201, #{word => Word, status => <<"added">>});
|
||||
{error, already_exists} ->
|
||||
send_error(Req2, 409, <<"Word already exists">>);
|
||||
{error, access_denied} ->
|
||||
send_error(Req2, 403, <<"Admin access required">>);
|
||||
{error, _} ->
|
||||
send_error(Req2, 500, <<"Internal server error">>)
|
||||
end;
|
||||
_ ->
|
||||
send_error(Req2, 400, <<"Missing 'word' field">>)
|
||||
catch
|
||||
_:_ ->
|
||||
send_error(Req2, 400, <<"Invalid JSON format">>)
|
||||
end;
|
||||
{error, Code, Message, Req1} ->
|
||||
send_error(Req1, Code, Message)
|
||||
end.
|
||||
|
||||
%% DELETE /v1/admin/banned-words - удалить запрещённое слово
|
||||
remove_banned_word(Req) ->
|
||||
case handler_auth:authenticate(Req) of
|
||||
{ok, AdminId, Req1} ->
|
||||
Word = cowboy_req:binding(word, Req1),
|
||||
case logic_moderation:remove_banned_word(AdminId, Word) of
|
||||
{ok, removed} ->
|
||||
send_json(Req1, 200, #{word => Word, status => <<"removed">>});
|
||||
{error, not_found} ->
|
||||
send_error(Req1, 404, <<"Word not found">>);
|
||||
{error, access_denied} ->
|
||||
send_error(Req1, 403, <<"Admin access required">>);
|
||||
{error, _} ->
|
||||
send_error(Req1, 500, <<"Internal server error">>)
|
||||
end;
|
||||
{error, Code, Message, Req1} ->
|
||||
send_error(Req1, Code, Message)
|
||||
end.
|
||||
|
||||
%% Вспомогательные функции
|
||||
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, []}.
|
||||
@@ -1,85 +0,0 @@
|
||||
-module(handler_report_by_id).
|
||||
-include("records.hrl").
|
||||
|
||||
-export([init/2]).
|
||||
|
||||
init(Req, Opts) ->
|
||||
handle(Req, Opts).
|
||||
|
||||
handle(Req, _Opts) ->
|
||||
case cowboy_req:method(Req) of
|
||||
<<"PUT">> -> resolve_report(Req);
|
||||
_ -> send_error(Req, 405, <<"Method not allowed">>)
|
||||
end.
|
||||
|
||||
%% PUT /v1/admin/reports/:id - рассмотрение жалобы
|
||||
resolve_report(Req) ->
|
||||
case handler_auth:authenticate(Req) of
|
||||
{ok, AdminId, Req1} ->
|
||||
ReportId = cowboy_req:binding(id, Req1),
|
||||
{ok, Body, Req2} = cowboy_req:read_body(Req1),
|
||||
try jsx:decode(Body, [return_maps]) of
|
||||
#{<<"action">> := Action} ->
|
||||
ActionAtom = case Action of
|
||||
<<"review">> -> reviewed;
|
||||
<<"dismiss">> -> dismissed;
|
||||
_ -> undefined
|
||||
end,
|
||||
case ActionAtom of
|
||||
undefined ->
|
||||
send_error(Req2, 400, <<"Invalid action. Use 'review' or 'dismiss'">>);
|
||||
_ ->
|
||||
case logic_moderation:resolve_report(AdminId, ReportId, ActionAtom) of
|
||||
{ok, Report} ->
|
||||
Response = report_to_json(Report),
|
||||
send_json(Req2, 200, Response);
|
||||
{error, access_denied} ->
|
||||
send_error(Req2, 403, <<"Admin access required">>);
|
||||
{error, already_resolved} ->
|
||||
send_error(Req2, 409, <<"Report already resolved">>);
|
||||
{error, not_found} ->
|
||||
send_error(Req2, 404, <<"Report not found">>);
|
||||
{error, _} ->
|
||||
send_error(Req2, 500, <<"Internal server error">>)
|
||||
end
|
||||
end;
|
||||
_ ->
|
||||
send_error(Req2, 400, <<"Missing action field">>)
|
||||
catch
|
||||
_:_ ->
|
||||
send_error(Req2, 400, <<"Invalid JSON format">>)
|
||||
end;
|
||||
{error, Code, Message, Req1} ->
|
||||
send_error(Req1, Code, Message)
|
||||
end.
|
||||
|
||||
%% Вспомогательные функции
|
||||
report_to_json(Report) ->
|
||||
#{
|
||||
id => Report#report.id,
|
||||
reporter_id => Report#report.reporter_id,
|
||||
target_type => Report#report.target_type,
|
||||
target_id => Report#report.target_id,
|
||||
reason => Report#report.reason,
|
||||
status => Report#report.status,
|
||||
created_at => datetime_to_iso8601(Report#report.created_at),
|
||||
resolved_at => case Report#report.resolved_at of
|
||||
undefined -> null;
|
||||
Dt -> datetime_to_iso8601(Dt)
|
||||
end,
|
||||
resolved_by => Report#report.resolved_by
|
||||
}.
|
||||
|
||||
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])).
|
||||
|
||||
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, []}.
|
||||
@@ -1,39 +0,0 @@
|
||||
-module(handler_ticket_stats).
|
||||
-include("records.hrl").
|
||||
|
||||
-export([init/2]).
|
||||
|
||||
init(Req, Opts) ->
|
||||
handle(Req, Opts).
|
||||
|
||||
handle(Req, _Opts) ->
|
||||
case cowboy_req:method(Req) of
|
||||
<<"GET">> -> get_statistics(Req);
|
||||
_ -> send_error(Req, 405, <<"Method not allowed">>)
|
||||
end.
|
||||
|
||||
%% GET /v1/admin/tickets/stats - статистика по тикетам
|
||||
get_statistics(Req) ->
|
||||
case handler_auth:authenticate(Req) of
|
||||
{ok, AdminId, Req1} ->
|
||||
case logic_ticket:get_statistics(AdminId) of
|
||||
Stats when is_map(Stats) ->
|
||||
send_json(Req1, 200, Stats);
|
||||
{error, access_denied} ->
|
||||
send_error(Req1, 403, <<"Admin access required">>);
|
||||
{error, _} ->
|
||||
send_error(Req1, 500, <<"Internal server error">>)
|
||||
end;
|
||||
{error, Code, Message, Req1} ->
|
||||
send_error(Req1, Code, Message)
|
||||
end.
|
||||
|
||||
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, []}.
|
||||
378
src/handlers/handler_utils.erl
Normal file
378
src/handlers/handler_utils.erl
Normal file
@@ -0,0 +1,378 @@
|
||||
%%%-------------------------------------------------------------------
|
||||
%%% @doc Общие утилиты для HTTP-обработчиков.
|
||||
%%% Содержит повторяющиеся функции, которые раньше копировались
|
||||
%%% в каждый обработчик: аутентификация, отправка ответов,
|
||||
%%% парсинг параметров, сериализация записей и генерация трейлов.
|
||||
%%% @end
|
||||
%%%-------------------------------------------------------------------
|
||||
-module(handler_utils).
|
||||
|
||||
-export([
|
||||
auth_admin/1,
|
||||
auth_user/1,
|
||||
send_json/3,
|
||||
send_json/4,
|
||||
send_error/3,
|
||||
parse_pagination_params/1,
|
||||
parse_int_qs/2,
|
||||
parse_datetime_qs/1,
|
||||
parse_datetime/1,
|
||||
event_to_json/1,
|
||||
user_to_json/1,
|
||||
review_to_json/1,
|
||||
report_to_json/1,
|
||||
ticket_to_json/1,
|
||||
calendar_to_json/1,
|
||||
subscription_to_json/1,
|
||||
trails_for_crud/4
|
||||
]).
|
||||
|
||||
-include("records.hrl").
|
||||
|
||||
%%%===================================================================
|
||||
%%% Аутентификация и авторизация
|
||||
%%%===================================================================
|
||||
|
||||
%% @doc Проверяет, что запрос содержит валидный токен администратора.
|
||||
-spec auth_admin(cowboy_req:req()) ->
|
||||
{ok, binary(), cowboy_req:req()} | {error, integer(), binary(), cowboy_req:req()}.
|
||||
auth_admin(Req) ->
|
||||
case handler_auth:authenticate(Req) of
|
||||
{ok, UserId, Req1} ->
|
||||
case admin_utils:is_admin(UserId) of
|
||||
true -> {ok, UserId, Req1};
|
||||
false -> {error, 403, <<"Admin access required">>, Req1}
|
||||
end;
|
||||
{error, Code, Msg, Req1} ->
|
||||
{error, Code, Msg, Req1}
|
||||
end.
|
||||
|
||||
%% @doc Проверяет, что запрос содержит валидный токен пользователя.
|
||||
-spec auth_user(cowboy_req:req()) ->
|
||||
{ok, binary(), cowboy_req:req()} | {error, integer(), binary(), cowboy_req:req()}.
|
||||
auth_user(Req) ->
|
||||
handler_auth:authenticate(Req).
|
||||
|
||||
%%%===================================================================
|
||||
%%% HTTP‑ответы
|
||||
%%%===================================================================
|
||||
|
||||
%% @doc Отправляет JSON-ответ с указанным статусом и стандартным заголовком.
|
||||
-spec send_json(cowboy_req:req(), cowboy:http_status(), jsx:json_term()) ->
|
||||
{ok, binary(), cowboy_req:req()}.
|
||||
send_json(Req, Status, Data) ->
|
||||
send_json(Req, Status, Data, #{}).
|
||||
|
||||
%% @doc Отправляет JSON-ответ с указанным статусом и дополнительными заголовками.
|
||||
%% ExtraHeaders вставляются поверх стандартного `content-type`.
|
||||
-spec send_json(cowboy_req:req(), cowboy:http_status(), jsx:json_term(), map()) ->
|
||||
{ok, binary(), cowboy_req:req()}.
|
||||
send_json(Req, Status, Data, ExtraHeaders) ->
|
||||
Body = jsx:encode(Data),
|
||||
BaseHeaders = #{<<"content-type">> => <<"application/json">>},
|
||||
Headers = maps:merge(BaseHeaders, ExtraHeaders),
|
||||
Req1 = cowboy_req:reply(Status, Headers, Body, Req),
|
||||
{ok, Body, Req1}.
|
||||
|
||||
%% @doc Отправляет JSON-ошибку.
|
||||
-spec send_error(cowboy_req:req(), cowboy:http_status(), binary()) ->
|
||||
{ok, binary(), cowboy_req:req()}.
|
||||
send_error(Req, Status, Message) ->
|
||||
Body = jsx:encode(#{error => Message}),
|
||||
Headers = #{<<"content-type">> => <<"application/json">>},
|
||||
Req1 = cowboy_req:reply(Status, Headers, Body, Req),
|
||||
{ok, Body, Req1}.
|
||||
|
||||
%%%===================================================================
|
||||
%%% Парсинг параметров запроса
|
||||
%%%===================================================================
|
||||
|
||||
%% @doc Извлекает стандартные параметры пагинации/сортировки.
|
||||
-spec parse_pagination_params(cowboy_req:req()) ->
|
||||
#{limit => integer(), offset => integer(), sort => binary(), order => binary()}.
|
||||
parse_pagination_params(Req) ->
|
||||
Qs = cowboy_req:parse_qs(Req),
|
||||
#{
|
||||
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">>)
|
||||
}.
|
||||
|
||||
-spec parse_int_qs(binary() | undefined, integer()) -> integer().
|
||||
parse_int_qs(undefined, Default) -> Default;
|
||||
parse_int_qs(Bin, Default) ->
|
||||
try binary_to_integer(Bin) catch _:_ -> Default end.
|
||||
|
||||
%% @doc Преобразует бинарный ISO8601 параметр в datetime().
|
||||
-spec parse_datetime_qs(binary() | undefined) -> calendar:datetime() | undefined.
|
||||
parse_datetime_qs(undefined) -> undefined;
|
||||
parse_datetime_qs(Bin) ->
|
||||
case parse_datetime(Bin) of
|
||||
{ok, Dt} -> Dt;
|
||||
_ -> undefined
|
||||
end.
|
||||
|
||||
%% @doc Разбирает ISO8601 строку в datetime().
|
||||
-spec parse_datetime(binary()) -> {ok, calendar:datetime()} | {error, invalid_format}.
|
||||
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.
|
||||
|
||||
%%%===================================================================
|
||||
%%% Сериализация записей (все поля согласно records.hrl)
|
||||
%%%===================================================================
|
||||
|
||||
%% @doc Преобразует #event{} в JSON-карту.
|
||||
-spec event_to_json(#event{}) -> map().
|
||||
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,
|
||||
reason => Event#event.reason,
|
||||
rating_avg => Event#event.rating_avg,
|
||||
rating_count => Event#event.rating_count,
|
||||
attachments => Event#event.attachments,
|
||||
edit_history => Event#event.edit_history,
|
||||
created_at => datetime_to_iso8601(Event#event.created_at),
|
||||
updated_at => datetime_to_iso8601(Event#event.updated_at)
|
||||
}.
|
||||
|
||||
%% @doc Преобразует #user{} в JSON-карту.
|
||||
-spec user_to_json(#user{}) -> map().
|
||||
user_to_json(User) ->
|
||||
#{
|
||||
id => User#user.id,
|
||||
email => User#user.email,
|
||||
role => User#user.role,
|
||||
status => User#user.status,
|
||||
reason => User#user.reason,
|
||||
nickname => User#user.nickname,
|
||||
avatar_url => User#user.avatar_url,
|
||||
timezone => User#user.timezone,
|
||||
language => User#user.language,
|
||||
social_links => User#user.social_links,
|
||||
phone => User#user.phone,
|
||||
preferences => User#user.preferences,
|
||||
last_login => datetime_to_iso8601(User#user.last_login),
|
||||
created_at => datetime_to_iso8601(User#user.created_at),
|
||||
updated_at => datetime_to_iso8601(User#user.updated_at)
|
||||
}.
|
||||
|
||||
%% @doc Преобразует #review{} в JSON-карту.
|
||||
-spec review_to_json(#review{}) -> map().
|
||||
review_to_json(Review) ->
|
||||
#{
|
||||
id => Review#review.id,
|
||||
user_id => Review#review.user_id,
|
||||
target_type => Review#review.target_type,
|
||||
target_id => Review#review.target_id,
|
||||
rating => Review#review.rating,
|
||||
comment => Review#review.comment,
|
||||
status => Review#review.status,
|
||||
reason => Review#review.reason,
|
||||
likes => Review#review.likes,
|
||||
dislikes => Review#review.dislikes,
|
||||
created_at => datetime_to_iso8601(Review#review.created_at),
|
||||
updated_at => datetime_to_iso8601(Review#review.updated_at)
|
||||
}.
|
||||
|
||||
%% @doc Преобразует #report{} в JSON-карту.
|
||||
-spec report_to_json(#report{}) -> map().
|
||||
report_to_json(Report) ->
|
||||
#{
|
||||
id => Report#report.id,
|
||||
reporter_id => Report#report.reporter_id,
|
||||
target_type => Report#report.target_type,
|
||||
target_id => Report#report.target_id,
|
||||
reason => Report#report.reason,
|
||||
status => Report#report.status,
|
||||
created_at => datetime_to_iso8601(Report#report.created_at),
|
||||
resolved_at => datetime_to_iso8601(Report#report.resolved_at),
|
||||
resolved_by => Report#report.resolved_by
|
||||
}.
|
||||
|
||||
%% @doc Преобразует #ticket{} в JSON-карту.
|
||||
-spec ticket_to_json(#ticket{}) -> map().
|
||||
ticket_to_json(Ticket) ->
|
||||
#{
|
||||
id => Ticket#ticket.id,
|
||||
reporter_id => Ticket#ticket.reporter_id,
|
||||
error_hash => Ticket#ticket.error_hash,
|
||||
error_message => Ticket#ticket.error_message,
|
||||
stacktrace => Ticket#ticket.stacktrace,
|
||||
context => Ticket#ticket.context,
|
||||
count => Ticket#ticket.count,
|
||||
first_seen => datetime_to_iso8601(Ticket#ticket.first_seen),
|
||||
last_seen => datetime_to_iso8601(Ticket#ticket.last_seen),
|
||||
status => Ticket#ticket.status,
|
||||
assigned_to => Ticket#ticket.assigned_to,
|
||||
resolution_note => Ticket#ticket.resolution_note
|
||||
}.
|
||||
|
||||
%% @doc Преобразует #calendar{} в JSON-карту.
|
||||
-spec calendar_to_json(#calendar{}) -> map().
|
||||
calendar_to_json(Calendar) ->
|
||||
#{
|
||||
id => Calendar#calendar.id,
|
||||
owner_id => Calendar#calendar.owner_id,
|
||||
title => Calendar#calendar.title,
|
||||
description => Calendar#calendar.description,
|
||||
short_name => Calendar#calendar.short_name,
|
||||
category => Calendar#calendar.category,
|
||||
color => Calendar#calendar.color,
|
||||
image_url => Calendar#calendar.image_url,
|
||||
settings => Calendar#calendar.settings,
|
||||
tags => Calendar#calendar.tags,
|
||||
type => Calendar#calendar.type,
|
||||
confirmation => Calendar#calendar.confirmation,
|
||||
rating_avg => Calendar#calendar.rating_avg,
|
||||
rating_count => Calendar#calendar.rating_count,
|
||||
status => Calendar#calendar.status,
|
||||
reason => Calendar#calendar.reason,
|
||||
created_at => datetime_to_iso8601(Calendar#calendar.created_at),
|
||||
updated_at => datetime_to_iso8601(Calendar#calendar.updated_at)
|
||||
}.
|
||||
|
||||
%% @doc Преобразует #subscription{} в JSON-карту.
|
||||
-spec subscription_to_json(#subscription{}) -> map().
|
||||
subscription_to_json(Subscription) ->
|
||||
#{
|
||||
id => Subscription#subscription.id,
|
||||
user_id => Subscription#subscription.user_id,
|
||||
plan => Subscription#subscription.plan,
|
||||
status => Subscription#subscription.status,
|
||||
trial_used => Subscription#subscription.trial_used,
|
||||
started_at => datetime_to_iso8601(Subscription#subscription.started_at),
|
||||
expires_at => datetime_to_iso8601(Subscription#subscription.expires_at),
|
||||
created_at => datetime_to_iso8601(Subscription#subscription.created_at),
|
||||
updated_at => datetime_to_iso8601(Subscription#subscription.updated_at)
|
||||
}.
|
||||
|
||||
%%%===================================================================
|
||||
%%% Вспомогательные внутренние функции
|
||||
%%%===================================================================
|
||||
|
||||
%% @private
|
||||
-spec datetime_to_iso8601(calendar:datetime() | undefined) -> binary() | undefined.
|
||||
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])).
|
||||
|
||||
%%%===================================================================
|
||||
%%% Генерация Swagger-трейлов для типового CRUD-ресурса
|
||||
%%%===================================================================
|
||||
|
||||
%% @doc Генерирует трейлы для GET (list), GET /:id, POST, PUT, DELETE.
|
||||
-spec trails_for_crud(binary(), binary(), map(), map()) -> [map()].
|
||||
trails_for_crud(Path, _Resource, GetSchema, UpdateSchema) ->
|
||||
IdParam = #{
|
||||
name => <<"id">>,
|
||||
in => <<"path">>,
|
||||
description => <<"Resource ID">>,
|
||||
required => true,
|
||||
schema => #{type => string}
|
||||
},
|
||||
[
|
||||
#{ % GET list
|
||||
path => Path,
|
||||
method => <<"GET">>,
|
||||
description => <<"List all records">>,
|
||||
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 records">>,
|
||||
content => #{<<"application/json">> => #{schema => GetSchema}}
|
||||
}
|
||||
}
|
||||
},
|
||||
#{ % GET by id
|
||||
path => <<Path/binary, "/:id">>,
|
||||
method => <<"GET">>,
|
||||
description => <<"Get record by ID">>,
|
||||
parameters => [IdParam],
|
||||
responses => #{
|
||||
200 => #{
|
||||
description => <<"Record details">>,
|
||||
content => #{<<"application/json">> => #{schema => GetSchema}}
|
||||
}
|
||||
}
|
||||
},
|
||||
#{ % POST
|
||||
path => Path,
|
||||
method => <<"POST">>,
|
||||
description => <<"Create a new record">>,
|
||||
requestBody => #{
|
||||
required => true,
|
||||
content => #{<<"application/json">> => #{schema => UpdateSchema}}
|
||||
},
|
||||
responses => #{
|
||||
201 => #{description => <<"Record created">>}
|
||||
}
|
||||
},
|
||||
#{ % PUT
|
||||
path => <<Path/binary, "/:id">>,
|
||||
method => <<"PUT">>,
|
||||
description => <<"Update record by ID">>,
|
||||
parameters => [IdParam],
|
||||
requestBody => #{
|
||||
required => true,
|
||||
content => #{<<"application/json">> => #{schema => UpdateSchema}}
|
||||
},
|
||||
responses => #{
|
||||
200 => #{description => <<"Record updated">>}
|
||||
}
|
||||
},
|
||||
#{ % DELETE
|
||||
path => <<Path/binary, "/:id">>,
|
||||
method => <<"DELETE">>,
|
||||
description => <<"Delete record by ID">>,
|
||||
parameters => [IdParam],
|
||||
responses => #{
|
||||
200 => #{description => <<"Record deleted">>}
|
||||
}
|
||||
}
|
||||
].
|
||||
25
src/infra/infra_utils.erl
Normal file
25
src/infra/infra_utils.erl
Normal file
@@ -0,0 +1,25 @@
|
||||
%%%-------------------------------------------------------------------
|
||||
%%% @doc Общие инфраструктурные утилиты.
|
||||
%%% Содержит функцию генерации уникальных идентификаторов,
|
||||
%%% используемых во всех основных сущностях (пользователи,
|
||||
%%% администраторы, события и т.д.).
|
||||
%%% @end
|
||||
%%%-------------------------------------------------------------------
|
||||
-module(infra_utils).
|
||||
|
||||
-export([generate_id/1]).
|
||||
|
||||
%%%===================================================================
|
||||
%%% API
|
||||
%%%===================================================================
|
||||
|
||||
%% @doc Генерирует уникальный идентификатор.
|
||||
%% Формат: URL-безопасный base64 от n случайных байт,
|
||||
%% без завершающих символов '='.
|
||||
%% Длина строки: 22 символа.
|
||||
%% Пример: <<"WyrF9DQm3YTksEJww4lyrQ">>
|
||||
-spec generate_id(non_neg_integer()) -> binary().
|
||||
generate_id(Bytes) ->
|
||||
Base64 = base64:encode(crypto:strong_rand_bytes(Bytes), #{mode => urlsafe, padding => false}),
|
||||
Id = binary:replace(Base64, <<"-">>, <<"0">>, [global]),
|
||||
binary:replace(Id, <<"_">>, <<"9">>, [global]).
|
||||
84
src/logic/logic_admin.erl
Normal file
84
src/logic/logic_admin.erl
Normal file
@@ -0,0 +1,84 @@
|
||||
-module(logic_admin).
|
||||
|
||||
-export([list_admins/2, get_admin/1, update_admin/2]).
|
||||
|
||||
-include("records.hrl").
|
||||
|
||||
%%%-------------------------------------------------------------------
|
||||
%%% Административный список администраторов с пагинацией
|
||||
%%%-------------------------------------------------------------------
|
||||
-spec list_admins(map(), #{limit => integer(), offset => integer(), sort => binary(), order => binary()}) ->
|
||||
{ok, non_neg_integer(), [#admin{}]}.
|
||||
list_admins(Filters, Pagination) ->
|
||||
#{limit := Limit, offset := Offset, sort := Sort, order := Order} = Pagination,
|
||||
AllAdmins = core_admin:list_all(),
|
||||
Filtered = apply_filters(AllAdmins, Filters),
|
||||
Sorted = sort_admins(Filtered, Sort, Order),
|
||||
Total = length(Sorted),
|
||||
Page = lists:sublist(Sorted, Offset + 1, Limit),
|
||||
{ok, Total, Page}.
|
||||
|
||||
%%%-------------------------------------------------------------------
|
||||
%%% Получение администратора по ID
|
||||
%%%-------------------------------------------------------------------
|
||||
-spec get_admin(binary()) -> {ok, #admin{}} | {error, not_found}.
|
||||
get_admin(AdminId) ->
|
||||
core_admin:get_by_id(AdminId).
|
||||
|
||||
%%%-------------------------------------------------------------------
|
||||
%%% Обновление администратора
|
||||
%%%-------------------------------------------------------------------
|
||||
-spec update_admin(binary(), proplists:proplist()) ->
|
||||
{ok, #admin{}} | {error, not_found | invalid_field}.
|
||||
update_admin(AdminId, Updates) ->
|
||||
case core_admin:get_by_id(AdminId) of
|
||||
{ok, _Admin} ->
|
||||
ValidUpdates = validate_admin_updates(Updates),
|
||||
core_admin:update(AdminId, ValidUpdates);
|
||||
Error ->
|
||||
Error
|
||||
end.
|
||||
|
||||
%%%===================================================================
|
||||
%%% Внутренние функции
|
||||
%%%===================================================================
|
||||
|
||||
apply_filters(Admins, Filters) ->
|
||||
Role = maps:get(role, Filters, undefined),
|
||||
Status = maps:get(status, Filters, undefined),
|
||||
F1 = case Role of
|
||||
undefined -> Admins;
|
||||
_ -> [A || A <- Admins, A#admin.role =:= Role]
|
||||
end,
|
||||
case Status of
|
||||
undefined -> F1;
|
||||
_ -> [A || A <- F1, A#admin.status =:= Status]
|
||||
end.
|
||||
|
||||
sort_admins(Admins, SortField, Order) ->
|
||||
Field = binary_to_existing_atom(SortField, utf8),
|
||||
Sorted = lists:sort(
|
||||
fun(A, B) ->
|
||||
ValA = admin_field(A, Field),
|
||||
ValB = admin_field(B, Field),
|
||||
if Order == <<"asc">> -> ValA =< ValB;
|
||||
true -> ValA >= ValB
|
||||
end
|
||||
end, Admins),
|
||||
Sorted.
|
||||
|
||||
admin_field(#admin{created_at = V}, created_at) -> V;
|
||||
admin_field(#admin{email = V}, email) -> V;
|
||||
admin_field(#admin{role = V}, role) -> V;
|
||||
admin_field(_, _) -> undefined.
|
||||
|
||||
validate_admin_updates(Updates) ->
|
||||
lists:filter(fun validate_admin_update/1, Updates).
|
||||
|
||||
validate_admin_update({nickname, V}) when is_binary(V); V =:= undefined -> true;
|
||||
validate_admin_update({avatar_url, V}) when is_binary(V); V =:= undefined -> true;
|
||||
validate_admin_update({timezone, V}) when is_binary(V); V =:= undefined -> true;
|
||||
validate_admin_update({language, V}) when is_binary(V); V =:= undefined -> true;
|
||||
validate_admin_update({phone, V}) when is_binary(V); V =:= undefined -> true;
|
||||
validate_admin_update({preferences, V}) when is_map(V); V =:= undefined -> true;
|
||||
validate_admin_update(_) -> false.
|
||||
47
src/logic/logic_report.erl
Normal file
47
src/logic/logic_report.erl
Normal file
@@ -0,0 +1,47 @@
|
||||
-module(logic_report).
|
||||
-include("records.hrl").
|
||||
|
||||
-export([list_reports/1, get_report/2, update_report_status/3, delete_report/2]).
|
||||
|
||||
%% Получить список всех жалоб (только для админов)
|
||||
-spec list_reports(binary()) -> {ok, [#report{}]} | {error, access_denied}.
|
||||
list_reports(AdminId) ->
|
||||
case admin_utils:is_admin(AdminId) of
|
||||
true ->
|
||||
{ok, Reports} = core_report:list_all(),
|
||||
{ok, Reports};
|
||||
false -> {error, access_denied}
|
||||
end.
|
||||
|
||||
%% Получить конкретную жалобу по ID (только для админов)
|
||||
-spec get_report(binary(), binary()) -> {ok, #report{}} | {error, not_found | access_denied}.
|
||||
get_report(AdminId, ReportId) ->
|
||||
case admin_utils:is_admin(AdminId) of
|
||||
true -> core_report:get_by_id(ReportId);
|
||||
false -> {error, access_denied}
|
||||
end.
|
||||
|
||||
%% Обновить статус жалобы (только для админов)
|
||||
-spec update_report_status(binary(), binary(), binary()) -> {ok, #report{}} | {error, not_found | access_denied | invalid_status}.
|
||||
update_report_status(AdminId, ReportId, NewStatus) ->
|
||||
case admin_utils:is_admin(AdminId) of
|
||||
true ->
|
||||
StatusAtom = case NewStatus of
|
||||
<<"reviewed">> -> reviewed;
|
||||
<<"dismissed">> -> dismissed;
|
||||
_ -> undefined
|
||||
end,
|
||||
case StatusAtom of
|
||||
undefined -> {error, invalid_status};
|
||||
_ -> core_report:update_status(ReportId, StatusAtom, AdminId)
|
||||
end;
|
||||
false -> {error, access_denied}
|
||||
end.
|
||||
|
||||
%% Удалить жалобу (только для админов)
|
||||
-spec delete_report(binary(), binary()) -> {ok, deleted} | {error, not_found | access_denied}.
|
||||
delete_report(AdminId, ReportId) ->
|
||||
case admin_utils:is_admin(AdminId) of
|
||||
true -> core_report:delete(ReportId);
|
||||
false -> {error, access_denied}
|
||||
end.
|
||||
@@ -5,6 +5,7 @@
|
||||
update_review/3, delete_review/2, hide_review/2, hide_review/3, unhide_review/2, unhide_review/3]).
|
||||
-export([can_review/3, update_target_rating/2, can_moderate_review/2]).
|
||||
-export([list_admin_reviews/1, bulk_update_status/1]).
|
||||
-export([list_admin_reviews/2, get_review_admin/1, update_review_admin/2]).
|
||||
|
||||
%% Создание отзыва
|
||||
create_review(UserId, TargetType, TargetId, Rating, Comment) ->
|
||||
@@ -200,8 +201,9 @@ can_moderate_review(UserId, ReviewId) ->
|
||||
%%% @end
|
||||
%%%-------------------------------------------------------------------
|
||||
list_admin_reviews(Filters) ->
|
||||
AllReviews = core_review:list_all(),
|
||||
apply_filters(AllReviews, Filters).
|
||||
Reviews = core_review:list_all(), % возвращает список
|
||||
Filtered = apply_filters(Reviews, Filters),
|
||||
{ok, Filtered}.
|
||||
|
||||
%% Вспомогательная функция: фильтрация списка по proplist
|
||||
apply_filters(Reviews, []) ->
|
||||
@@ -272,3 +274,52 @@ update_target_rating(calendar, CalendarId) ->
|
||||
{Avg, Count} = core_review:get_average_rating(calendar, CalendarId),
|
||||
core_calendar:update(CalendarId, [{rating_avg, Avg}, {rating_count, Count}]);
|
||||
update_target_rating(_, _) -> ok.
|
||||
|
||||
%% Административный список с пагинацией
|
||||
-spec list_admin_reviews(map(), #{limit => integer(), offset => integer(), sort => binary(), order => binary()}) ->
|
||||
{ok, non_neg_integer(), [#review{}]}.
|
||||
list_admin_reviews(Filters, Pagination) ->
|
||||
#{limit := Limit, offset := Offset, sort := Sort, order := Order} = Pagination,
|
||||
% Получаем все отзывы (можно временно через list_admin_reviews/1)
|
||||
{ok, All} = list_admin_reviews(maps:to_list(Filters)),
|
||||
Sorted = sort_reviews(All, Sort, Order),
|
||||
Total = length(Sorted),
|
||||
Page = lists:sublist(Sorted, Offset + 1, Limit),
|
||||
{ok, Total, Page}.
|
||||
|
||||
%% Получить отзыв без проверки прав
|
||||
-spec get_review_admin(binary()) -> {ok, #review{}} | {error, not_found}.
|
||||
get_review_admin(ReviewId) ->
|
||||
core_review:get_by_id(ReviewId).
|
||||
|
||||
%% Обновить отзыв без проверки прав
|
||||
-spec update_review_admin(binary(), proplists:proplist()) ->
|
||||
{ok, #review{}} | {error, not_found}.
|
||||
update_review_admin(ReviewId, Updates) ->
|
||||
case core_review:get_by_id(ReviewId) of
|
||||
{ok, _} -> core_review:update(ReviewId, Updates);
|
||||
Error -> Error
|
||||
end.
|
||||
|
||||
%%%-------------------------------------------------------------------
|
||||
%%% Вспомогательные функции административной пагинации
|
||||
%%%-------------------------------------------------------------------
|
||||
|
||||
%% @private Сортирует список отзывов по указанному полю.
|
||||
sort_reviews(Reviews, SortField, Order) ->
|
||||
Field = binary_to_existing_atom(SortField, utf8),
|
||||
Sorted = lists:sort(
|
||||
fun(A, B) ->
|
||||
ValA = review_field(A, Field),
|
||||
ValB = review_field(B, Field),
|
||||
if Order == <<"asc">> -> ValA =< ValB;
|
||||
true -> ValA >= ValB
|
||||
end
|
||||
end, Reviews),
|
||||
Sorted.
|
||||
|
||||
%% @private Извлекает значение поля из записи отзыва для сортировки.
|
||||
review_field(#review{created_at = V}, created_at) -> V;
|
||||
review_field(#review{rating = V}, rating) -> V;
|
||||
review_field(#review{status = V}, status) -> V;
|
||||
review_field(_, _) -> undefined.
|
||||
@@ -10,6 +10,7 @@
|
||||
resolve_ticket/3,
|
||||
close_ticket/2,
|
||||
get_statistics/1]).
|
||||
-export([delete_ticket/2]).
|
||||
|
||||
%% Зарегистрировать ошибку (создать или обновить тикет)
|
||||
report_error(ErrorMessage, Stacktrace, Context) ->
|
||||
@@ -93,6 +94,13 @@ close_ticket(AdminId, TicketId) ->
|
||||
false -> {error, access_denied}
|
||||
end.
|
||||
|
||||
%% Удалить тикет (только для админов)
|
||||
delete_ticket(AdminId, TicketId) ->
|
||||
case admin_utils:is_admin(AdminId) of
|
||||
true -> core_ticket:delete_ticket(TicketId);
|
||||
false -> {error, access_denied}
|
||||
end.
|
||||
|
||||
%% Получить статистику по тикетам
|
||||
get_statistics(AdminId) ->
|
||||
case admin_utils:is_admin(AdminId) of
|
||||
|
||||
107
src/logic/logic_user.erl
Normal file
107
src/logic/logic_user.erl
Normal file
@@ -0,0 +1,107 @@
|
||||
-module(logic_user).
|
||||
|
||||
-export([list_users_admin/2, get_user_admin/1, update_user_admin/2, delete_user_admin/1]).
|
||||
|
||||
-include("records.hrl").
|
||||
|
||||
%%%-------------------------------------------------------------------
|
||||
%%% Административный список пользователей с пагинацией
|
||||
%%%-------------------------------------------------------------------
|
||||
-spec list_users_admin(map(), #{limit => integer(), offset => integer(), sort => binary(), order => binary()}) ->
|
||||
{ok, non_neg_integer(), [#user{}]}.
|
||||
list_users_admin(Filters, Pagination) ->
|
||||
#{limit := Limit, offset := Offset, sort := Sort, order := Order} = Pagination,
|
||||
AllUsers = core_user:list_all(),
|
||||
Filtered = apply_filters(AllUsers, Filters),
|
||||
Sorted = sort_users(Filtered, Sort, Order),
|
||||
Total = length(Sorted),
|
||||
Page = lists:sublist(Sorted, Offset + 1, Limit),
|
||||
{ok, Total, Page}.
|
||||
|
||||
%%%-------------------------------------------------------------------
|
||||
%%% Получение пользователя по ID (без проверки прав)
|
||||
%%%-------------------------------------------------------------------
|
||||
-spec get_user_admin(binary()) -> {ok, #user{}} | {error, not_found}.
|
||||
get_user_admin(UserId) ->
|
||||
core_user:get_by_id(UserId).
|
||||
|
||||
%%%-------------------------------------------------------------------
|
||||
%%% Обновление пользователя (без проверки прав)
|
||||
%%%-------------------------------------------------------------------
|
||||
-spec update_user_admin(binary(), proplists:proplist()) ->
|
||||
{ok, #user{}} | {error, not_found | invalid_field}.
|
||||
update_user_admin(UserId, Updates) ->
|
||||
case core_user:get_by_id(UserId) of
|
||||
{ok, _User} ->
|
||||
ValidUpdates = validate_user_updates(Updates),
|
||||
core_user:update(UserId, ValidUpdates);
|
||||
Error ->
|
||||
Error
|
||||
end.
|
||||
|
||||
%%%-------------------------------------------------------------------
|
||||
%%% Мягкое удаление пользователя (установка статуса deleted)
|
||||
%%%-------------------------------------------------------------------
|
||||
-spec delete_user_admin(binary()) -> {ok, #user{}} | {error, not_found}.
|
||||
delete_user_admin(UserId) ->
|
||||
case core_user:get_by_id(UserId) of
|
||||
{ok, User} ->
|
||||
UpdatedUser = User#user{status = deleted},
|
||||
core_user:update(UserId, UpdatedUser);
|
||||
Error ->
|
||||
Error
|
||||
end.
|
||||
|
||||
%%%===================================================================
|
||||
%%% Внутренние функции
|
||||
%%%===================================================================
|
||||
|
||||
apply_filters(Users, Filters) ->
|
||||
Role = maps:get(role, Filters, undefined),
|
||||
Status = maps:get(status, Filters, undefined),
|
||||
Q = maps:get(q, Filters, undefined),
|
||||
F1 = case Role of
|
||||
undefined -> Users;
|
||||
_ -> [U || U <- Users, U#user.role =:= Role]
|
||||
end,
|
||||
F2 = case Status of
|
||||
undefined -> F1;
|
||||
_ -> [U || U <- F1, U#user.status =:= Status]
|
||||
end,
|
||||
case Q of
|
||||
undefined -> F2;
|
||||
_ -> [U || U <- F2,
|
||||
string:str(binary_to_list(U#user.email), binary_to_list(Q)) > 0 orelse
|
||||
(U#user.nickname /= undefined andalso string:str(binary_to_list(U#user.nickname), binary_to_list(Q)) > 0)]
|
||||
end.
|
||||
|
||||
sort_users(Users, SortField, Order) ->
|
||||
Field = binary_to_existing_atom(SortField, utf8),
|
||||
Sorted = lists:sort(
|
||||
fun(A, B) ->
|
||||
ValA = user_field(A, Field),
|
||||
ValB = user_field(B, Field),
|
||||
if Order == <<"asc">> -> ValA =< ValB;
|
||||
true -> ValA >= ValB
|
||||
end
|
||||
end, Users),
|
||||
Sorted.
|
||||
|
||||
user_field(#user{created_at = V}, created_at) -> V;
|
||||
user_field(#user{email = V}, email) -> V;
|
||||
user_field(#user{role = V}, role) -> V;
|
||||
user_field(#user{status = V}, status) -> V;
|
||||
user_field(_, _) -> undefined.
|
||||
|
||||
validate_user_updates(Updates) ->
|
||||
lists:filter(fun validate_user_update/1, Updates).
|
||||
|
||||
validate_user_update({role, V}) when V =:= user; V =:= bot -> true;
|
||||
validate_user_update({status, V}) when V =:= active; V =:= frozen; V =:= deleted -> true;
|
||||
validate_user_update({reason, V}) when is_binary(V); V =:= undefined -> true;
|
||||
validate_user_update({nickname, V}) when is_binary(V); V =:= undefined -> true;
|
||||
validate_user_update({timezone, V}) when is_binary(V); V =:= undefined -> true;
|
||||
validate_user_update({language, V}) when is_binary(V); V =:= undefined -> true;
|
||||
validate_user_update({phone, V}) when is_binary(V); V =:= undefined -> true;
|
||||
validate_user_update({preferences, V}) when is_map(V); V =:= undefined -> true;
|
||||
validate_user_update(_) -> false.
|
||||
@@ -3,9 +3,37 @@
|
||||
|
||||
admin() ->
|
||||
Modules = [
|
||||
% ================== БАЗОВЫЕ ==================
|
||||
admin_handler_health,
|
||||
admin_handler_stats,
|
||||
admin_handler_login,
|
||||
% ================== ПОЛЬЗОВАТЕЛИ ==================
|
||||
admin_handler_users,
|
||||
admin_handler_user_by_id,
|
||||
% ================== СОБЫТИЯ ==================
|
||||
admin_handler_events,
|
||||
admin_handler_event_by_id
|
||||
%% другие админские обработчики с trails/0
|
||||
admin_handler_event_by_id,
|
||||
% ================== ОТЧЁТЫ ==================
|
||||
admin_handler_reports,
|
||||
admin_handler_report_by_id,
|
||||
% ================== ОТЗЫВЫ ==================
|
||||
admin_handler_reviews,
|
||||
admin_handler_reviews_by_id,
|
||||
% ================== БАН-СЛОВА ==================
|
||||
admin_handler_banned_words,
|
||||
% ================== ТИКЕТЫ ==================
|
||||
admin_handler_ticket_stats,
|
||||
admin_handler_ticket_by_id,
|
||||
admin_handler_tickets,
|
||||
% ================== ПОДПИСКИ ==================
|
||||
admin_handler_subscriptions,
|
||||
admin_handler_subscriptions_by_id,
|
||||
% ================== МОДЕРАЦИЯ (общий маршрут) ==================
|
||||
admin_handler_moderation,
|
||||
% ================== Управление ролями (только для superadmin) ==================
|
||||
admin_handler_me,
|
||||
admin_handler_admins,
|
||||
admin_handler_audit
|
||||
],
|
||||
lists:flatmap(fun trails_from_module/1, Modules).
|
||||
|
||||
|
||||
@@ -131,7 +131,7 @@ test() ->
|
||||
<<"error_message">> => <<"Test error">>,
|
||||
<<"stacktrace">> => <<"trace">>
|
||||
}),
|
||||
{ok, {{_, 201, _}, _, TicketResp}} = httpc:request(post, {AdminURL ++ "/v1/admin/tickets", [{"Authorization", "Bearer " ++ binary_to_list(AdminToken)}], "application/json", TicketBody}, [], []),
|
||||
{ok, {{_, 201, _}, _, TicketResp}} = httpc:request(post, {UserURL ++ "/v1/tickets", [{"Authorization", "Bearer " ++ binary_to_list(UserToken)}], "application/json", TicketBody}, [], []),
|
||||
#{<<"id">> := TicketId} = jsx:decode(list_to_binary(TicketResp), [return_maps]),
|
||||
ct:pal(" OK (TicketId: ~p)~n", [TicketId]),
|
||||
ct:pal("OK~n"),
|
||||
@@ -164,11 +164,8 @@ test() ->
|
||||
|
||||
%% TEST 18: Create subscription
|
||||
ct:pal(" TEST 18: Create subscription... "),
|
||||
SubBody = jsx:encode(#{
|
||||
<<"user_id">> => UserId,
|
||||
<<"plan">> => <<"monthly">>
|
||||
}),
|
||||
{ok, {{_, 201, _}, _, SubResp}} = httpc:request(post, {AdminURL ++ "/v1/admin/subscriptions", [{"Authorization", "Bearer " ++ binary_to_list(AdminToken)}], "application/json", SubBody}, [], []),
|
||||
SubBody = jsx:encode(#{action => <<"activate">>, plan => <<"monthly">>, payment_info => #{card => <<"4242">>}}),
|
||||
{ok, {{_, 201, _}, _, SubResp}} = httpc:request(post, {UserURL ++ "/v1/subscription", [{"Authorization", "Bearer " ++ binary_to_list(UserToken)}], "application/json", SubBody}, [], []),
|
||||
#{<<"id">> := SubId} = jsx:decode(list_to_binary(SubResp), [return_maps]),
|
||||
ct:pal("OK~n"),
|
||||
|
||||
|
||||
Reference in New Issue
Block a user