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

This commit is contained in:
2026-05-10 22:14:38 +03:00
parent a35d6f7acc
commit 6403f061df
46 changed files with 3082 additions and 2091 deletions

View File

@@ -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).

View File

@@ -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,

View File

@@ -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)).

View File

@@ -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}).

View File

@@ -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)

View File

@@ -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)

View File

@@ -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).

View File

@@ -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)

View File

@@ -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;

View File

@@ -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,

View File

@@ -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,

View File

@@ -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) ==================

View File

@@ -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">>
}.

View File

@@ -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">>
}.

View File

@@ -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">>
}.

View File

@@ -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, []}.

View File

@@ -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, []}.

View File

@@ -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}
}
}}}
}
}
}
].

View File

@@ -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">>}
}
}
].

View File

@@ -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])).

View File

@@ -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, []}.

View File

@@ -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, []}.

View File

@@ -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">>
}.

View File

@@ -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">>
}.

View File

@@ -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, []}.

View File

@@ -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, []}.

View File

@@ -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">>
}.

View 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.

View File

@@ -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.

View File

@@ -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, []}.

View File

@@ -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">>
}.

View File

@@ -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, []}.

View File

@@ -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">>
}.

View File

@@ -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.

View File

@@ -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, []}.

View File

@@ -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, []}.

View File

@@ -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, []}.

View 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
View 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
View 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.

View 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.

View File

@@ -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.

View File

@@ -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
View 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.

View File

@@ -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).

View File

@@ -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"),