Рефакторинг обработчиков. Часть 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). -module(core_admin).
-include("records.hrl"). -include("records.hrl").
-export([create/3, get_by_email/1, get_by_id/1, list_all/0, -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) -> create(Email, Password, Role) ->
case get_by_email(Email) of case get_by_email(Email) of
{ok, _} -> {ok, _} ->
{error, email_exists}; {error, email_exists};
{error, not_found} -> {error, not_found} ->
Id = generate_id(), Id = infra_utils:generate_id(16),
{ok, Hash} = argon2:hash(Password), {ok, Hash} = argon2:hash(Password),
Now = calendar:universal_time(), Now = calendar:universal_time(),
Admin = #admin{ Admin = #admin{
@@ -24,6 +25,22 @@ create(Email, Password, Role) ->
{ok, Admin} {ok, Admin}
end. 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) -> get_by_email(Email) ->
Match = #admin{email = Email, _ = '_'}, Match = #admin{email = Email, _ = '_'},
case mnesia:dirty_match_object(Match) of case mnesia:dirty_match_object(Match) of
@@ -73,5 +90,23 @@ update_status(Id, Status) ->
Error -> Error Error -> Error
end. 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) ->
log(AdminId, Email, Role, Action, EntityType, EntityId, Ip, undefined). log(AdminId, Email, Role, Action, EntityType, EntityId, Ip, undefined).
log(AdminId, Email, Role, Action, EntityType, EntityId, Ip, Reason) -> 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{ Entry = #admin_audit{
id = Id, id = Id,
admin_id = AdminId, admin_id = AdminId,

View File

@@ -10,7 +10,7 @@ list_banned_words() ->
mnesia:dirty_match_object(#banned_word{_ = '_'}). mnesia:dirty_match_object(#banned_word{_ = '_'}).
add_banned_word(Word, AddedBy) -> add_banned_word(Word, AddedBy) ->
Id = generate_id(), Id = infra_utils:generate_id(9),
Now = calendar:universal_time(), Now = calendar:universal_time(),
BW = #banned_word{id = Id, word = Word, added_by = AddedBy, added_at = Now}, BW = #banned_word{id = Id, word = Word, added_by = AddedBy, added_at = Now},
case mnesia:transaction(fun() -> case mnesia:transaction(fun() ->
@@ -48,7 +48,4 @@ update_banned_word(OldWord, NewWord) ->
end) of end) of
{atomic, {ok, UpdatedRec}} -> {ok, UpdatedRec}; {atomic, {ok, UpdatedRec}} -> {ok, UpdatedRec};
{aborted, not_found} -> {error, not_found} {aborted, not_found} -> {error, not_found}
end. 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([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([update_status/2, delete/1]).
-export([generate_id/0]).
-export([count_bookings/0]). -export([count_bookings/0]).
%% Создание бронирования %% Создание бронирования
create(EventId, UserId) -> create(EventId, UserId) ->
Id = generate_id(), Id = infra_utils:generate_id(16),
Booking = #booking{ Booking = #booking{
id = Id, id = Id,
event_id = EventId, event_id = EventId,
@@ -98,8 +97,4 @@ delete(Id) ->
{aborted, Reason} -> {error, Reason} {aborted, Reason} -> {error, Reason}
end. end.
count_bookings() -> mnesia:table_info(booking, size). 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). -module(core_calendar).
-include("records.hrl"). -include("records.hrl").
-export([create/4, create/5, get_by_id/1, list_by_owner/1, update/2, delete/1]). -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([count_calendars/0]).
-export([freeze/2, unfreeze/2]). % ← новые функции -export([freeze/2, unfreeze/2]). % ← новые функции
%% Создание календаря %% Создание календаря
create(OwnerId, Title, Description, Confirmation) -> create(OwnerId, Title, Description, Confirmation) ->
Id = generate_id(), Id = infra_utils:generate_id(16),
Calendar = #calendar{ Calendar = #calendar{
id = Id, id = Id,
owner_id = OwnerId, owner_id = OwnerId,
@@ -30,7 +29,7 @@ create(OwnerId, Title, Description, Confirmation) ->
%% Создание календаря с типом и политикой %% Создание календаря с типом и политикой
create(OwnerId, Title, Description, Confirmation, Type) -> create(OwnerId, Title, Description, Confirmation, Type) ->
Id = generate_id(), Id = infra_utils:generate_id(16),
Calendar = #calendar{ Calendar = #calendar{
id = Id, id = Id,
owner_id = OwnerId, owner_id = OwnerId,
@@ -94,10 +93,6 @@ freeze(Id, Reason) ->
unfreeze(Id, Reason) -> unfreeze(Id, Reason) ->
update(Id, [{status, active}, {reason, Reason}]). update(Id, [{status, active}, {reason, Reason}]).
%% Внутренние функции
generate_id() ->
base64:encode(crypto:strong_rand_bytes(16), #{mode => urlsafe, padding => false}).
apply_updates(Calendar, Updates) -> apply_updates(Calendar, Updates) ->
Updated = lists:foldl(fun({Field, Value}, C) -> Updated = lists:foldl(fun({Field, Value}, C) ->
set_field(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, -export([create/4, create_recurring/5, get_by_id/1, list_by_calendar/1,
update/2, delete/1, materialize_occurrence/3]). update/2, delete/1, materialize_occurrence/3]).
-export([generate_id/0]).
-export([count_events/0, count_events_by_date/2]). -export([count_events/0, count_events_by_date/2]).
-export([freeze/2, unfreeze/2]). -export([freeze/2, unfreeze/2]).
-export([list_all/0]). -export([list_all/0]).
%% Создание одиночного события %% Создание одиночного события
create(CalendarId, Title, StartTime, Duration) -> create(CalendarId, Title, StartTime, Duration) ->
Id = generate_id(), Id = infra_utils:generate_id(16),
Event = #event{ Event = #event{
id = Id, id = Id,
calendar_id = CalendarId, calendar_id = CalendarId,
@@ -46,7 +45,7 @@ create(CalendarId, Title, StartTime, Duration) ->
%% Создание повторяющегося события (мастер-запись) %% Создание повторяющегося события (мастер-запись)
create_recurring(CalendarId, Title, StartTime, Duration, RRule) -> create_recurring(CalendarId, Title, StartTime, Duration, RRule) ->
Id = generate_id(), Id = infra_utils:generate_id(16),
Event = #event{ Event = #event{
id = Id, id = Id,
calendar_id = CalendarId, calendar_id = CalendarId,
@@ -94,7 +93,7 @@ materialize_occurrence(MasterId, OccurrenceStart, SpecialistId) ->
case Existing of case Existing of
[] -> [] ->
% Создаём новый экземпляр % Создаём новый экземпляр
InstanceId = generate_id(), InstanceId = infra_utils:generate_id(16),
Instance = #event{ Instance = #event{
id = InstanceId, id = InstanceId,
calendar_id = Master#event.calendar_id, calendar_id = Master#event.calendar_id,
@@ -193,10 +192,6 @@ count_events_by_date(From, To) ->
date_part({{Y,M,D}, _}) -> {Y,M,D}. 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) -> apply_updates(Event, Updates) ->
Updated = lists:foldl(fun({Field, Value}, E) -> Updated = lists:foldl(fun({Field, Value}, E) ->
set_field(Field, Value, E) set_field(Field, Value, E)

View File

@@ -6,10 +6,11 @@
-export([generate_id/0]). -export([generate_id/0]).
-export([count_reports_by_status/1, count_reports_by_admin/2]). -export([count_reports_by_status/1, count_reports_by_admin/2]).
-export([count_reports_resolved_by_admin/2, avg_resolution_time/1]). -export([count_reports_resolved_by_admin/2, avg_resolution_time/1]).
-export([delete/1, update/2]). % <-- добавлено
%% Создание жалобы %% Создание жалобы
create(ReporterId, TargetType, TargetId, Reason) -> create(ReporterId, TargetType, TargetId, Reason) ->
Id = generate_id(), Id = infra_utils:generate_id(16),
Report = #report{ Report = #report{
id = Id, id = Id,
reporter_id = ReporterId, reporter_id = ReporterId,
@@ -109,6 +110,41 @@ avg_resolution_time(Status) ->
TotalSeconds / length(Resolved) / 3600.0 TotalSeconds / length(Resolved) / 3600.0
end. 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() -> generate_id() ->
base64:encode(crypto:strong_rand_bytes(16), #{mode => urlsafe, padding => false}). 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, -export([create/5, get_by_id/1, list_by_target/2, list_by_user/1,
update/2, delete/1, hide/2, unhide/2]). update/2, delete/1, hide/2, unhide/2]).
-export([get_average_rating/2, has_user_reviewed/3]). -export([get_average_rating/2, has_user_reviewed/3]).
-export([generate_id/0]).
-export([count_reviews/0, list_all/0]). -export([count_reviews/0, list_all/0]).
%% Создание отзыва %% Создание отзыва
create(UserId, TargetType, TargetId, Rating, Comment) -> create(UserId, TargetType, TargetId, Rating, Comment) ->
Id = generate_id(), Id = infra_utils:generate_id(16),
Review = #review{ Review = #review{
id = Id, id = Id,
user_id = UserId, user_id = UserId,
@@ -117,10 +116,6 @@ count_reviews() -> mnesia:table_info(review, size).
list_all() -> mnesia:dirty_match_object(#review{_ = '_'}). list_all() -> mnesia:dirty_match_object(#review{_ = '_'}).
%% Внутренние функции
generate_id() ->
base64:encode(crypto:strong_rand_bytes(16), #{mode => urlsafe, padding => false}).
apply_updates(Review, Updates) -> apply_updates(Review, Updates) ->
Updated = lists:foldl(fun({Field, Value}, R) -> Updated = lists:foldl(fun({Field, Value}, R) ->
set_field(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([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([update_status/2, check_expired/0]).
-export([generate_id/0]).
% --------------- новые обёртки для админки ------------------ % --------------- новые обёртки для админки ------------------
-export([list_subscriptions/0, -export([list_subscriptions/0,
create_subscription/1, create_subscription/1,
@@ -16,7 +15,7 @@
%% Создание подписки %% Создание подписки
create(UserId, Plan, TrialUsed) -> create(UserId, Plan, TrialUsed) ->
Id = generate_id(), Id = infra_utils:generate_id(16),
Now = calendar:universal_time(), Now = calendar:universal_time(),
{StartDate, EndDate} = case TrialUsed of {StartDate, EndDate} = case TrialUsed of
@@ -129,10 +128,6 @@ downgrade_user_calendars(UserId) ->
core_calendar:update(Cal#calendar.id, [{type, personal}]) core_calendar:update(Cal#calendar.id, [{type, personal}])
end, Calendars). end, Calendars).
%% Внутренние функции
generate_id() ->
base64:encode(crypto:strong_rand_bytes(16), #{mode => urlsafe, padding => false}).
plan_to_months(monthly) -> 1; plan_to_months(monthly) -> 1;
plan_to_months(quarterly) -> 3; plan_to_months(quarterly) -> 3;
plan_to_months(biannual) -> 6; plan_to_months(biannual) -> 6;

View File

@@ -48,7 +48,7 @@ stats() ->
%% ── новые функции ────────────────────────────────────── %% ── новые функции ──────────────────────────────────────
create_ticket(Data) -> 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(), Now = calendar:universal_time(),
Ticket = #ticket{ Ticket = #ticket{
id = Id, id = Id,
@@ -103,4 +103,4 @@ apply_updates(Ticket, Updates) ->
<<"context">> -> Acc#ticket{context = Value}; <<"context">> -> Acc#ticket{context = Value};
_ -> Acc _ -> Acc
end end
end, Ticket, maps:to_list(Updates)). end, Ticket, maps:to_list(Updates)).

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([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([email_exists/1]).
-export([generate_id/0]).
-export([list_users/0]). -export([list_users/0]).
-export([block/2, unblock/2]). -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]). -export([create_bot/2, delete_bot/1]).
%% Создание пользователя %% Создание пользователя
@@ -16,7 +15,7 @@ create(Email, Password) ->
true -> true ->
{error, email_exists}; {error, email_exists};
false -> false ->
Id = generate_id(), Id = infra_utils:generate_id(16),
{ok, PasswordHash} = logic_auth:hash_password(Password), {ok, PasswordHash} = logic_auth:hash_password(Password),
User = #user{ User = #user{
@@ -150,6 +149,10 @@ unblock(Id, Reason) ->
count_users() -> count_users() ->
mnesia:table_info(user, size). mnesia:table_info(user, size).
%% Административный список (все пользователи, без фильтрации)
list_all() ->
mnesia:dirty_match_object(#user{_ = '_'}).
count_users_by_date(From, To) -> count_users_by_date(From, To) ->
All = mnesia:dirty_match_object(#user{_ = '_'}), All = mnesia:dirty_match_object(#user{_ = '_'}),
Filtered = lists:filter(fun(U) -> Filtered = lists:filter(fun(U) ->
@@ -166,10 +169,6 @@ count_users_by_date(From, To) ->
date_part({{Y,M,D}, _}) -> {Y,M,D}. 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) -> apply_updates(User, Updates) ->
Updated = lists:foldl(fun({Field, Value}, U) -> Updated = lists:foldl(fun({Field, Value}, U) ->
set_field(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 case mnesia:dirty_index_read(user, Email, email) of
[] -> [] ->
{ok, PasswordHash} = logic_auth:hash_password(Password), {ok, PasswordHash} = logic_auth:hash_password(Password),
Id = generate_id(), Id = infra_utils:generate_id(16),
User = #user{ User = #user{
id = Id, id = Id,
email = Email, email = Email,

View File

@@ -87,7 +87,7 @@ start_http() ->
{"/v1/tickets", handler_tickets, []}, {"/v1/tickets", handler_tickets, []},
{"/v1/tickets/:id", handler_ticket_by_id, []}, {"/v1/tickets/:id", handler_ticket_by_id, []},
{"/v1/subscription", handler_subscription, []} {"/v1/subscription", handler_subscription, []}
]} ]} %% 23
]), ]),
Middlewares = [cowboy_router, cowboy_handler], Middlewares = [cowboy_router, cowboy_handler],
Env = #{dispatch => Dispatch}, Env = #{dispatch => Dispatch},
@@ -126,7 +126,7 @@ start_admin_http() ->
{"/v1/admin/tickets", admin_handler_tickets, []}, {"/v1/admin/tickets", admin_handler_tickets, []},
% ================== ПОДПИСКИ ================== % ================== ПОДПИСКИ ==================
{"/v1/admin/subscriptions", admin_handler_subscriptions, []}, {"/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, []}, {"/v1/admin/:target_type/:id", admin_handler_moderation, []},
% ================== Управление ролями (только для superadmin) ================== % ================== Управление ролями (только для superadmin) ==================

View File

@@ -1,117 +1,103 @@
-module(admin_handler_admins). -module(admin_handler_admins).
-behaviour(cowboy_handler). -behaviour(cowboy_handler).
-include("records.hrl").
-export([init/2]). -export([init/2]).
-export([trails/0]).
-include("records.hrl").
init(Req, _Opts) -> init(Req, _Opts) ->
case cowboy_req:method(Req) of case cowboy_req:method(Req) of
<<"GET">> -> list_admins(Req); <<"GET">> -> list_admins(Req);
<<"POST">> -> create_admin(Req); _ -> handler_utils:send_error(Req, 405, <<"Method not allowed">>)
<<"PUT">> -> update_admin_role(Req);
_ -> send_error(Req, 405, <<"Method not allowed">>)
end. end.
list_admins(Req) -> trails() ->
case handler_auth:authenticate(Req) of [
{ok, AdminId, Req1} -> #{
case admin_utils:check_role(AdminId, superadmin) of path => <<"/v1/admin/admins">>,
true -> method => <<"GET">>,
Admins = core_admin:list_all(), description => <<"List all admins (superadmin only)">>,
Json = [admin_to_json(A) || A <- Admins], tags => [<<"Admins">>],
send_json(Req1, 200, Json); parameters => [
false -> #{name => <<"role">>, in => <<"query">>, schema => #{type => string}},
send_error(Req1, 403, <<"Superadmin access required">>) #{name => <<"status">>, in => <<"query">>, schema => #{type => string}},
end; #{name => <<"limit">>, in => <<"query">>, schema => #{type => integer}},
{error, Code, Message, Req1} -> #{name => <<"offset">>, in => <<"query">>, schema => #{type => integer}}
send_error(Req1, Code, Message) ],
end. responses => #{
200 => #{
description => <<"Array of admins">>,
content => #{<<"application/json">> => #{schema => #{
type => array,
items => admin_schema()
}}}
}
}
}
].
create_admin(Req) -> admin_schema() ->
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) ->
#{ #{
id => A#admin.id, type => object,
email => A#admin.email, properties => #{
role => A#admin.role, id => #{type => string},
status => A#admin.status, email => #{type => string, format => <<"email">>},
created_at => datetime_to_iso8601(A#admin.created_at), role => #{type => string, enum => [<<"superadmin">>, <<"admin">>, <<"moderator">>, <<"support">>]},
updated_at => datetime_to_iso8601(A#admin.updated_at) 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}}) -> list_admins(Req) ->
iolist_to_binary(io_lib:format("~4..0B-~2..0B-~2..0BT~2..0B:~2..0B:~2..0BZ", [Y,M,D,H,Min,S])); case handler_utils:auth_admin(Req) of
datetime_to_iso8601(_) -> null. {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) -> parse_admin_filters(Req) ->
Headers = #{ Qs = cowboy_req:parse_qs(Req),
<<"content-type">> => <<"application/json">>, #{
<<"access-control-allow-origin">> => <<"*">>, role => proplists:get_value(<<"role">>, Qs),
<<"access-control-expose-headers">> => <<"Content-Range">> status => proplists:get_value(<<"status">>, Qs)
}, }.
Body = jsx:encode(Data),
cowboy_req:reply(Status, Headers, Body, Req),
{ok, Body, []}.
send_error(Req, Code, Message) -> admin_to_json(Admin) ->
Body = jsx:encode(#{error => Message}), #{
Req2 = cowboy_req:reply(Code, #{<<"content-type">> => <<"application/json">>}, Body, Req), id => Admin#admin.id,
{ok, Req2, []}. 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). -module(admin_handler_audit).
-behaviour(cowboy_handler). -behaviour(cowboy_handler).
-export([init/2]).
-export([trails/0]).
-include("records.hrl"). -include("records.hrl").
-export([init/2]). %%% cowboy_handler callback
-spec init(cowboy_req:req(), any()) -> {ok, cowboy_req:req(), any()}.
init(Req, _Opts) -> init(Req, _Opts) ->
case cowboy_req:method(Req) of case cowboy_req:method(Req) of
<<"GET">> -> <<"GET">> -> list_audit(Req);
case handler_auth:authenticate(Req) of _ -> handler_utils:send_error(Req, 405, <<"Method not allowed">>)
{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">>)
end. end.
parse_filters(Req) -> %%% Swagger metadata
Qs = cowboy_req:parse_qs(Req), -spec trails() -> [map()].
lists:filtermap(fun trails() ->
({<<"admin_id">>, Val}) -> {true, {admin_id, Val}}; [
({<<"action">>, Val}) -> {true, {action, Val}}; #{
(_) -> false path => <<"/v1/admin/audit">>,
end, Qs). method => <<"GET">>,
description => <<"List audit records (admin)">>,
tags => [<<"Audit">>],
parameters => [
#{name => <<"admin_id">>, in => <<"query">>, schema => #{type => string}, description => <<"Filter by admin ID">>},
#{name => <<"action">>, in => <<"query">>, schema => #{type => string}, description => <<"Filter by action">>},
#{name => <<"entity_type">>, in => <<"query">>, schema => #{type => string}, description => <<"Filter by entity type">>},
#{name => <<"from">>, in => <<"query">>, schema => #{type => string, format => <<"date-time">>}, description => <<"Start timestamp (ISO8601)">>},
#{name => <<"to">>, in => <<"query">>, schema => #{type => string, format => <<"date-time">>}, description => <<"End timestamp (ISO8601)">>},
#{name => <<"limit">>, in => <<"query">>, schema => #{type => integer}, description => <<"Page size">>},
#{name => <<"offset">>, in => <<"query">>, schema => #{type => integer}, description => <<"Offset">>}
],
responses => #{
200 => #{
description => <<"Array of audit records">>,
content => #{<<"application/json">> => #{schema => #{
type => array,
items => audit_schema()
}}}
}
}
}
].
audit_to_json(E) -> audit_schema() ->
#{ #{
id => E#admin_audit.id, type => object,
admin_id => E#admin_audit.admin_id, properties => #{
email => E#admin_audit.email, id => #{type => string},
role => E#admin_audit.role, admin_id => #{type => string},
action => E#admin_audit.action, email => #{type => string, format => <<"email">>},
entity_type => E#admin_audit.entity_type, role => #{type => string},
entity_id => E#admin_audit.entity_id, action => #{type => string},
timestamp => datetime_to_iso8601(E#admin_audit.timestamp), entity_type => #{type => string},
ip => E#admin_audit.ip, entity_id => #{type => string},
reason => E#admin_audit.reason timestamp => #{type => string, format => <<"date-time">>},
ip => #{type => string},
reason => #{type => string, nullable => true}
}
}. }.
datetime_to_iso8601({{Y,M,D},{H,Min,S}}) -> %%% Internal functions
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) -> %% @doc Получить список записей аудита с пагинацией и фильтрацией.
Body = jsx:encode(Data), -spec list_audit(cowboy_req:req()) -> {ok, binary(), cowboy_req:req()}.
Req2 = cowboy_req:reply(Status, #{<<"content-type">> => <<"application/json">>}, Body, Req), list_audit(Req) ->
{ok, Req2, []}. 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) -> %% @private Извлечь фильтры из query string.
Body = jsx:encode(#{error => Message}), -spec parse_audit_filters(cowboy_req:req()) -> map().
Req2 = cowboy_req:reply(Code, #{<<"content-type">> => <<"application/json">>}, Body, Req), parse_audit_filters(Req) ->
{ok, Req2, []}. 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). -module(admin_handler_banned_words).
-behaviour(cowboy_handler). -behaviour(cowboy_handler).
-export([init/2]). -export([init/2]).
-export([trails/0]).
-include("records.hrl"). -include("records.hrl").
-spec init(cowboy_req:req(), any()) -> {ok, cowboy_req:req(), any()}.
init(Req, _Opts) -> 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 case cowboy_req:method(Req) of
<<"GET">> -> list_banned_words(Req); <<"GET">> -> list_words(Req);
<<"POST">> -> add_banned_word(Req); <<"POST">> -> add_word(Req);
_ -> send_error(Req, 405, <<"Method not allowed">>) <<"DELETE">> -> delete_word(Req);
_ -> handler_utils:send_error(Req, 405, <<"Method not allowed">>)
end. end.
handle_item(Word, Req) -> -spec trails() -> [map()].
case cowboy_req:method(Req) of trails() ->
<<"DELETE">> -> delete_banned_word(Word, Req); [
<<"PUT">> -> update_banned_word(Word, Req); #{ % GET list
_ -> send_error(Req, 405, <<"Method not allowed">>) path => <<"/v1/admin/banned-words">>,
end. method => <<"GET">>,
description => <<"List all banned words (admin)">>,
tags => [<<"Banned Words">>],
parameters => [
#{name => <<"limit">>, in => <<"query">>, schema => #{type => integer}, description => <<"Page size">>},
#{name => <<"offset">>, in => <<"query">>, schema => #{type => integer}, description => <<"Offset">>}
],
responses => #{
200 => #{
description => <<"Array of banned words">>,
content => #{<<"application/json">> => #{schema => #{
type => array,
items => banned_word_schema()
}}}
}
}
},
#{ % POST add
path => <<"/v1/admin/banned-words">>,
method => <<"POST">>,
description => <<"Add a new banned word">>,
tags => [<<"Banned Words">>],
requestBody => #{
required => true,
content => #{<<"application/json">> => #{schema => #{
type => object,
required => [<<"word">>],
properties => #{
word => #{type => string}
}
}}}
},
responses => #{
201 => #{description => <<"Word added">>},
409 => #{description => <<"Word already exists">>}
}
},
#{ % DELETE by word
path => <<"/v1/admin/banned-words/:word">>,
method => <<"DELETE">>,
description => <<"Remove a banned word">>,
tags => [<<"Banned Words">>],
parameters => [
#{
name => <<"word">>,
in => <<"path">>,
description => <<"The word to remove">>,
required => true,
schema => #{type => string}
}
],
responses => #{
200 => #{description => <<"Word removed">>},
404 => #{description => <<"Word not found">>}
}
}
].
%% ================== GET /banned-words ================== banned_word_schema() ->
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) ->
#{ #{
id => BW#banned_word.id, type => object,
word => BW#banned_word.word, properties => #{
added_by => BW#banned_word.added_by, id => #{type => string},
added_at => datetime_to_iso8601(BW#banned_word.added_at) 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}}) -> 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", iolist_to_binary(io_lib:format("~4..0B-~2..0B-~2..0BT~2..0B:~2..0B:~2..0BZ",
[Year, Month, Day, Hour, Minute, Second])); [Year, Month, Day, Hour, Minute, Second])).
datetime_to_iso8601(undefined) -> undefined.
%% ================== HTTP-ответы ================== pagination_headers(#{limit := Limit, offset := Offset}, Total) ->
send_json(Req, Status, Data) -> 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])),
<<"access-control-allow-origin">> => <<"*">>, <<"x-total-count">> => integer_to_binary(Total),
<<"access-control-expose-headers">> => <<"Content-Range">> <<"access-control-expose-headers">> => <<"Content-Range, X-Total-Count">>
}, }.
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

@@ -15,73 +15,58 @@ init(Req, _Opts) ->
<<"GET">> -> get_event(Req); <<"GET">> -> get_event(Req);
<<"PUT">> -> update_event(Req); <<"PUT">> -> update_event(Req);
<<"DELETE">> -> delete_event(Req); <<"DELETE">> -> delete_event(Req);
_ -> send_error(Req, 405, <<"Method not allowed">>) _ -> handler_utils:send_error(Req, 405, <<"Method not allowed">>)
end. end.
%%%=================================================================== %%%===================================================================
%%% Swagger / Trails metadata %%% Swagger metadata
%%%=================================================================== %%%===================================================================
trails() -> trails() ->
Path = <<"/v1/admin/events/:id">>,
BaseParams = [ BaseParams = [
#{ #{
name => <<"id">>, name => <<"id">>,
in => <<"path">>, in => <<"path">>,
description => <<"Event ID">>, description => <<"Event ID">>,
required => true, required => true,
schema => #{type => string} schema => #{type => string}
} }
], ],
[ [
%% GET #{ % GET
#{ path => <<"/v1/admin/events/:id">>,
path => Path, method => <<"GET">>,
method => <<"GET">>,
handler => ?MODULE,
tags => [<<"Events: id">>],
description => <<"Get event by ID (admin)">>, description => <<"Get event by ID (admin)">>,
parameters => BaseParams, tags => [<<"Events">>],
responses => #{ parameters => BaseParams,
responses => #{
200 => #{ 200 => #{
description => <<"Event details">>, description => <<"Event details">>,
content => #{ content => #{<<"application/json">> => #{schema => event_schema()}}
<<"application/json">> => #{
schema => event_schema()
}
}
} }
} }
}, },
%% PUT #{ % PUT
#{ path => <<"/v1/admin/events/:id">>,
path => Path, method => <<"PUT">>,
method => <<"PUT">>,
handler => ?MODULE,
tags => [<<"Events: id">>],
description => <<"Update event (admin)">>, description => <<"Update event (admin)">>,
parameters => BaseParams, tags => [<<"Events">>],
parameters => BaseParams,
requestBody => #{ requestBody => #{
required => true, required => true,
content => #{ content => #{<<"application/json">> => #{schema => event_update_schema()}}
<<"application/json">> => #{
schema => event_update_schema()
}
}
}, },
responses => #{ responses => #{
200 => #{description => <<"Updated event">>} 200 => #{description => <<"Updated event">>}
} }
}, },
%% DELETE #{ % DELETE
#{ path => <<"/v1/admin/events/:id">>,
path => Path, method => <<"DELETE">>,
method => <<"DELETE">>,
handler => ?MODULE,
tags => [<<"Events: id">>],
description => <<"Soft-delete event (admin)">>, description => <<"Soft-delete event (admin)">>,
parameters => BaseParams, tags => [<<"Events">>],
responses => #{ parameters => BaseParams,
responses => #{
200 => #{description => <<"Event status set to deleted">>} 200 => #{description => <<"Event status set to deleted">>}
} }
} }
@@ -89,52 +74,55 @@ trails() ->
event_schema() -> event_schema() ->
#{ #{
type => object, type => object,
properties => #{ properties => #{
id => #{type => string}, id => #{type => string},
calendar_id => #{type => string}, calendar_id => #{type => string},
title => #{type => string}, title => #{type => string},
description => #{type => string}, description => #{type => string},
event_type => #{type => string, enum => [<<"single">>, <<"recurring">>]}, event_type => #{type => string, enum => [<<"single">>, <<"recurring">>]},
start_time => #{type => string, format => <<"date-time">>}, start_time => #{type => string, format => <<"date-time">>},
duration => #{type => integer}, duration => #{type => integer},
recurrence => #{type => object, nullable => true}, recurrence => #{type => object, nullable => true},
master_id => #{type => string, nullable => true}, master_id => #{type => string, nullable => true},
is_instance => #{type => boolean}, is_instance => #{type => boolean},
specialist_id => #{type => string, nullable => true}, specialist_id => #{type => string, nullable => true},
location => #{type => object, nullable => true}, location => #{type => object, nullable => true},
tags => #{type => array, items => #{type => string}}, tags => #{type => array, items => #{type => string}},
capacity => #{type => integer, nullable => true}, capacity => #{type => integer, nullable => true},
online_link => #{type => string, nullable => true}, online_link => #{type => string, nullable => true},
status => #{type => string, enum => [<<"active">>, <<"cancelled">>, <<"completed">>]}, status => #{type => string, enum => [<<"active">>, <<"cancelled">>, <<"completed">>]},
rating_avg => #{type => number, format => float}, reason => #{type => string, nullable => true},
rating_count => #{type => integer}, rating_avg => #{type => number, format => float},
created_at => #{type => string, format => <<"date-time">>}, rating_count => #{type => integer},
updated_at => #{type => string, format => <<"date-time">>} attachments => #{type => array, items => #{type => string}, nullable => true},
edit_history => #{type => array, items => #{type => object}, nullable => true},
created_at => #{type => string, format => <<"date-time">>},
updated_at => #{type => string, format => <<"date-time">>}
} }
}. }.
event_update_schema() -> event_update_schema() ->
#{ #{
type => object, type => object,
properties => #{ properties => #{
title => #{type => string}, title => #{type => string},
description => #{type => string}, description => #{type => string},
start_time => #{type => string, format => <<"date-time">>}, start_time => #{type => string, format => <<"date-time">>},
duration => #{type => integer}, duration => #{type => integer},
status => #{type => string, enum => [<<"active">>, <<"cancelled">>, <<"completed">>]}, status => #{type => string, enum => [<<"active">>, <<"cancelled">>, <<"completed">>]},
specialist_id => #{type => string}, specialist_id => #{type => string},
location => #{ location => #{
type => object, type => object,
properties => #{ properties => #{
address => #{type => string}, address => #{type => string},
lat => #{type => number, format => float}, lat => #{type => number, format => float},
lon => #{type => number, format => float} lon => #{type => number, format => float}
} }
}, },
tags => #{type => array, items => #{type => string}}, tags => #{type => array, items => #{type => string}},
capacity => #{type => integer}, capacity => #{type => integer},
online_link => #{type => string} online_link => #{type => string}
} }
}. }.
@@ -142,26 +130,24 @@ event_update_schema() ->
%%% Internal functions %%% Internal functions
%%%=================================================================== %%%===================================================================
%% GET /v1/admin/events/:id
get_event(Req) -> get_event(Req) ->
case auth_admin(Req) of case handler_utils:auth_admin(Req) of
{ok, _AdminId, Req1} -> {ok, _AdminId, Req1} ->
EventId = cowboy_req:binding(id, Req1), EventId = cowboy_req:binding(id, Req1),
case logic_event:get_event_admin(EventId) of case logic_event:get_event_admin(EventId) of
{ok, Event} -> {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} -> {error, not_found} ->
send_error(Req1, 404, <<"Event not found">>); handler_utils:send_error(Req1, 404, <<"Event not found">>);
{error, _} -> {error, _} ->
send_error(Req1, 500, <<"Internal server error">>) handler_utils:send_error(Req1, 500, <<"Internal server error">>)
end; end;
{error, Code, Msg, Req1} -> {error, Code, Msg, Req1} ->
send_error(Req1, Code, Msg) handler_utils:send_error(Req1, Code, Msg)
end. end.
%% PUT /v1/admin/events/:id
update_event(Req) -> update_event(Req) ->
case auth_admin(Req) of case handler_utils:auth_admin(Req) of
{ok, _AdminId, Req1} -> {ok, _AdminId, Req1} ->
EventId = cowboy_req:binding(id, Req1), EventId = cowboy_req:binding(id, Req1),
{ok, Body, Req2} = cowboy_req:read_body(Req1), {ok, Body, Req2} = cowboy_req:read_body(Req1),
@@ -171,171 +157,66 @@ update_event(Req) ->
UpdatesWithTypes = convert_fields(Updates), UpdatesWithTypes = convert_fields(Updates),
case logic_event:update_event_admin(EventId, UpdatesWithTypes) of case logic_event:update_event_admin(EventId, UpdatesWithTypes) of
{ok, Event} -> {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} -> {error, not_found} ->
send_error(Req2, 404, <<"Event not found">>); handler_utils:send_error(Req2, 404, <<"Event not found">>);
{error, _} -> {error, _} ->
send_error(Req2, 500, <<"Internal server error">>) handler_utils:send_error(Req2, 500, <<"Internal server error">>)
end; end;
_ -> _ ->
send_error(Req2, 400, <<"Invalid JSON">>) handler_utils:send_error(Req2, 400, <<"Invalid JSON">>)
catch catch
_:_ -> send_error(Req1, 400, <<"Invalid JSON format">>) _:_ -> handler_utils:send_error(Req1, 400, <<"Invalid JSON format">>)
end; end;
{error, Code, Msg, Req1} -> {error, Code, Msg, Req1} ->
send_error(Req1, Code, Msg) handler_utils:send_error(Req1, Code, Msg)
end. end.
%% DELETE /v1/admin/events/:id
delete_event(Req) -> delete_event(Req) ->
case auth_admin(Req) of case handler_utils:auth_admin(Req) of
{ok, _AdminId, Req1} -> {ok, _AdminId, Req1} ->
EventId = cowboy_req:binding(id, Req1), EventId = cowboy_req:binding(id, Req1),
case logic_event:delete_event_admin(EventId) of case logic_event:delete_event_admin(EventId) of
{ok, _} -> {ok, _} ->
send_json(Req1, 200, #{status => <<"deleted">>}); handler_utils:send_json(Req1, 200, #{status => <<"deleted">>});
{error, not_found} -> {error, not_found} ->
send_error(Req1, 404, <<"Event not found">>); handler_utils:send_error(Req1, 404, <<"Event not found">>);
{error, _} -> {error, _} ->
send_error(Req1, 500, <<"Internal server error">>) handler_utils:send_error(Req1, 500, <<"Internal server error">>)
end; end;
{error, Code, Msg, Req1} -> {error, Code, Msg, Req1} ->
send_error(Req1, Code, Msg) handler_utils:send_error(Req1, Code, Msg)
end. 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) -> convert_fields(Updates) ->
lists:map(fun convert_field/1, Updates). lists:map(fun convert_field/1, Updates).
convert_field({<<"title">>, Val}) -> {title, Val}; convert_field({<<"title">>, Val}) -> {title, Val};
convert_field({<<"description">>, Val}) -> {description, Val}; convert_field({<<"description">>, Val}) -> {description, Val};
convert_field({<<"event_type">>, Val}) -> {event_type, Val}; convert_field({<<"event_type">>, Val}) -> {event_type, Val};
convert_field({<<"start_time">>, Val}) -> convert_field({<<"start_time">>, Val}) ->
case parse_datetime(Val) of case handler_utils:parse_datetime(Val) of
{ok, Dt} -> {start_time, Dt}; {ok, Dt} -> {start_time, Dt};
_ -> {start_time, Val} _ -> {start_time, Val}
end; end;
convert_field({<<"duration">>, Val}) -> {duration, Val}; convert_field({<<"duration">>, Val}) -> {duration, Val};
convert_field({<<"recurrence">>, Val}) -> convert_field({<<"recurrence">>, Val}) -> {recurrence_rule, jsx:encode(Val)};
RuleJson = jsx:encode(Val),
{recurrence_rule, RuleJson};
convert_field({<<"specialist_id">>, Val}) -> {specialist_id, Val}; convert_field({<<"specialist_id">>, Val}) -> {specialist_id, Val};
convert_field({<<"location">>, Val}) when is_map(Val) -> convert_field({<<"location">>, Val}) when is_map(Val) ->
Loc = #location{ Loc = #location{
address = maps:get(<<"address">>, Val, undefined), address = maps:get(<<"address">>, Val, undefined),
lat = maps:get(<<"lat">>, Val, undefined), lat = maps:get(<<"lat">>, Val, undefined),
lon = maps:get(<<"lon">>, Val, undefined) lon = maps:get(<<"lon">>, Val, undefined)
}, },
{location, Loc}; {location, Loc};
convert_field({<<"location">>, Val}) -> {location, Val}; convert_field({<<"location">>, Val}) -> {location, Val};
convert_field({<<"tags">>, Val}) -> {tags, Val}; convert_field({<<"tags">>, Val}) -> {tags, Val};
convert_field({<<"capacity">>, Val}) -> {capacity, Val}; convert_field({<<"capacity">>, Val}) -> {capacity, Val};
convert_field({<<"online_link">>, Val}) -> {online_link, Val}; convert_field({<<"online_link">>, Val}) -> {online_link, Val};
convert_field({<<"status">>, Val}) -> convert_field({<<"status">>, Val}) ->
try binary_to_existing_atom(Val, utf8) of try binary_to_existing_atom(Val, utf8) of
Atom -> {status, Atom} Atom -> {status, Atom}
catch catch
error:badarg -> {status, Val} error:badarg -> {status, Val}
end; end;
convert_field(Other) -> Other. 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,50 +7,45 @@
-include("records.hrl"). -include("records.hrl").
%%%=================================================================== %%%===================================================================
%%% cowboy_handler callbacks %%% cowboy_handler callback
%%%=================================================================== %%%===================================================================
init(Req, _Opts) -> init(Req, _Opts) ->
case cowboy_req:method(Req) of case cowboy_req:method(Req) of
<<"GET">> -> list_all_events(Req); <<"GET">> -> list_all_events(Req);
_ -> send_error(Req, 405, <<"Method not allowed">>) _ -> handler_utils:send_error(Req, 405, <<"Method not allowed">>)
end. end.
%%%=================================================================== %%%===================================================================
%%% Swagger / Trails metadata %%% Swagger metadata
%%%=================================================================== %%%===================================================================
trails() -> trails() ->
[ [
#{ #{
path => <<"/v1/admin/events">>, path => <<"/v1/admin/events">>,
method => <<"GET">>, method => <<"GET">>,
handler => ?MODULE,
tags => [<<"Events">>],
description => <<"Search and list events (admin)">>, description => <<"Search and list events (admin)">>,
parameters => [ tags => [<<"Events">>],
#{name => <<"from">>, in => <<"query">>, description => <<"ISO8601 start datetime">>, required => false, schema => #{type => string}}, parameters => [
#{name => <<"to">>, in => <<"query">>, description => <<"ISO8601 end datetime">>, required => false, schema => #{type => string}}, #{name => <<"from">>, in => <<"query">>, description => <<"ISO8601 start datetime">>, required => false, schema => #{type => string}},
#{name => <<"status">>, in => <<"query">>, description => <<"active, cancelled, completed, or all">>, required => false, schema => #{type => string}}, #{name => <<"to">>, in => <<"query">>, description => <<"ISO8601 end datetime">>, required => false, schema => #{type => string}},
#{name => <<"calendar_id">>, in => <<"query">>, description => <<"Filter by calendar ID">>, required => false, schema => #{type => string}}, #{name => <<"status">>, in => <<"query">>, description => <<"active, cancelled, completed, or all">>, required => false, schema => #{type => string}},
#{name => <<"title">>, in => <<"query">>, description => <<"Exact title match">>, required => false, schema => #{type => string}}, #{name => <<"calendar_id">>, in => <<"query">>, description => <<"Filter by calendar ID">>, required => false, schema => #{type => string}},
#{name => <<"q">>, in => <<"query">>, description => <<"Substring search in title/description">>, required => false, schema => #{type => string}}, #{name => <<"title">>, in => <<"query">>, description => <<"Exact title match">>, required => false, schema => #{type => string}},
#{name => <<"limit">>, in => <<"query">>, description => <<"Page size (max 200)">>, required => false, schema => #{type => integer}}, #{name => <<"q">>, in => <<"query">>, description => <<"Substring search in title/description">>, required => false, schema => #{type => string}},
#{name => <<"offset">>, in => <<"query">>, description => <<"Offset">>, required => false, schema => #{type => integer}}, #{name => <<"limit">>, in => <<"query">>, description => <<"Page size (max 200)">>, required => false, schema => #{type => integer}},
#{name => <<"sort">>, in => <<"query">>, description => <<"created_at, start_time, title, status">>, required => false, schema => #{type => string}}, #{name => <<"offset">>, in => <<"query">>, description => <<"Offset">>, required => false, schema => #{type => integer}},
#{name => <<"order">>, in => <<"query">>, description => <<"asc or desc">>, required => false, schema => #{type => string, enum => [<<"asc">>, <<"desc">>]}} #{name => <<"sort">>, in => <<"query">>, description => <<"created_at, start_time, title, status">>, required => false, schema => #{type => string}},
#{name => <<"order">>, in => <<"query">>, description => <<"asc or desc">>, required => false, schema => #{type => string, enum => [<<"asc">>, <<"desc">>]}}
], ],
responses => #{ responses => #{
200 => #{ 200 => #{
description => <<"Array of events with Content-Range header">>, description => <<"Array of events with Content-Range header">>,
content => #{ content => #{<<"application/json">> => #{schema => #{
<<"application/json">> => #{ type => array,
schema => #{ items => event_schema()
type => array, }}}
items => event_schema()
}
}
}
}, },
405 => #{description => <<"Method not allowed">>} 405 => #{description => <<"Method not allowed">>}
} }
@@ -59,28 +54,31 @@ trails() ->
event_schema() -> event_schema() ->
#{ #{
type => object, type => object,
properties => #{ properties => #{
id => #{type => string}, id => #{type => string},
calendar_id => #{type => string}, calendar_id => #{type => string},
title => #{type => string}, title => #{type => string},
description => #{type => string}, description => #{type => string},
event_type => #{type => string, enum => [<<"single">>, <<"recurring">>]}, event_type => #{type => string, enum => [<<"single">>, <<"recurring">>]},
start_time => #{type => string, format => <<"date-time">>}, start_time => #{type => string, format => <<"date-time">>},
duration => #{type => integer}, duration => #{type => integer},
recurrence => #{type => object, nullable => true}, recurrence => #{type => object, nullable => true},
master_id => #{type => string, nullable => true}, master_id => #{type => string, nullable => true},
is_instance => #{type => boolean}, is_instance => #{type => boolean},
specialist_id => #{type => string, nullable => true}, specialist_id => #{type => string, nullable => true},
location => #{type => object, nullable => true}, location => #{type => object, nullable => true},
tags => #{type => array, items => #{type => string}}, tags => #{type => array, items => #{type => string}},
capacity => #{type => integer, nullable => true}, capacity => #{type => integer, nullable => true},
online_link => #{type => string, nullable => true}, online_link => #{type => string, nullable => true},
status => #{type => string, enum => [<<"active">>, <<"cancelled">>, <<"completed">>]}, status => #{type => string, enum => [<<"active">>, <<"cancelled">>, <<"completed">>]},
rating_avg => #{type => number, format => float}, reason => #{type => string, nullable => true},
rating_count => #{type => integer}, rating_avg => #{type => number, format => float},
created_at => #{type => string, format => <<"date-time">>}, rating_count => #{type => integer},
updated_at => #{type => string, format => <<"date-time">>} 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) -> list_all_events(Req) ->
case auth_admin(Req) of case handler_utils:auth_admin(Req) of
{ok, _AdminId, Req1} -> {ok, _AdminId, Req1} ->
Params = parse_admin_event_search(Req1), Params = parse_admin_event_search(Req1),
{ok, Total, Events} = logic_event:search_events(Params), {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), Limit = maps:get(limit, Params, 50),
Offset = maps:get(offset, Params, 0), Offset = maps:get(offset, Params, 0),
RangeEnd = min(Offset + Limit - 1, Total - 1), RangeEnd = min(Offset + Limit - 1, Total - 1),
Headers = #{ Headers = #{
<<"content-type">> => <<"application/json">>, <<"content-type">> => <<"application/json">>,
<<"content-range">> => iolist_to_binary( <<"content-range">> => iolist_to_binary(io_lib:format("items ~B-~B/~B", [Offset, RangeEnd, Total])),
io_lib:format("items ~B-~B/~B", [Offset, RangeEnd, Total])), <<"x-total-count">> => integer_to_binary(Total),
<<"x-total-count">> => integer_to_binary(Total),
<<"access-control-expose-headers">> => <<"Content-Range, X-Total-Count">> <<"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} -> {error, Code, Msg, Req1} ->
send_error(Req1, Code, Msg) handler_utils:send_error(Req1, Code, Msg)
end. end.
parse_admin_event_search(Req) -> parse_admin_event_search(Req) ->
Qs = cowboy_req:parse_qs(Req), Qs = cowboy_req:parse_qs(Req),
#{ #{
from => parse_datetime_qs(proplists:get_value(<<"from">>, Qs)), from => handler_utils:parse_datetime_qs(proplists:get_value(<<"from">>, Qs)),
to => parse_datetime_qs(proplists:get_value(<<"to">>, Qs)), to => handler_utils:parse_datetime_qs(proplists:get_value(<<"to">>, Qs)),
status => proplists:get_value(<<"status">>, Qs, undefined), status => proplists:get_value(<<"status">>, Qs, undefined),
calendar_id => proplists:get_value(<<"calendar_id">>, Qs, undefined), calendar_id => proplists:get_value(<<"calendar_id">>, Qs, undefined),
title => proplists:get_value(<<"title">>, Qs, undefined), title => proplists:get_value(<<"title">>, Qs, undefined),
q => proplists:get_value(<<"q">>, Qs, undefined), q => proplists:get_value(<<"q">>, Qs, undefined),
limit => parse_int_qs(proplists:get_value(<<"limit">>, Qs), 50), limit => handler_utils:parse_int_qs(proplists:get_value(<<"limit">>, Qs), 50),
offset => parse_int_qs(proplists:get_value(<<"offset">>, Qs), 0), offset => handler_utils:parse_int_qs(proplists:get_value(<<"offset">>, Qs), 0),
sort => proplists:get_value(<<"sort">>, Qs, <<"created_at">>), sort => proplists:get_value(<<"sort">>, Qs, <<"created_at">>),
order => proplists:get_value(<<"order">>, Qs, <<"desc">>) 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). -module(admin_handler_health).
-behaviour(cowboy_handler). -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 case cowboy_req:method(Req) of
<<"GET">> -> <<"GET">> ->
Body = jsx:encode(#{status => <<"ok">>}), handler_utils:send_json(Req, 200, #{status => <<"ok">>});
Req2 = cowboy_req:reply(200, #{<<"content-type">> => <<"application/json">>}, Body, Req),
{ok, Req2, State};
_ -> _ ->
send_error(Req, 405, <<"Method not allowed">>) handler_utils:send_error(Req, 405, <<"Method not allowed">>)
end. end.
send_error(Req, Status, Message) -> %%% Swagger metadata
Body = jsx:encode(#{error => Message}), trails() ->
Req2 = cowboy_req:reply(Status, #{<<"content-type">> => <<"application/json">>}, Body, Req), [
{ok, Req2, []}. #{
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). -module(admin_handler_login).
-behaviour(cowboy_handler). -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 case cowboy_req:method(Req0) of
<<"POST">> -> <<"POST">> ->
case cowboy_req:has_body(Req0) of case cowboy_req:has_body(Req0) of
@@ -12,48 +21,67 @@ init(Req0, State) ->
#{<<"email">> := Email, <<"password">> := Password} -> #{<<"email">> := Email, <<"password">> := Password} ->
case eventhub_auth:authenticate_admin_request(Req1, Email, Password) of case eventhub_auth:authenticate_admin_request(Req1, Email, Password) of
{ok, Token, User} -> {ok, Token, User} ->
% Генерация refresh-токена для администратора UserId = maps:get(id, User),
{RefreshToken, _ExpiresAt} = eventhub_auth:generate_refresh_token(maps:get(id, User)), {RefreshToken, _ExpiresAt} = eventhub_auth:generate_refresh_token(UserId),
% Сохранение refresh-токена в admin_session core_admin_session:create(UserId, RefreshToken),
core_admin_session:create(maps:get(id, User), RefreshToken), core_admin:update_last_login(UserId),
core_admin:update_last_login(maps:get(id, User)), Resp = #{
Resp = jsx:encode(#{
<<"token">> => Token, <<"token">> => Token,
<<"user">> => #{ <<"user">> => #{
<<"id">> => maps:get(id, User), <<"id">> => UserId,
<<"email">> => maps:get(email, User), <<"email">> => maps:get(email, User),
<<"role">> => maps:get(role, User) <<"role">> => maps:get(role, User)
}, },
<<"refresh_token">> => RefreshToken <<"refresh_token">> => RefreshToken
}), },
Req2 = cowboy_req:reply(200, #{ handler_utils:send_json(Req1, 200, Resp);
<<"content-type">> => <<"application/json">>,
<<"access-control-allow-origin">> => <<"*">>
}, Resp, Req1),
{ok, Req2, State};
{error, insufficient_permissions} -> {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, 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, Reason} ->
error_response(401, Reason, Req1, State) handler_utils:send_error(Req1, 401, Reason)
end; end;
_ -> _ ->
error_response(400, <<"Missing email or password">>, Req1, State) handler_utils:send_error(Req1, 400, <<"Missing email or password">>)
catch catch
_:_ -> error_response(400, <<"Invalid JSON">>, Req1, State) _:_ -> handler_utils:send_error(Req1, 400, <<"Invalid JSON">>)
end; end;
false -> false ->
error_response(400, <<"Missing request body">>, Req0, State) handler_utils:send_error(Req0, 400, <<"Missing request body">>)
end; end;
_ -> _ ->
error_response(405, <<"Method not allowed">>, Req0, State) handler_utils:send_error(Req0, 405, <<"Method not allowed">>)
end. end.
error_response(Code, Reason, Req, State) -> %%% Swagger metadata
Body = jsx:encode(#{<<"error">> => Reason}), -spec trails() -> [map()].
Req2 = cowboy_req:reply(Code, #{ trails() ->
<<"content-type">> => <<"application/json">>, [
<<"access-control-allow-origin">> => <<"*">> #{
}, Body, Req), path => <<"/v1/admin/login">>,
{ok, Req2, State}. 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). -module(admin_handler_me).
-behaviour(cowboy_handler). -behaviour(cowboy_handler).
-include("records.hrl").
-export([init/2]). -export([init/2]).
-export([trails/0]).
-include("records.hrl").
init(Req, _Opts) -> init(Req, _Opts) ->
case cowboy_req:method(Req) of case cowboy_req:method(Req) of
<<"GET">> -> <<"GET">> -> get_me(Req);
case handler_auth:authenticate(Req) of <<"PUT">> -> update_me(Req);
{ok, AdminId, Req1} -> _ -> handler_utils:send_error(Req, 405, <<"Method not allowed">>)
case core_admin:get_by_id(AdminId) of
{ok, Admin} ->
Permissions = admin_utils:get_permissions(Admin#admin.role),
Resp = jsx:encode(#{
id => Admin#admin.id,
email => Admin#admin.email,
role => Admin#admin.role,
permissions => Permissions
}),
Req2 = cowboy_req:reply(200, #{<<"content-type">> => <<"application/json">>}, Resp, Req1),
{ok, Req2, []};
{error, not_found} ->
send_error(Req1, 404, <<"Admin not found">>)
end;
{error, Code, Message, Req1} ->
send_error(Req1, Code, Message)
end;
_ ->
send_error(Req, 405, <<"Method not allowed">>)
end. end.
send_error(Req, Code, Message) -> trails() ->
Body = jsx:encode(#{error => Message}), [
Req2 = cowboy_req:reply(Code, #{<<"content-type">> => <<"application/json">>}, Body, Req), #{ % GET
{ok, Req2, []}. path => <<"/v1/admin/me">>,
method => <<"GET">>,
description => <<"Get current admin profile">>,
tags => [<<"Admins">>],
responses => #{
200 => #{
description => <<"Admin profile">>,
content => #{<<"application/json">> => #{schema => admin_schema()}}
}
}
},
#{ % PUT
path => <<"/v1/admin/me">>,
method => <<"PUT">>,
description => <<"Update current admin profile">>,
tags => [<<"Admins">>],
requestBody => #{
required => true,
content => #{<<"application/json">> => #{schema => admin_update_schema()}}
},
responses => #{
200 => #{description => <<"Updated profile">>}
}
}
].
admin_schema() ->
#{
type => object,
properties => #{
id => #{type => string},
email => #{type => string},
role => #{type => string},
status => #{type => string},
nickname => #{type => string, nullable => true},
avatar_url => #{type => string, nullable => true},
timezone => #{type => string, nullable => true},
language => #{type => string, nullable => true},
phone => #{type => string, nullable => true},
preferences => #{type => object, nullable => true},
last_login => #{type => string, format => <<"date-time">>},
created_at => #{type => string, format => <<"date-time">>},
updated_at => #{type => string, format => <<"date-time">>}
}
}.
admin_update_schema() ->
#{
type => object,
properties => #{
nickname => #{type => string},
avatar_url => #{type => string},
timezone => #{type => string},
language => #{type => string},
phone => #{type => string},
preferences => #{type => object}
}
}.
get_me(Req) ->
case handler_utils:auth_admin(Req) of
{ok, AdminId, Req1} ->
case logic_admin:get_admin(AdminId) of
{ok, Admin} ->
handler_utils:send_json(Req1, 200, admin_to_json(Admin));
{error, not_found} ->
handler_utils:send_error(Req1, 404, <<"Admin not found">>);
{error, _} ->
handler_utils:send_error(Req1, 500, <<"Internal server error">>)
end;
{error, Code, Msg, Req1} ->
handler_utils:send_error(Req1, Code, Msg)
end.
update_me(Req) ->
case handler_utils:auth_admin(Req) of
{ok, AdminId, Req1} ->
{ok, Body, Req2} = cowboy_req:read_body(Req1),
try jsx:decode(Body, [return_maps]) of
UpdatesMap when is_map(UpdatesMap) ->
Updates = maps:to_list(UpdatesMap),
case logic_admin:update_admin(AdminId, Updates) of
{ok, Admin} ->
handler_utils:send_json(Req2, 200, admin_to_json(Admin));
{error, not_found} ->
handler_utils:send_error(Req2, 404, <<"Admin not found">>);
{error, _} ->
handler_utils:send_error(Req2, 500, <<"Internal server error">>)
end;
_ ->
handler_utils:send_error(Req2, 400, <<"Invalid JSON">>)
catch
_:_ -> handler_utils:send_error(Req1, 400, <<"Invalid JSON format">>)
end;
{error, Code, Msg, Req1} ->
handler_utils:send_error(Req1, Code, Msg)
end.
admin_to_json(Admin) ->
#{
id => Admin#admin.id,
email => Admin#admin.email,
role => Admin#admin.role,
status => Admin#admin.status,
nickname => Admin#admin.nickname,
avatar_url => Admin#admin.avatar_url,
timezone => Admin#admin.timezone,
language => Admin#admin.language,
phone => Admin#admin.phone,
preferences => Admin#admin.preferences,
last_login => datetime_to_iso8601(Admin#admin.last_login),
created_at => datetime_to_iso8601(Admin#admin.created_at),
updated_at => datetime_to_iso8601(Admin#admin.updated_at)
}.
datetime_to_iso8601(undefined) -> undefined;
datetime_to_iso8601({{Year, Month, Day}, {Hour, Minute, Second}}) ->
iolist_to_binary(io_lib:format("~4..0B-~2..0B-~2..0BT~2..0B:~2..0B:~2..0BZ",
[Year, Month, Day, Hour, Minute, Second])).

View File

@@ -1,19 +1,85 @@
%%%-------------------------------------------------------------------
%%% @doc Административный обработчик модерации.
%%% PUT применяет действие модерации к указанной сущности.
%%% @end
%%%-------------------------------------------------------------------
-module(admin_handler_moderation). -module(admin_handler_moderation).
-behaviour(cowboy_handler). -behaviour(cowboy_handler).
-export([init/2]). -export([init/2]).
-export([trails/0]).
-include("records.hrl"). -include("records.hrl").
-define(VALID_TARGETS, [<<"calendar">>, <<"event">>, <<"review">>, <<"user">>]). -define(VALID_TARGETS, [<<"calendar">>, <<"event">>, <<"review">>, <<"user">>]).
%%% cowboy_handler callback
-spec init(cowboy_req:req(), any()) -> {ok, cowboy_req:req(), any()}.
init(Req, _Opts) -> init(Req, _Opts) ->
case cowboy_req:method(Req) of case cowboy_req:method(Req) of
<<"PUT">> -> moderate(Req); <<"PUT">> -> moderate(Req);
_ -> send_error(Req, 405, <<"Method not allowed">>) _ -> handler_utils:send_error(Req, 405, <<"Method not allowed">>)
end. 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) -> moderate(Req) ->
case authenticate_and_check_admin(Req) of case handler_utils:auth_admin(Req) of
{ok, AdminId, Req1} -> {ok, AdminId, Req1} ->
TargetType = cowboy_req:binding(target_type, Req1), TargetType = cowboy_req:binding(target_type, Req1),
TargetId = cowboy_req:binding(id, Req1), TargetId = cowboy_req:binding(id, Req1),
@@ -25,15 +91,15 @@ moderate(Req) ->
Reason = maps:get(<<"reason">>, BodyMap, <<"">>), Reason = maps:get(<<"reason">>, BodyMap, <<"">>),
apply_moderation(TargetType, TargetId, Action, Reason, Req2, AdminId); 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 catch
_:_ -> send_error(Req2, 400, <<"Invalid JSON">>) _:_ -> handler_utils:send_error(Req2, 400, <<"Invalid JSON">>)
end; end;
false -> false ->
send_error(Req1, 400, <<"Invalid target_type">>) handler_utils:send_error(Req1, 400, <<"Invalid target_type">>)
end; end;
{error, Code, Message, Req1} -> {error, Code, Message, Req1} ->
send_error(Req1, Code, Message) handler_utils:send_error(Req1, Code, Message)
end. end.
apply_moderation(<<"calendar">>, Id, Action, Reason, Req, AdminId) -> 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 case core_calendar:freeze(Id, Reason) of
{ok, Calendar} -> {ok, Calendar} ->
log_audit(AdminId, <<"freeze_calendar">>, <<"calendar">>, Id, Reason), log_audit(AdminId, <<"freeze_calendar">>, <<"calendar">>, Id, Reason),
send_json(Req, 200, calendar_to_json(Calendar)); handler_utils:send_json(Req, 200, handler_utils:calendar_to_json(Calendar));
{error, not_found} -> send_error(Req, 404, <<"Calendar not found">>) {error, not_found} ->
handler_utils:send_error(Req, 404, <<"Calendar not found">>)
end; end;
handle_calendar(Id, <<"unfreeze">>, Reason, Req, AdminId) -> handle_calendar(Id, <<"unfreeze">>, Reason, Req, AdminId) ->
case core_calendar:unfreeze(Id, Reason) of case core_calendar:unfreeze(Id, Reason) of
{ok, Calendar} -> {ok, Calendar} ->
log_audit(AdminId, <<"unfreeze_calendar">>, <<"calendar">>, Id, Reason), log_audit(AdminId, <<"unfreeze_calendar">>, <<"calendar">>, Id, Reason),
send_json(Req, 200, calendar_to_json(Calendar)); handler_utils:send_json(Req, 200, handler_utils:calendar_to_json(Calendar));
{error, not_found} -> send_error(Req, 404, <<"Calendar not found">>) {error, not_found} ->
handler_utils:send_error(Req, 404, <<"Calendar not found">>)
end; end;
handle_calendar(_Id, _Action, _Reason, Req, _AdminId) -> 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) -> handle_event(Id, <<"freeze">>, Reason, Req, AdminId) ->
case core_event:freeze(Id, Reason) of case core_event:freeze(Id, Reason) of
{ok, Event} -> {ok, Event} ->
log_audit(AdminId, <<"freeze_event">>, <<"event">>, Id, Reason), log_audit(AdminId, <<"freeze_event">>, <<"event">>, Id, Reason),
send_json(Req, 200, event_to_json(Event)); handler_utils:send_json(Req, 200, handler_utils:event_to_json(Event));
{error, not_found} -> send_error(Req, 404, <<"Event not found">>) {error, not_found} ->
handler_utils:send_error(Req, 404, <<"Event not found">>)
end; end;
handle_event(Id, <<"unfreeze">>, Reason, Req, AdminId) -> handle_event(Id, <<"unfreeze">>, Reason, Req, AdminId) ->
case core_event:unfreeze(Id, Reason) of case core_event:unfreeze(Id, Reason) of
{ok, Event} -> {ok, Event} ->
log_audit(AdminId, <<"unfreeze_event">>, <<"event">>, Id, Reason), log_audit(AdminId, <<"unfreeze_event">>, <<"event">>, Id, Reason),
send_json(Req, 200, event_to_json(Event)); handler_utils:send_json(Req, 200, handler_utils:event_to_json(Event));
{error, not_found} -> send_error(Req, 404, <<"Event not found">>) {error, not_found} ->
handler_utils:send_error(Req, 404, <<"Event not found">>)
end; end;
handle_event(_Id, _Action, _Reason, Req, _AdminId) -> 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) -> handle_review(Id, <<"hide">>, Reason, Req, AdminId) ->
case core_review:hide(Id, Reason) of case core_review:hide(Id, Reason) of
{ok, Review} -> {ok, Review} ->
log_audit(AdminId, <<"hide_review">>, <<"review">>, Id, Reason), log_audit(AdminId, <<"hide_review">>, <<"review">>, Id, Reason),
send_json(Req, 200, review_to_json(Review)); handler_utils:send_json(Req, 200, handler_utils:review_to_json(Review));
{error, not_found} -> send_error(Req, 404, <<"Review not found">>) {error, not_found} ->
handler_utils:send_error(Req, 404, <<"Review not found">>)
end; end;
handle_review(Id, <<"unhide">>, Reason, Req, AdminId) -> handle_review(Id, <<"unhide">>, Reason, Req, AdminId) ->
case core_review:unhide(Id, Reason) of case core_review:unhide(Id, Reason) of
{ok, Review} -> {ok, Review} ->
log_audit(AdminId, <<"unhide_review">>, <<"review">>, Id, Reason), log_audit(AdminId, <<"unhide_review">>, <<"review">>, Id, Reason),
send_json(Req, 200, review_to_json(Review)); handler_utils:send_json(Req, 200, handler_utils:review_to_json(Review));
{error, not_found} -> send_error(Req, 404, <<"Review not found">>) {error, not_found} ->
handler_utils:send_error(Req, 404, <<"Review not found">>)
end; end;
handle_review(_Id, _Action, _Reason, Req, _AdminId) -> 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) -> handle_user(Id, <<"block">>, Reason, Req, AdminId) ->
case core_user:block(Id, Reason) of case core_user:block(Id, Reason) of
{ok, User} -> {ok, User} ->
log_audit(AdminId, <<"block_user">>, <<"user">>, Id, Reason), log_audit(AdminId, <<"block_user">>, <<"user">>, Id, Reason),
send_json(Req, 200, user_to_json(User)); handler_utils:send_json(Req, 200, handler_utils:user_to_json(User));
{error, not_found} -> send_error(Req, 404, <<"User not found">>) {error, not_found} ->
handler_utils:send_error(Req, 404, <<"User not found">>)
end; end;
handle_user(Id, <<"unblock">>, Reason, Req, AdminId) -> handle_user(Id, <<"unblock">>, Reason, Req, AdminId) ->
case core_user:unblock(Id, Reason) of case core_user:unblock(Id, Reason) of
{ok, User} -> {ok, User} ->
log_audit(AdminId, <<"unblock_user">>, <<"user">>, Id, Reason), log_audit(AdminId, <<"unblock_user">>, <<"user">>, Id, Reason),
send_json(Req, 200, user_to_json(User)); handler_utils:send_json(Req, 200, handler_utils:user_to_json(User));
{error, not_found} -> send_error(Req, 404, <<"User not found">>) {error, not_found} ->
handler_utils:send_error(Req, 404, <<"User not found">>)
end; end;
handle_user(_Id, _Action, _Reason, Req, _AdminId) -> 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) -> log_audit(AdminId, Action, EntityType, EntityId, Reason) ->
case core_admin:get_by_id(AdminId) of case core_admin:get_by_id(AdminId) of
{ok, Admin} -> {ok, Admin} ->
core_admin_audit:log(AdminId, Admin#admin.email, Admin#admin.role, core_admin_audit:log(AdminId, Admin#admin.email, Admin#admin.role,
Action, EntityType, EntityId, Action, EntityType, EntityId, client_ip(), Reason);
client_ip(), Reason);
_ -> ok _ -> ok
end. end.
%% ── ВСПОМОГАТЕЛЬНЫЕ ФУНКЦИИ ──────────────────────────────── %% ── ВСПОМОГАТЕЛЬНЫЕ ФУНКЦИИ ────────────────────────────────
authenticate_and_check_admin(Req) -> client_ip() -> <<"127.0.0.1">>.
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). -module(admin_handler_report_by_id).
-behaviour(cowboy_handler). -behaviour(cowboy_handler).
-export([init/2]). -export([init/2]).
-export([trails/0]).
-include("records.hrl"). -include("records.hrl").
-spec init(cowboy_req:req(), any()) -> {ok, cowboy_req:req(), any()}.
init(Req, _Opts) -> init(Req, _Opts) ->
case cowboy_req:method(Req) of case cowboy_req:method(Req) of
<<"GET">> -> get_report(Req); <<"GET">> -> get_report(Req);
<<"PUT">> -> update_report(Req); <<"PUT">> -> update_report(Req);
_ -> send_error(Req, 405, <<"Method not allowed">>) _ -> handler_utils:send_error(Req, 405, <<"Method not allowed">>)
end. 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) -> get_report(Req) ->
case auth_admin(Req) of case handler_utils:auth_admin(Req) of
{ok, AdminId, Req1} -> {ok, AdminId, Req1} ->
case admin_utils:is_admin(AdminId) of ReportId = cowboy_req:binding(id, Req1),
true -> case logic_report:get_report(AdminId, ReportId) of
ReportId = cowboy_req:binding(id, Req1), {ok, Report} ->
case core_report:get_by_id(ReportId) of handler_utils:send_json(Req1, 200, handler_utils:report_to_json(Report));
{ok, Report} -> {error, not_found} ->
send_json(Req1, 200, report_to_json(Report)); handler_utils:send_error(Req1, 404, <<"Report not found">>);
{error, not_found} -> {error, _} ->
send_error(Req1, 404, <<"Report not found">>) handler_utils:send_error(Req1, 500, <<"Internal server error">>)
end;
false ->
send_error(Req1, 403, <<"Admin access required">>)
end; end;
{error, Code, Message, Req1} -> {error, Code, Msg, Req1} ->
send_error(Req1, Code, Message) handler_utils:send_error(Req1, Code, Msg)
end. end.
update_report(Req) -> update_report(Req) ->
case auth_admin(Req) of case handler_utils:auth_admin(Req) of
{ok, AdminId, Req1} -> {ok, AdminId, Req1} ->
case admin_utils:is_admin(AdminId) of ReportId = cowboy_req:binding(id, Req1),
true -> {ok, Body, Req2} = cowboy_req:read_body(Req1),
ReportId = cowboy_req:binding(id, Req1), try jsx:decode(Body, [return_maps]) of
{ok, Body, Req2} = cowboy_req:read_body(Req1), #{<<"status">> := Status} ->
try jsx:decode(Body, [return_maps]) of case logic_report:update_report_status(AdminId, ReportId, Status) of
#{<<"status">> := NewStatus, <<"reason">> := Reason} -> {ok, Report} ->
StatusAtom = binary_to_atom(NewStatus, utf8), handler_utils:send_json(Req2, 200, handler_utils:report_to_json(Report));
case core_report:update_status(ReportId, StatusAtom, AdminId) of {error, not_found} ->
{ok, Report} -> handler_utils:send_error(Req2, 404, <<"Report not found">>);
log_audit(AdminId, <<"update_report_status">>, <<"report">>, ReportId, Reason), {error, _} ->
send_json(Req2, 200, report_to_json(Report)); handler_utils:send_error(Req2, 500, <<"Internal server error">>)
{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; end;
false -> _ ->
send_error(Req1, 403, <<"Admin access required">>) handler_utils:send_error(Req2, 400, <<"Missing status field">>)
catch
_:_ -> handler_utils:send_error(Req1, 400, <<"Invalid JSON">>)
end; end;
{error, Code, Message, Req1} -> {error, Code, Msg, Req1} ->
send_error(Req1, Code, Message) handler_utils:send_error(Req1, Code, Msg)
end. 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). -module(admin_handler_reports).
-behaviour(cowboy_handler). -behaviour(cowboy_handler).
-export([init/2]). -export([init/2]).
-export([trails/0]).
-include("records.hrl"). -include("records.hrl").
-spec init(cowboy_req:req(), any()) -> {ok, cowboy_req:req(), any()}.
init(Req, _Opts) -> init(Req, _Opts) ->
case cowboy_req:method(Req) of case cowboy_req:method(Req) of
<<"GET">> -> list_reports(Req); <<"GET">> -> list_reports(Req);
<<"PUT">> -> update_report(Req); _ -> handler_utils:send_error(Req, 405, <<"Method not allowed">>)
_ -> send_error(Req, 405, <<"Method not allowed">>)
end. end.
list_reports(Req) -> -spec trails() -> [map()].
case auth_admin(Req) of trails() ->
{ok, AdminId, Req1} -> [
case admin_utils:is_admin(AdminId) of #{
true -> path => <<"/v1/admin/reports">>,
{ok, Reports} = core_report:list_all(), method => <<"GET">>,
send_json(Req1, 200, [report_to_json(R) || R <- Reports]); description => <<"List all reports (admin)">>,
false -> tags => [<<"Reports">>],
send_error(Req1, 403, <<"Admin access required">>) parameters => [
end; #{name => <<"status">>, in => <<"query">>, schema => #{type => string, enum => [<<"pending">>, <<"reviewed">>, <<"dismissed">>]}, description => <<"Filter by status">>},
{error, Code, Message, Req1} -> #{name => <<"target_type">>, in => <<"query">>, schema => #{type => string, enum => [<<"calendar">>, <<"event">>, <<"review">>]}, description => <<"Filter by target type">>},
send_error(Req1, Code, Message) #{name => <<"q">>, in => <<"query">>, schema => #{type => string}, description => <<"Search in reason">>},
end. #{name => <<"limit">>, in => <<"query">>, schema => #{type => integer}, description => <<"Page size">>},
#{name => <<"offset">>, in => <<"query">>, schema => #{type => integer}, description => <<"Offset">>}
],
responses => #{
200 => #{
description => <<"Array of reports">>,
content => #{<<"application/json">> => #{schema => #{
type => array,
items => report_schema()
}}}
}
}
}
].
update_report(Req) -> report_schema() ->
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) ->
#{ #{
id => R#report.id, type => object,
reporter_id => R#report.reporter_id, properties => #{
target_type => R#report.target_type, id => #{type => string},
target_id => R#report.target_id, reporter_id => #{type => string},
reason => R#report.reason, target_type => #{type => string, enum => [<<"calendar">>, <<"event">>, <<"review">>]},
status => R#report.status, target_id => #{type => string},
created_at => datetime_to_iso8601(R#report.created_at), reason => #{type => string},
resolved_at => datetime_to_iso8601(R#report.resolved_at) 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}}) -> %%% Internal functions
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) -> -spec list_reports(cowboy_req:req()) -> {ok, binary(), cowboy_req:req()}.
Headers = #{ list_reports(Req) ->
<<"content-type">> => <<"application/json">>, case handler_utils:auth_admin(Req) of
<<"access-control-allow-origin">> => <<"*">>, {ok, AdminId, Req1} ->
<<"access-control-expose-headers">> => <<"Content-Range">> Filters = parse_report_filters(Req1),
}, Pagination = handler_utils:parse_pagination_params(Req1),
Body = jsx:encode(Data), case logic_report:list_reports(AdminId) of
cowboy_req:reply(Status, Headers, Body, Req), {ok, AllReports} ->
{ok, Body, []}. 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) -> parse_report_filters(Req) ->
Body = jsx:encode(#{error => Message}), Qs = cowboy_req:parse_qs(Req),
cowboy_req:reply(Status, #{<<"content-type">> => <<"application/json">>}, Body, Req), #{
{ok, Body, []}. 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). -module(admin_handler_reviews).
-behaviour(cowboy_handler). -behaviour(cowboy_handler).
-export([init/2]).
-export([trails/0]).
-include("records.hrl"). -include("records.hrl").
-export([init/2]). %%% cowboy_handler callback
init(Req, _Opts) -> init(Req, _Opts) ->
case cowboy_req:method(Req) of case cowboy_req:method(Req) of
<<"GET">> -> list_reviews(Req); <<"GET">> -> list_reviews(Req);
<<"PATCH">> -> bulk_update_reviews(Req); <<"PATCH">> -> bulk_update_reviews(Req);
_ -> send_error(Req, 405, <<"Method not allowed">>) _ -> handler_utils:send_error(Req, 405, <<"Method not allowed">>)
end. 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) -> list_reviews(Req) ->
case auth_admin(Req) of case handler_utils:auth_admin(Req) of
{ok, _AdminId, Req1} -> {ok, _AdminId, Req1} ->
Filters = parse_filters(Req1), Filters = parse_review_filters(Req1),
Reviews = logic_review:list_admin_reviews(Filters), Pagination = handler_utils:parse_pagination_params(Req1),
Json = [review_to_json(R) || R <- Reviews], {ok, Total, Reviews} = logic_review:list_admin_reviews(Filters, Pagination),
send_json(Req1, 200, Json); 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} -> {error, Code, Msg, Req1} ->
send_error(Req1, Code, Msg) handler_utils:send_error(Req1, Code, Msg)
end. end.
bulk_update_reviews(Req) -> bulk_update_reviews(Req) ->
case auth_admin(Req) of case handler_utils:auth_admin(Req) of
{ok, _AdminId, Req1} -> {ok, _AdminId, Req1} ->
try try
{ok, Body, Req2} = cowboy_req:read_body(Req1), {ok, Body, Req2} = cowboy_req:read_body(Req1),
Operations = jsx:decode(Body, [return_maps]), Operations = jsx:decode(Body, [return_maps]),
true = is_list(Operations),
case logic_review:bulk_update_status(Operations) of case logic_review:bulk_update_status(Operations) of
{ok, Count} -> {ok, UpdatedCount} ->
send_json(Req2, 200, #{updated_count => Count}); handler_utils:send_json(Req2, 200, #{updated_count => UpdatedCount});
{error, Reason} -> {error, Reason} ->
send_error(Req2, 400, Reason) handler_utils:send_error(Req2, 400, Reason)
end end
catch catch
_:_ -> send_error(Req1, 400, <<"Invalid JSON body">>) _:_ -> handler_utils:send_error(Req1, 400, <<"Invalid JSON body">>)
end; end;
{error, Code, Msg, Req1} -> {error, Code, Msg, Req1} ->
send_error(Req1, Code, Msg) handler_utils:send_error(Req1, Code, Msg)
end. end.
auth_admin(Req) -> parse_review_filters(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) ->
Qs = cowboy_req:parse_qs(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, target_type => proplists:get_value(<<"target_type">>, Qs),
user_id => R#review.user_id, target_id => proplists:get_value(<<"target_id">>, Qs),
target_type => R#review.target_type, user_id => proplists:get_value(<<"user_id">>, Qs),
target_id => R#review.target_id, status => proplists:get_value(<<"status">>, Qs)
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}}) -> pagination_headers(#{limit := Limit, offset := Offset}, Total) ->
iolist_to_binary(io_lib:format("~4..0B-~2..0B-~2..0BT~2..0B:~2..0B:~2..0BZ", RangeEnd = min(Offset + Limit - 1, Total - 1),
[Year, Month, Day, Hour, Minute, Second])); #{
datetime_to_iso8601(undefined) -> undefined. <<"content-range">> => iolist_to_binary(io_lib:format("items ~B-~B/~B", [Offset, RangeEnd, Total])),
<<"x-total-count">> => integer_to_binary(Total),
send_json(Req, Status, Data) -> <<"access-control-expose-headers">> => <<"Content-Range, X-Total-Count">>
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,93 +1,127 @@
-module(admin_handler_reviews_by_id). -module(admin_handler_reviews_by_id).
-behaviour(cowboy_handler). -behaviour(cowboy_handler).
-export([init/2]). -export([init/2]).
-export([trails/0]).
-include("records.hrl"). -include("records.hrl").
%%% cowboy_handler callback
init(Req, _Opts) -> init(Req, _Opts) ->
case cowboy_req:method(Req) of case cowboy_req:method(Req) of
<<"GET">> -> get_review(Req); <<"GET">> -> get_review(Req);
<<"PUT">> -> update_review(Req); <<"PUT">> -> update_review(Req);
_ -> send_error(Req, 405, <<"Method not allowed">>) _ -> handler_utils:send_error(Req, 405, <<"Method not allowed">>)
end. 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) -> get_review(Req) ->
case handler_auth:authenticate(Req) of case handler_utils:auth_admin(Req) of
{ok, AdminId, Req1} -> {ok, _AdminId, Req1} ->
case admin_utils:is_admin(AdminId) of ReviewId = cowboy_req:binding(id, Req1),
true -> case logic_review:get_review_admin(ReviewId) of
ReviewId = cowboy_req:binding(id, Req1), {ok, Review} ->
case core_review:get_by_id(ReviewId) of handler_utils:send_json(Req1, 200, handler_utils:review_to_json(Review));
{ok, Review} -> {error, not_found} ->
send_json(Req1, 200, review_to_json(Review)); handler_utils:send_error(Req1, 404, <<"Review not found">>);
{error, not_found} -> {error, _} ->
send_error(Req1, 404, <<"Review not found">>) handler_utils:send_error(Req1, 500, <<"Internal server error">>)
end;
false ->
send_error(Req1, 403, <<"Admin access required">>)
end; end;
{error, Code, Message, Req1} -> {error, Code, Msg, Req1} ->
send_error(Req1, Code, Message) handler_utils:send_error(Req1, Code, Msg)
end. end.
update_review(Req) -> update_review(Req) ->
case handler_auth:authenticate(Req) of case handler_utils:auth_admin(Req) of
{ok, AdminId, Req1} -> {ok, _AdminId, Req1} ->
case admin_utils:is_admin(AdminId) of ReviewId = cowboy_req:binding(id, Req1),
true -> {ok, Body, Req2} = cowboy_req:read_body(Req1),
ReviewId = cowboy_req:binding(id, Req1), try jsx:decode(Body, [return_maps]) of
{ok, Body, Req2} = cowboy_req:read_body(Req1), UpdatesMap when is_map(UpdatesMap) ->
try jsx:decode(Body, [return_maps]) of Updates = maps:to_list(UpdatesMap),
#{<<"status">> := NewStatus} -> case logic_review:update_review_admin(ReviewId, Updates) of
case core_review:update_status(ReviewId, NewStatus) of {ok, Review} ->
{ok, Review} -> handler_utils:send_json(Req2, 200, handler_utils:review_to_json(Review));
send_json(Req2, 200, review_to_json(Review)); {error, not_found} ->
{error, not_found} -> handler_utils:send_error(Req2, 404, <<"Review not found">>);
send_error(Req2, 404, <<"Review not found">>); {error, _} ->
{error, _} -> handler_utils:send_error(Req2, 500, <<"Internal server error">>)
send_error(Req2, 500, <<"Internal server error">>)
end;
_ ->
send_error(Req2, 400, <<"Missing status field">>)
catch
_:_ -> send_error(Req2, 400, <<"Invalid JSON">>)
end; end;
false -> _ ->
send_error(Req1, 403, <<"Admin access required">>) handler_utils:send_error(Req2, 400, <<"Invalid JSON">>)
catch
_:_ -> handler_utils:send_error(Req1, 400, <<"Invalid JSON format">>)
end; end;
{error, Code, Message, Req1} -> {error, Code, Msg, Req1} ->
send_error(Req1, Code, Message) handler_utils:send_error(Req1, Code, Msg)
end. 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,63 +1,102 @@
%%%-------------------------------------------------------------------
%%% @doc Административный обработчик для получения статистики.
%%% GET возвращает агрегированную статистику для дашборда.
%%% Поддерживает фильтрацию по диапазону дат (from, to).
%%% @end
%%%-------------------------------------------------------------------
-module(admin_handler_stats). -module(admin_handler_stats).
-include("records.hrl"). -behaviour(cowboy_handler).
-export([init/2]).
-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) -> init(Req, _Opts) ->
case cowboy_req:method(Req) of case cowboy_req:method(Req) of
<<"GET">> -> get_stats(Req); <<"GET">> -> get_stats(Req);
_ -> send_error(Req, 405, <<"Method not allowed">>) _ -> handler_utils:send_error(Req, 405, <<"Method not allowed">>)
end. 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) -> get_stats(Req) ->
case handler_auth:authenticate(Req) of case handler_utils:auth_admin(Req) of
{ok, AdminId, Req1} -> {ok, AdminId, Req1} ->
case admin_utils:is_admin(AdminId) of {ok, Admin} = core_admin:get_by_id(AdminId),
true -> Role = Admin#admin.role,
{ok, Admin} = core_admin:get_by_id(AdminId), Stats = case parse_date_range(Req1) of
Role = Admin#admin.role, {ok, From, To} -> logic_stats:get_stats(Role, AdminId, From, To);
% Извлекаем параметры from и to из запроса _ -> logic_stats:get_stats(Role, AdminId)
Stats = case parse_date_range(Req1) of end,
{ok, From, To} -> handler_utils:send_json(Req1, 200, Stats);
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;
{error, Code, Message, Req1} -> {error, Code, Message, Req1} ->
send_error(Req1, Code, Message) handler_utils:send_error(Req1, Code, Message)
end. end.
%% @private Разбирает параметры 'from' и 'to' из строки запроса.
%% В случае успеха возвращает {ok, FromDT, ToDT}.
-spec parse_date_range(cowboy_req:req()) -> {ok, calendar:datetime(), calendar:datetime()} | error.
parse_date_range(Req) -> parse_date_range(Req) ->
Qs = cowboy_req:parse_qs(Req), Qs = cowboy_req:parse_qs(Req),
From = proplists:get_value(<<"from">>, Qs), From = proplists:get_value(<<"from">>, Qs),
To = proplists:get_value(<<"to">>, Qs), To = proplists:get_value(<<"to">>, Qs),
case {From, To} of case {From, To} of
{undefined, _} -> error; {undefined, _} -> error;
{_, undefined} -> error; {_, undefined} -> error;
{F, T} -> {F, T} -> try FromDT = iso8601_to_datetime(F),
try ToDT = iso8601_to_datetime(T),
FromDT = iso8601_to_datetime(F), {ok, FromDT, ToDT}
ToDT = iso8601_to_datetime(T), catch _:_ -> error
{ok, FromDT, ToDT} end
catch _:_ -> error
end
end. end.
%% @private Преобразует бинарную строку ISO8601 в кортеж datetime().
-spec iso8601_to_datetime(binary()) -> calendar:datetime().
iso8601_to_datetime(Str) -> iso8601_to_datetime(Str) ->
[Date, Time] = binary:split(Str, <<"T">>), [Date, Time] = binary:split(Str, <<"T">>),
[Y, M, D] = [binary_to_integer(X) || X <- binary:split(Date, <<"-">>, [global])], [Y, M, D] = [binary_to_integer(X) || X <- binary:split(Date, <<"-">>, [global])],
[H, Min, S] = [binary_to_integer(X) || X <- binary:split(Time, <<":">>, [global])], [H, Min, S] = [binary_to_integer(X) || X <- binary:split(Time, <<":">>, [global])],
{{Y, M, D}, {H, Min, S}}. {{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). -module(admin_handler_subscriptions).
-behaviour(cowboy_handler). -behaviour(cowboy_handler).
-export([init/2]). -export([init/2]).
-export([trails/0]).
-include("records.hrl"). -include("records.hrl").
-spec init(cowboy_req:req(), any()) -> {ok, cowboy_req:req(), any()}.
init(Req, _Opts) -> 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 case cowboy_req:method(Req) of
<<"GET">> -> list_subscriptions(Req); <<"GET">> -> list_subscriptions(Req);
<<"POST">> -> create_subscription(Req); _ -> handler_utils:send_error(Req, 405, <<"Method not allowed">>)
_ -> send_error(Req, 405, <<"Method not allowed">>)
end. end.
%% ================== Элемент ================== -spec trails() -> [map()].
handle_item(Req) -> trails() ->
SubId = cowboy_req:binding(id, Req), [
case cowboy_req:method(Req) of #{
<<"GET">> -> get_subscription(SubId, Req); path => <<"/v1/admin/subscriptions">>,
<<"PUT">> -> update_subscription(SubId, Req); method => <<"GET">>,
<<"DELETE">> -> delete_subscription(SubId, Req); description => <<"List all subscriptions (admin)">>,
_ -> send_error(Req, 405, <<"Method not allowed">>) tags => [<<"Subscriptions">>],
end. parameters => [
#{name => <<"plan">>, in => <<"query">>, schema => #{type => string, enum => [<<"monthly">>, <<"quarterly">>, <<"biannual">>, <<"annual">>]}, description => <<"Filter by plan">>},
#{name => <<"status">>, in => <<"query">>, schema => #{type => string, enum => [<<"active">>, <<"expired">>, <<"cancelled">>]}, description => <<"Filter by status">>},
#{name => <<"limit">>, in => <<"query">>, schema => #{type => integer}, description => <<"Page size">>},
#{name => <<"offset">>, in => <<"query">>, schema => #{type => integer}, description => <<"Offset">>}
],
responses => #{
200 => #{
description => <<"Array of subscriptions">>,
content => #{<<"application/json">> => #{schema => #{
type => array,
items => subscription_schema()
}}}
}
}
}
].
%% ================== GET /subscriptions ================== subscription_schema() ->
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) ->
#{ #{
id => S#subscription.id, type => object,
user_id => S#subscription.user_id, properties => #{
plan => atom_to_binary(S#subscription.plan, utf8), id => #{type => string},
status => atom_to_binary(S#subscription.status, utf8), user_id => #{type => string},
trial_used => S#subscription.trial_used, plan => #{type => string, enum => [<<"monthly">>, <<"quarterly">>, <<"biannual">>, <<"annual">>]},
started_at => datetime_to_iso8601(S#subscription.started_at), status => #{type => string, enum => [<<"active">>, <<"expired">>, <<"cancelled">>]},
expires_at => datetime_to_iso8601(S#subscription.expires_at), trial_used => #{type => boolean},
created_at => datetime_to_iso8601(S#subscription.created_at), started_at => #{type => string, format => <<"date-time">>},
updated_at => datetime_to_iso8601(S#subscription.updated_at) 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}}) -> %%% Internal functions
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.
%% ================== Валидация ================== list_subscriptions(Req) ->
validate_plan(Plan) when is_binary(Plan) -> case handler_utils:auth_admin(Req) of
lists:member(Plan, [<<"monthly">>, <<"yearly">>, <<"quarterly">>, <<"biannual">>, <<"annual">>]); {ok, _AdminId, Req1} ->
validate_plan(_) -> false. 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-ответы ================== parse_subscription_filters(Req) ->
send_json(Req, Status, Data) -> Qs = cowboy_req:parse_qs(Req),
Headers = #{ #{
<<"content-type">> => <<"application/json">>, plan => proplists:get_value(<<"plan">>, Qs),
<<"access-control-allow-origin">> => <<"*">>, status => proplists:get_value(<<"status">>, Qs)
<<"access-control-expose-headers">> => <<"Content-Range">> }.
},
Body = jsx:encode(Data),
cowboy_req:reply(Status, Headers, Body, Req),
{ok, Body, []}.
send_error(Req, Code, Message) -> apply_filters(Subs, Filters) ->
Headers = #{ Plan = maps:get(plan, Filters, undefined),
<<"content-type">> => <<"application/json">>, Status = maps:get(status, Filters, undefined),
<<"access-control-allow-origin">> => <<"*">>, F1 = case Plan of
<<"access-control-expose-headers">> => <<"Content-Range">> undefined -> Subs;
}, _ -> [S || S <- Subs, S#subscription.plan =:= Plan]
Body = jsx:encode(#{error => Message}), end,
cowboy_req:reply(Code, Headers, Body, Req), case Status of
{ok, Body, []}. 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). -module(admin_handler_ticket_by_id).
-behaviour(cowboy_handler). -behaviour(cowboy_handler).
-export([init/2]). -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) -> init(Req, _Opts) ->
case cowboy_req:method(Req) of case cowboy_req:method(Req) of
<<"GET">> -> get_ticket(Req); <<"GET">> -> get_ticket(Req);
<<"PUT">> -> update_ticket(Req); <<"PUT">> -> update_ticket(Req);
<<"DELETE">> -> delete_ticket(Req); <<"DELETE">> -> delete_ticket(Req);
_ -> send_error(Req, 405, <<"Method not allowed">>) _ -> handler_utils:send_error(Req, 405, <<"Method not allowed">>)
end. 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) -> get_ticket(Req) ->
case auth_admin(Req) of case handler_utils:auth_admin(Req) of
{ok, _AdminId, Req1} -> {ok, AdminId, Req1} ->
TicketId = cowboy_req:binding(id, 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} -> {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} -> {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; end;
{error, Code, Message, Req1} -> {error, Code, Msg, Req1} ->
send_error(Req1, Code, Message) handler_utils:send_error(Req1, Code, Msg)
end. end.
%% @doc Обновить тикет.
-spec update_ticket(cowboy_req:req()) -> {ok, binary(), cowboy_req:req()}.
update_ticket(Req) -> update_ticket(Req) ->
case auth_admin(Req) of case handler_utils:auth_admin(Req) of
{ok, _AdminId, Req1} -> {ok, AdminId, Req1} ->
TicketId = cowboy_req:binding(id, Req1), TicketId = cowboy_req:binding(id, Req1),
{ok, Body, Req2} = cowboy_req:read_body(Req1), {ok, Body, Req2} = cowboy_req:read_body(Req1),
try jsx:decode(Body, [return_maps]) of try jsx:decode(Body, [return_maps]) of
UpdatesMap when is_map(UpdatesMap) -> Data when is_map(Data) ->
case core_ticket:update_ticket(TicketId, UpdatesMap) of Result = apply_ticket_changes(AdminId, TicketId, Data),
case Result of
{ok, Ticket} -> {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} -> {error, not_found} ->
send_error(Req2, 404, <<"Ticket not found">>); handler_utils:send_error(Req2, 404, <<"Ticket not found">>);
{error, Reason} -> {error, access_denied} ->
send_error(Req2, 500, Reason) handler_utils:send_error(Req2, 403, <<"Admin access required">>);
{error, _} ->
handler_utils:send_error(Req2, 500, <<"Internal server error">>)
end; end;
_ -> _ ->
send_error(Req2, 400, <<"Invalid JSON">>) handler_utils:send_error(Req2, 400, <<"Invalid JSON">>)
catch catch
_:_ -> send_error(Req2, 400, <<"Invalid JSON">>) _:_ -> handler_utils:send_error(Req1, 400, <<"Invalid JSON format">>)
end; end;
{error, Code, Message, Req1} -> {error, Code, Msg, Req1} ->
send_error(Req1, Code, Message) handler_utils:send_error(Req1, Code, Msg)
end. end.
%% @doc Удалить тикет.
-spec delete_ticket(cowboy_req:req()) -> {ok, binary(), cowboy_req:req()}.
delete_ticket(Req) -> delete_ticket(Req) ->
case auth_admin(Req) of case handler_utils: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
{ok, AdminId, Req1} -> {ok, AdminId, Req1} ->
case admin_utils:is_admin(AdminId) of TicketId = cowboy_req:binding(id, Req1),
true -> {ok, AdminId, Req1}; case logic_ticket:delete_ticket(AdminId, TicketId) of
false -> {error, 403, <<"Admin access required">>, Req1} {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; end;
{error, Code, Message, Req1} -> {error, Code, Msg, Req1} ->
{error, Code, Message, Req1} handler_utils:send_error(Req1, Code, Msg)
end. end.
ticket_to_json(T) -> %% @private Применить изменения (аналогично admin_handler_tickets).
#{ apply_ticket_changes(AdminId, TicketId, Data) ->
id => T#ticket.id, case {maps:find(<<"status">>, Data), maps:find(<<"resolution_note">>, Data)} of
error_hash => T#ticket.error_hash, {{ok, <<"resolved">>}, {ok, Note}} ->
error_message => T#ticket.error_message, logic_ticket:resolve_ticket(AdminId, TicketId, Note);
stacktrace => T#ticket.stacktrace, {{ok, <<"resolved">>}, error} ->
context => T#ticket.context, logic_ticket:update_status(AdminId, TicketId, resolved);
count => T#ticket.count, {{ok, <<"closed">>}, _} ->
first_seen => datetime_to_iso8601(T#ticket.first_seen), logic_ticket:close_ticket(AdminId, TicketId);
last_seen => datetime_to_iso8601(T#ticket.last_seen), {{ok, OtherStatus}, _} ->
status => T#ticket.status, case logic_ticket:update_status(AdminId, TicketId, OtherStatus) of
assigned_to => T#ticket.assigned_to, {ok, Ticket1} ->
resolution_note => T#ticket.resolution_note case maps:find(<<"assigned_to">>, Data) of
}. {ok, AssignTo} ->
logic_ticket:assign_ticket(AdminId, TicketId, AssignTo);
datetime_to_iso8601({{Year, Month, Day}, {Hour, Minute, Second}}) -> error -> {ok, Ticket1}
iolist_to_binary(io_lib:format("~4..0B-~2..0B-~2..0BT~2..0B:~2..0B:~2..0BZ", end;
[Year, Month, Day, Hour, Minute, Second])); Error -> Error
datetime_to_iso8601(undefined) -> undefined. end;
{error, _} ->
send_json(Req, Status, Data) -> case maps:find(<<"assigned_to">>, Data) of
Body = jsx:encode(Data), {ok, AssignTo} ->
cowboy_req:reply(Status, #{<<"content-type">> => <<"application/json">>}, Body, Req), logic_ticket:assign_ticket(AdminId, TicketId, AssignTo);
{ok, Body, []}. error -> {error, no_changes}
end
send_error(Req, Status, Message) -> end.
Body = jsx:encode(#{error => Message}),
cowboy_req:reply(Status, #{<<"content-type">> => <<"application/json">>}, Body, Req),
{ok, Body, []}.

View File

@@ -1,46 +1,62 @@
%%%-------------------------------------------------------------------
%%% @doc Административный обработчик для получения статистики по тикетам.
%%% GET возвращает агрегированную статистику тикетов
%%% (количество по статусам: open, in_progress, resolved, closed).
%%% @end
%%%-------------------------------------------------------------------
-module(admin_handler_ticket_stats). -module(admin_handler_ticket_stats).
-behaviour(cowboy_handler). -behaviour(cowboy_handler).
-export([init/2]). -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) -> init(Req, _Opts) ->
case cowboy_req:method(Req) of case cowboy_req:method(Req) of
<<"GET">> -> get_stats(Req); <<"GET">> -> get_stats(Req);
_ -> send_error(Req, 405, <<"Method not allowed">>) _ -> handler_utils:send_error(Req, 405, <<"Method not allowed">>)
end. 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) -> get_stats(Req) ->
case auth_admin(Req) of case handler_utils:auth_admin(Req) of
{ok, _AdminId, Req1} -> {ok, _AdminId, Req1} ->
Stats = core_ticket:stats(), Stats = core_ticket:stats(),
send_json(Req1, 200, Stats); handler_utils:send_json(Req1, 200, Stats);
{error, Code, Message, Req1} -> {error, Code, Message, Req1} ->
send_error(Req1, Code, Message) handler_utils:send_error(Req1, Code, Message)
end. 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). -module(admin_handler_tickets).
-behaviour(cowboy_handler). -behaviour(cowboy_handler).
-export([init/2]). -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) -> 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 case cowboy_req:method(Req) of
<<"GET">> -> list_tickets(Req); <<"GET">> -> list_tickets(Req);
<<"POST">> -> create_ticket(Req); _ -> handler_utils:send_error(Req, 405, <<"Method not allowed">>)
_ -> send_error(Req, 405, <<"Method not allowed">>)
end. end.
handle_item(TicketId, Req) -> %%% Swagger metadata
case cowboy_req:method(Req) of -spec trails() -> [map()].
<<"GET">> -> get_ticket(TicketId, Req); trails() ->
<<"PUT">> -> update_ticket(TicketId, Req); [
<<"DELETE">> -> delete_ticket(TicketId, Req); #{ % GET list
_ -> send_error(Req, 405, <<"Method not allowed">>) path => <<"/v1/admin/tickets">>,
end. 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()
}}}
}
}
}
].
%% ── Список тикетов ────────────────────────────────────── 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) ->
#{ #{
id => T#ticket.id, type => object,
reporter_id => T#ticket.reporter_id, properties => #{
error_hash => T#ticket.error_hash, id => #{type => string},
error_message => T#ticket.error_message, reporter_id => #{type => string},
stacktrace => T#ticket.stacktrace, error_hash => #{type => string},
context => T#ticket.context, error_message => #{type => string},
count => T#ticket.count, stacktrace => #{type => string},
first_seen => datetime_to_iso8601(T#ticket.first_seen), context => #{type => string},
last_seen => datetime_to_iso8601(T#ticket.last_seen), count => #{type => integer},
status => T#ticket.status, first_seen => #{type => string, format => <<"date-time">>},
assigned_to => T#ticket.assigned_to, last_seen => #{type => string, format => <<"date-time">>},
resolution_note => T#ticket.resolution_note 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}}) -> %%% Internal functions
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.
%% ── HTTP-ответы ───────────────────────────────────────── %% @doc Получить список тикетов с пагинацией и фильтрацией.
send_json(Req, Status, Data) -> -spec list_tickets(cowboy_req:req()) -> {ok, binary(), cowboy_req:req()}.
Headers = #{ list_tickets(Req) ->
<<"content-type">> => <<"application/json">>, case handler_utils:auth_admin(Req) of
<<"access-control-allow-origin">> => <<"*">>, {ok, AdminId, Req1} ->
<<"access-control-expose-headers">> => <<"Content-Range">> Filters = parse_ticket_filters(Req1),
}, Pagination = handler_utils:parse_pagination_params(Req1),
Body = jsx:encode(Data), TicketsResult = case maps:get(status, Filters, undefined) of
cowboy_req:reply(Status, Headers, Body, Req), undefined -> logic_ticket:list_tickets(AdminId);
{ok, Body, []}. 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) -> %% @private Извлечь фильтры из query string.
Headers = #{ -spec parse_ticket_filters(cowboy_req:req()) -> map().
<<"content-type">> => <<"application/json">>, parse_ticket_filters(Req) ->
<<"access-control-allow-origin">> => <<"*">>, Qs = cowboy_req:parse_qs(Req),
<<"access-control-expose-headers">> => <<"Content-Range">> #{
}, status => proplists:get_value(<<"status">>, Qs),
Body = jsx:encode(#{error => Message}), assigned_to => proplists:get_value(<<"assigned_to">>, Qs),
cowboy_req:reply(Code, Headers, Body, Req), q => proplists:get_value(<<"q">>, Qs)
{ok, Body, []}. }.
%% @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). -module(admin_handler_user_by_id).
-include("records.hrl"). -behaviour(cowboy_handler).
-export([init/2]). -export([init/2]).
-export([trails/0]).
-include("records.hrl").
init(Req, _Opts) -> init(Req, _Opts) ->
case cowboy_req:method(Req) of case cowboy_req:method(Req) of
<<"GET">> -> get_user(Req); <<"GET">> -> get_user(Req);
<<"PUT">> -> update_user(Req); <<"PUT">> -> update_user(Req);
<<"DELETE">> -> delete_user(Req); <<"DELETE">> -> delete_user(Req);
_ -> send_error(Req, 405, <<"Method not allowed">>) _ -> handler_utils:send_error(Req, 405, <<"Method not allowed">>)
end. 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) -> get_user(Req) ->
case handler_auth:authenticate(Req) of case handler_utils:auth_admin(Req) of
{ok, AdminId, Req1} -> {ok, _AdminId, Req1} ->
case admin_utils:is_admin(AdminId) of UserId = cowboy_req:binding(id, Req1),
true -> case logic_user:get_user_admin(UserId) of
UserId = cowboy_req:binding(id, Req1), {ok, User} ->
case core_user:get_by_id(UserId) of handler_utils:send_json(Req1, 200, handler_utils:user_to_json(User));
{ok, User} -> {error, not_found} ->
send_json(Req1, 200, user_to_json(User)); handler_utils:send_error(Req1, 404, <<"User not found">>);
{error, not_found} -> {error, _} ->
send_error(Req1, 404, <<"User not found">>) handler_utils:send_error(Req1, 500, <<"Internal server error">>)
end;
false ->
send_error(Req1, 403, <<"Admin access required">>)
end; end;
{error, Code, Message, Req1} -> {error, Code, Msg, Req1} ->
send_error(Req1, Code, Message) handler_utils:send_error(Req1, Code, Msg)
end. end.
update_user(Req) -> update_user(Req) ->
case handler_auth:authenticate(Req) of case handler_utils:auth_admin(Req) of
{ok, AdminId, Req1} -> {ok, _AdminId, Req1} ->
case admin_utils:is_admin(AdminId) of UserId = cowboy_req:binding(id, Req1),
true -> {ok, Body, Req2} = cowboy_req:read_body(Req1),
UserId = cowboy_req:binding(id, Req1), try jsx:decode(Body, [return_maps]) of
{ok, Body, Req2} = cowboy_req:read_body(Req1), UpdatesMap when is_map(UpdatesMap) ->
try jsx:decode(Body, [return_maps]) of Updates = maps:to_list(UpdatesMap),
Updates when map_size(Updates) > 0 -> case logic_user:update_user_admin(UserId, Updates) of
% Проверка на наличие reason при изменении статуса {ok, User} ->
case maps:find(<<"status">>, Updates) of handler_utils:send_json(Req2, 200, handler_utils:user_to_json(User));
{ok, NewStatus} when NewStatus =:= <<"blocked">> orelse NewStatus =:= <<"active">> -> {error, not_found} ->
case maps:find(<<"reason">>, Updates) of handler_utils:send_error(Req2, 404, <<"User not found">>);
{ok, Reason} when byte_size(Reason) > 0 -> {error, _} ->
apply_updates(UserId, Updates, AdminId, Reason, Req2); handler_utils:send_error(Req2, 500, <<"Internal server error">>)
_ ->
send_error(Req2, 400, <<"Missing or empty reason">>)
end;
_ ->
apply_updates(UserId, Updates, AdminId, undefined, Req2)
end;
_ ->
send_error(Req2, 400, <<"Request body is empty">>)
catch
_:_ -> send_error(Req2, 400, <<"Invalid JSON">>)
end; end;
false -> _ ->
send_error(Req1, 403, <<"Admin access required">>) handler_utils:send_error(Req2, 400, <<"Invalid JSON">>)
catch
_:_ -> handler_utils:send_error(Req1, 400, <<"Invalid JSON format">>)
end; end;
{error, Code, Message, Req1} -> {error, Code, Msg, Req1} ->
send_error(Req1, Code, Message) handler_utils:send_error(Req1, Code, Msg)
end. end.
delete_user(Req) -> delete_user(Req) ->
case handler_auth:authenticate(Req) of case handler_utils:auth_admin(Req) of
{ok, AdminId, Req1} -> {ok, _AdminId, Req1} ->
case admin_utils:is_admin(AdminId) of UserId = cowboy_req:binding(id, Req1),
true -> case logic_user:delete_user_admin(UserId) of
UserId = cowboy_req:binding(id, Req1), {ok, _} ->
case core_user:delete(UserId) of handler_utils:send_json(Req1, 200, #{status => <<"deleted">>});
{ok, _} -> {error, not_found} ->
send_json(Req1, 200, #{status => <<"deleted">>}); handler_utils:send_error(Req1, 404, <<"User not found">>);
{error, not_found} -> {error, _} ->
send_error(Req1, 404, <<"User not found">>) handler_utils:send_error(Req1, 500, <<"Internal server error">>)
end;
false ->
send_error(Req1, 403, <<"Admin access required">>)
end; end;
{error, Code, Message, Req1} -> {error, Code, Msg, Req1} ->
send_error(Req1, Code, Message) handler_utils:send_error(Req1, Code, Msg)
end. end.
%% ── Вспомогательная функция обновления ────────────────────
apply_updates(UserId, Updates, AdminId, Reason, Req) ->
Converted = convert_updates(maps:to_list(Updates)),
case core_user:update(UserId, Converted) of
{ok, User} ->
% Логируем, если был указан reason
case Reason of
undefined -> ok;
_ ->
case core_admin:get_by_id(AdminId) of
{ok, Admin} ->
Action = case maps:get(<<"status">>, Updates, undefined) of
<<"blocked">> -> <<"block_user">>;
<<"active">> -> <<"unblock_user">>;
_ -> <<"update_user">>
end,
core_admin_audit:log(AdminId, Admin#admin.email, Admin#admin.role,
Action, <<"user">>, UserId, <<"127.0.0.1">>, Reason);
_ -> ok
end
end,
send_json(Req, 200, user_to_json(User));
{error, not_found} ->
send_error(Req, 404, <<"User not found">>);
{error, _} ->
send_error(Req, 500, <<"Internal server error">>)
end.
convert_updates(Updates) ->
lists:map(fun({<<"status">>, Value}) -> {status, binary_to_existing_atom(Value, utf8)};
({<<"role">>, Value}) -> {role, binary_to_existing_atom(Value, utf8)};
({<<"reason">>, Value}) -> {reason, Value};
(Other) -> Other
end, Updates).
user_to_json(User) ->
#{
id => User#user.id,
email => User#user.email,
role => atom_to_binary(User#user.role, utf8),
status => atom_to_binary(User#user.status, utf8),
reason => User#user.reason,
created_at => datetime_to_iso8601(User#user.created_at),
updated_at => datetime_to_iso8601(User#user.updated_at)
}.
datetime_to_iso8601({{Y,M,D},{H,Min,S}}) ->
iolist_to_binary(io_lib:format("~4..0B-~2..0B-~2..0BT~2..0B:~2..0B:~2..0BZ", [Y,M,D,H,Min,S]));
datetime_to_iso8601(_) -> null.
send_json(Req, Status, Data) ->
Headers = #{
<<"content-type">> => <<"application/json">>,
<<"access-control-allow-origin">> => <<"*">>,
<<"access-control-expose-headers">> => <<"Content-Range">>
},
Body = jsx:encode(Data),
cowboy_req:reply(Status, Headers, Body, Req),
{ok, Body, []}.
send_error(Req, Code, Message) ->
Headers = #{
<<"content-type">> => <<"application/json">>,
<<"access-control-allow-origin">> => <<"*">>,
<<"access-control-expose-headers">> => <<"Content-Range">>
},
Body = jsx:encode(#{error => Message}),
cowboy_req:reply(Code, Headers, Body, Req),
{ok, Body, []}.

View File

@@ -1,61 +1,90 @@
-module(admin_handler_users). -module(admin_handler_users).
-include("records.hrl"). -behaviour(cowboy_handler).
-export([init/2]). -export([init/2]).
-export([trails/0]).
-include("records.hrl").
init(Req, _Opts) -> init(Req, _Opts) ->
case cowboy_req:method(Req) of case cowboy_req:method(Req) of
<<"GET">> -> list_users(Req); <<"GET">> -> list_users(Req);
_ -> send_error(Req, 405, <<"Method not allowed">>) _ -> handler_utils:send_error(Req, 405, <<"Method not allowed">>)
end. end.
list_users(Req) -> trails() ->
case handler_auth:authenticate(Req) of [
{ok, AdminId, Req1} -> #{
case admin_utils:is_admin(AdminId) of path => <<"/v1/admin/users">>,
true -> method => <<"GET">>,
{ok, Users} = core_user:list_users(), description => <<"List all users (admin)">>,
send_json(Req1, 200, [user_to_map(U) || U <- Users]); tags => [<<"Users">>],
false -> parameters => [
send_error(Req1, 403, <<"Admin access required">>) #{name => <<"role">>, in => <<"query">>, schema => #{type => string, enum => [<<"user">>, <<"bot">>]}},
end; #{name => <<"status">>, in => <<"query">>, schema => #{type => string, enum => [<<"active">>, <<"frozen">>, <<"deleted">>]}},
{error, Code, Message, Req1} -> #{name => <<"q">>, in => <<"query">>, schema => #{type => string}, description => <<"Search by email or nickname">>},
send_error(Req1, Code, Message) #{name => <<"limit">>, in => <<"query">>, schema => #{type => integer}},
end. #{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), type => object,
email => maps:get(email, User), properties => #{
role => maps:get(role, User, <<"user">>), id => #{type => string},
status => maps:get(status, User, <<"active">>), email => #{type => string, format => <<"email">>},
created_at => datetime_to_iso8601(maps:get(created_at, User)), role => #{type => string, enum => [<<"user">>, <<"bot">>]},
updated_at => datetime_to_iso8601(maps:get(updated_at, User)) status => #{type => string, enum => [<<"active">>, <<"frozen">>, <<"deleted">>]},
}; reason => #{type => string, nullable => true},
user_to_map(User) -> nickname => #{type => string, nullable => true},
#{ avatar_url => #{type => string, nullable => true},
id => User#user.id, timezone => #{type => string, nullable => true},
email => User#user.email, language => #{type => string, nullable => true},
role => atom_to_binary(User#user.role, utf8), social_links => #{type => array, items => #{type => string}, nullable => true},
status => atom_to_binary(User#user.status, utf8), phone => #{type => string, nullable => true},
created_at => datetime_to_iso8601(User#user.created_at), preferences => #{type => object, nullable => true},
updated_at => datetime_to_iso8601(User#user.updated_at) 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}}) -> list_users(Req) ->
iolist_to_binary(io_lib:format("~4..0B-~2..0B-~2..0BT~2..0B:~2..0B:~2..0BZ", [Y,M,D,H,Min,S])); case handler_utils:auth_admin(Req) of
datetime_to_iso8601(_) -> null. {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) -> parse_user_filters(Req) ->
Headers = #{ Qs = cowboy_req:parse_qs(Req),
<<"content-type">> => <<"application/json">>, #{
<<"access-control-allow-origin">> => <<"*">>, role => proplists:get_value(<<"role">>, Qs),
<<"access-control-expose-headers">> => <<"Content-Range">> status => proplists:get_value(<<"status">>, Qs),
}, q => proplists:get_value(<<"q">>, Qs)
Body = jsx:encode(Data), }.
cowboy_req:reply(Status, Headers, Body, Req),
{ok, Body, []}.
send_error(Req, Status, Message) -> pagination_headers(#{limit := Limit, offset := Offset}, Total) ->
Body = jsx:encode(#{error => Message}), RangeEnd = min(Offset + Limit - 1, Total - 1),
cowboy_req:reply(Status, #{<<"content-type">> => <<"application/json">>}, Body, Req), #{
{ok, Body, []}. <<"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). -module(admin_ws_handler).
-behaviour(cowboy_websocket). -behaviour(cowboy_websocket).
-export([init/2]). -export([init/2]).
-export([websocket_init/1]). -export([websocket_init/1]).
-export([websocket_handle/2]). -export([websocket_handle/2]).
@@ -10,6 +17,11 @@
admin_id :: binary() | undefined 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) -> init(Req, _Opts) ->
Qs = cowboy_req:parse_qs(Req), Qs = cowboy_req:parse_qs(Req),
case proplists:get_value(<<"token">>, Qs) of case proplists:get_value(<<"token">>, Qs) of
@@ -42,11 +54,15 @@ init(Req, _Opts) ->
end end
end. end.
%% @doc Вызывается после установки WebSocket-соединения.
-spec websocket_init(#state{}) -> {ok, #state{}}.
websocket_init(State) -> websocket_init(State) ->
io:format("[ADMIN_WS] WebSocket initialized for admin ~s~n", [State#state.admin_id]), io:format("[ADMIN_WS] WebSocket initialized for admin ~s~n", [State#state.admin_id]),
pg:join(eventhub_admin_ws, self()), pg:join(eventhub_admin_ws, self()),
{ok, State}. {ok, State}.
%% @doc Обрабатывает входящие текстовые сообщения (subscribe/unsubscribe/ping).
-spec websocket_handle(term(), #state{}) -> {ok, #state{}} | {reply, {text, binary()}, #state{}}.
websocket_handle({text, Msg}, State) -> websocket_handle({text, Msg}, State) ->
io:format("[ADMIN_WS] Received: ~s~n", [Msg]), io:format("[ADMIN_WS] Received: ~s~n", [Msg]),
try jsx:decode(Msg, [return_maps]) of try jsx:decode(Msg, [return_maps]) of
@@ -63,22 +79,25 @@ websocket_handle({text, Msg}, State) ->
_ -> _ ->
{ok, State} {ok, State}
catch catch
_:_ -> _:_ -> {ok, State}
{ok, State}
end; end;
websocket_handle(_Frame, State) -> websocket_handle(_Frame, State) ->
{ok, State}. {ok, State}.
%% @doc Отправляет административное уведомление через WebSocket.
-spec websocket_info(term(), #state{}) -> {reply, {text, binary()}, #state{}} | {ok, #state{}}.
websocket_info({admin_notification, Type, Data}, State) -> websocket_info({admin_notification, Type, Data}, State) ->
Msg = jsx:encode(#{ Msg = jsx:encode(#{
type => Type, type => Type,
data => Data, data => Data,
timestamp => os:system_time(seconds) timestamp => os:system_time(seconds)
}), }),
{reply, {text, Msg}, State}; {reply, {text, Msg}, State};
websocket_info(_Info, State) -> websocket_info(_Info, State) ->
{ok, State}. {ok, State}.
%% @private Вызывается при закрытии соединения.
-spec terminate(term(), cowboy_req:req(), #state{}) -> ok.
terminate(_Reason, _Req, _State) -> terminate(_Reason, _Req, _State) ->
pg:leave(eventhub_admin_ws, self()), pg:leave(eventhub_admin_ws, self()),
ok. 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]). 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([can_review/3, update_target_rating/2, can_moderate_review/2]).
-export([list_admin_reviews/1, bulk_update_status/1]). -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) -> create_review(UserId, TargetType, TargetId, Rating, Comment) ->
@@ -200,8 +201,9 @@ can_moderate_review(UserId, ReviewId) ->
%%% @end %%% @end
%%%------------------------------------------------------------------- %%%-------------------------------------------------------------------
list_admin_reviews(Filters) -> list_admin_reviews(Filters) ->
AllReviews = core_review:list_all(), Reviews = core_review:list_all(), % возвращает список
apply_filters(AllReviews, Filters). Filtered = apply_filters(Reviews, Filters),
{ok, Filtered}.
%% Вспомогательная функция: фильтрация списка по proplist %% Вспомогательная функция: фильтрация списка по proplist
apply_filters(Reviews, []) -> apply_filters(Reviews, []) ->
@@ -271,4 +273,53 @@ update_target_rating(event, EventId) ->
update_target_rating(calendar, CalendarId) -> update_target_rating(calendar, CalendarId) ->
{Avg, Count} = core_review:get_average_rating(calendar, CalendarId), {Avg, Count} = core_review:get_average_rating(calendar, CalendarId),
core_calendar:update(CalendarId, [{rating_avg, Avg}, {rating_count, Count}]); core_calendar:update(CalendarId, [{rating_avg, Avg}, {rating_count, Count}]);
update_target_rating(_, _) -> ok. 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, resolve_ticket/3,
close_ticket/2, close_ticket/2,
get_statistics/1]). get_statistics/1]).
-export([delete_ticket/2]).
%% Зарегистрировать ошибку (создать или обновить тикет) %% Зарегистрировать ошибку (создать или обновить тикет)
report_error(ErrorMessage, Stacktrace, Context) -> report_error(ErrorMessage, Stacktrace, Context) ->
@@ -93,6 +94,13 @@ close_ticket(AdminId, TicketId) ->
false -> {error, access_denied} false -> {error, access_denied}
end. 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) -> get_statistics(AdminId) ->
case admin_utils:is_admin(AdminId) of 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() -> admin() ->
Modules = [ Modules = [
% ================== БАЗОВЫЕ ==================
admin_handler_health,
admin_handler_stats,
admin_handler_login,
% ================== ПОЛЬЗОВАТЕЛИ ==================
admin_handler_users,
admin_handler_user_by_id,
% ================== СОБЫТИЯ ==================
admin_handler_events, admin_handler_events,
admin_handler_event_by_id admin_handler_event_by_id,
%% другие админские обработчики с trails/0 % ================== ОТЧЁТЫ ==================
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). lists:flatmap(fun trails_from_module/1, Modules).

View File

@@ -131,7 +131,7 @@ test() ->
<<"error_message">> => <<"Test error">>, <<"error_message">> => <<"Test error">>,
<<"stacktrace">> => <<"trace">> <<"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]), #{<<"id">> := TicketId} = jsx:decode(list_to_binary(TicketResp), [return_maps]),
ct:pal(" OK (TicketId: ~p)~n", [TicketId]), ct:pal(" OK (TicketId: ~p)~n", [TicketId]),
ct:pal("OK~n"), ct:pal("OK~n"),
@@ -164,11 +164,8 @@ test() ->
%% TEST 18: Create subscription %% TEST 18: Create subscription
ct:pal(" TEST 18: Create subscription... "), ct:pal(" TEST 18: Create subscription... "),
SubBody = jsx:encode(#{ SubBody = jsx:encode(#{action => <<"activate">>, plan => <<"monthly">>, payment_info => #{card => <<"4242">>}}),
<<"user_id">> => UserId, {ok, {{_, 201, _}, _, SubResp}} = httpc:request(post, {UserURL ++ "/v1/subscription", [{"Authorization", "Bearer " ++ binary_to_list(UserToken)}], "application/json", SubBody}, [], []),
<<"plan">> => <<"monthly">>
}),
{ok, {{_, 201, _}, _, SubResp}} = httpc:request(post, {AdminURL ++ "/v1/admin/subscriptions", [{"Authorization", "Bearer " ++ binary_to_list(AdminToken)}], "application/json", SubBody}, [], []),
#{<<"id">> := SubId} = jsx:decode(list_to_binary(SubResp), [return_maps]), #{<<"id">> := SubId} = jsx:decode(list_to_binary(SubResp), [return_maps]),
ct:pal("OK~n"), ct:pal("OK~n"),