Рефакторинг обработчиков. Часть 1 #21
This commit is contained in:
@@ -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).
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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() ->
|
||||||
@@ -49,6 +49,3 @@ update_banned_word(OldWord, NewWord) ->
|
|||||||
{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)).
|
|
||||||
@@ -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,
|
||||||
@@ -99,7 +98,3 @@ delete(Id) ->
|
|||||||
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}).
|
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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).
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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)).
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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) ==================
|
||||||
|
|||||||
@@ -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">>
|
||||||
|
}.
|
||||||
@@ -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">>
|
||||||
|
}.
|
||||||
@@ -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, []}.
|
|
||||||
@@ -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, []}.
|
|
||||||
@@ -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, []}.
|
|
||||||
@@ -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}
|
||||||
|
}
|
||||||
|
}}}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
].
|
||||||
@@ -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">>}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
].
|
||||||
@@ -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])).
|
||||||
@@ -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) ->
|
|
||||||
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">>.
|
client_ip() -> <<"127.0.0.1">>.
|
||||||
|
|
||||||
calendar_to_json(C) ->
|
|
||||||
#{
|
|
||||||
id => C#calendar.id,
|
|
||||||
title => C#calendar.title,
|
|
||||||
status => atom_to_binary(C#calendar.status, utf8),
|
|
||||||
reason => C#calendar.reason
|
|
||||||
}.
|
|
||||||
|
|
||||||
event_to_json(E) ->
|
|
||||||
#{
|
|
||||||
id => E#event.id,
|
|
||||||
title => E#event.title,
|
|
||||||
status => atom_to_binary(E#event.status, utf8),
|
|
||||||
reason => E#event.reason
|
|
||||||
}.
|
|
||||||
|
|
||||||
review_to_json(R) ->
|
|
||||||
#{
|
|
||||||
id => R#review.id,
|
|
||||||
status => atom_to_binary(R#review.status, utf8),
|
|
||||||
reason => R#review.reason
|
|
||||||
}.
|
|
||||||
|
|
||||||
user_to_json(U) ->
|
|
||||||
#{
|
|
||||||
id => U#user.id,
|
|
||||||
email => U#user.email,
|
|
||||||
status => atom_to_binary(U#user.status, utf8),
|
|
||||||
reason => U#user.reason
|
|
||||||
}.
|
|
||||||
|
|
||||||
send_json(Req, Status, Data) ->
|
|
||||||
Body = jsx:encode(Data),
|
|
||||||
cowboy_req:reply(Status, #{<<"content-type">> => <<"application/json">>}, Body, Req),
|
|
||||||
{ok, Body, []}.
|
|
||||||
|
|
||||||
send_error(Req, Status, Message) ->
|
|
||||||
Body = jsx:encode(#{error => Message}),
|
|
||||||
cowboy_req:reply(Status, #{<<"content-type">> => <<"application/json">>}, Body, Req),
|
|
||||||
{ok, Body, []}.
|
|
||||||
@@ -1,109 +1,130 @@
|
|||||||
|
%%%-------------------------------------------------------------------
|
||||||
|
%%% @doc Административный обработчик конкретной жалобы.
|
||||||
|
%%% GET – получить жалобу по ID.
|
||||||
|
%%% PUT – обновить статус жалобы.
|
||||||
|
%%% @end
|
||||||
|
%%%-------------------------------------------------------------------
|
||||||
-module(admin_handler_report_by_id).
|
-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, []}.
|
|
||||||
@@ -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">>
|
||||||
|
}.
|
||||||
@@ -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, []}.
|
|
||||||
@@ -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, []}.
|
|
||||||
@@ -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, []}.
|
|
||||||
@@ -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">>
|
||||||
|
}.
|
||||||
163
src/handlers/admin/admin_handler_subscriptions_by_id.erl
Normal file
163
src/handlers/admin/admin_handler_subscriptions_by_id.erl
Normal file
@@ -0,0 +1,163 @@
|
|||||||
|
%%%-------------------------------------------------------------------
|
||||||
|
%%% @doc Административный обработчик конкретной подписки.
|
||||||
|
%%% GET – получить подписку по ID.
|
||||||
|
%%% PUT – обновить подписку (статус, план, дата окончания).
|
||||||
|
%%% DELETE – удалить подписку.
|
||||||
|
%%% @end
|
||||||
|
%%%-------------------------------------------------------------------
|
||||||
|
-module(admin_handler_subscriptions_by_id).
|
||||||
|
-behaviour(cowboy_handler).
|
||||||
|
|
||||||
|
-export([init/2]).
|
||||||
|
-export([trails/0]).
|
||||||
|
|
||||||
|
-include("records.hrl").
|
||||||
|
|
||||||
|
-spec init(cowboy_req:req(), any()) -> {ok, cowboy_req:req(), any()}.
|
||||||
|
init(Req, _Opts) ->
|
||||||
|
case cowboy_req:method(Req) of
|
||||||
|
<<"GET">> -> get_subscription(Req);
|
||||||
|
<<"PUT">> -> update_subscription(Req);
|
||||||
|
<<"DELETE">> -> delete_subscription(Req);
|
||||||
|
_ -> handler_utils:send_error(Req, 405, <<"Method not allowed">>)
|
||||||
|
end.
|
||||||
|
|
||||||
|
-spec trails() -> [map()].
|
||||||
|
trails() ->
|
||||||
|
BaseParams = [
|
||||||
|
#{
|
||||||
|
name => <<"id">>,
|
||||||
|
in => <<"path">>,
|
||||||
|
description => <<"Subscription ID">>,
|
||||||
|
required => true,
|
||||||
|
schema => #{type => string}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
[
|
||||||
|
#{ % GET by id
|
||||||
|
path => <<"/v1/admin/subscriptions/:id">>,
|
||||||
|
method => <<"GET">>,
|
||||||
|
description => <<"Get subscription by ID (admin)">>,
|
||||||
|
tags => [<<"Subscriptions">>],
|
||||||
|
parameters => BaseParams,
|
||||||
|
responses => #{
|
||||||
|
200 => #{
|
||||||
|
description => <<"Subscription details">>,
|
||||||
|
content => #{<<"application/json">> => #{schema => subscription_schema()}}
|
||||||
|
},
|
||||||
|
404 => #{description => <<"Subscription not found">>}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
#{ % PUT update
|
||||||
|
path => <<"/v1/admin/subscriptions/:id">>,
|
||||||
|
method => <<"PUT">>,
|
||||||
|
description => <<"Update subscription (admin)">>,
|
||||||
|
tags => [<<"Subscriptions">>],
|
||||||
|
parameters => BaseParams,
|
||||||
|
requestBody => #{
|
||||||
|
required => true,
|
||||||
|
content => #{<<"application/json">> => #{schema => subscription_update_schema()}}
|
||||||
|
},
|
||||||
|
responses => #{
|
||||||
|
200 => #{description => <<"Updated subscription">>},
|
||||||
|
404 => #{description => <<"Subscription not found">>}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
#{ % DELETE
|
||||||
|
path => <<"/v1/admin/subscriptions/:id">>,
|
||||||
|
method => <<"DELETE">>,
|
||||||
|
description => <<"Delete subscription (admin)">>,
|
||||||
|
tags => [<<"Subscriptions">>],
|
||||||
|
parameters => BaseParams,
|
||||||
|
responses => #{
|
||||||
|
200 => #{description => <<"Subscription deleted">>},
|
||||||
|
404 => #{description => <<"Subscription not found">>}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
].
|
||||||
|
|
||||||
|
subscription_schema() ->
|
||||||
|
#{
|
||||||
|
type => object,
|
||||||
|
properties => #{
|
||||||
|
id => #{type => string},
|
||||||
|
user_id => #{type => string},
|
||||||
|
plan => #{type => string, enum => [<<"monthly">>, <<"quarterly">>, <<"biannual">>, <<"annual">>]},
|
||||||
|
status => #{type => string, enum => [<<"active">>, <<"expired">>, <<"cancelled">>]},
|
||||||
|
trial_used => #{type => boolean},
|
||||||
|
started_at => #{type => string, format => <<"date-time">>},
|
||||||
|
expires_at => #{type => string, format => <<"date-time">>},
|
||||||
|
created_at => #{type => string, format => <<"date-time">>},
|
||||||
|
updated_at => #{type => string, format => <<"date-time">>}
|
||||||
|
}
|
||||||
|
}.
|
||||||
|
|
||||||
|
subscription_update_schema() ->
|
||||||
|
#{
|
||||||
|
type => object,
|
||||||
|
properties => #{
|
||||||
|
plan => #{type => string, enum => [<<"monthly">>, <<"quarterly">>, <<"biannual">>, <<"annual">>]},
|
||||||
|
status => #{type => string, enum => [<<"active">>, <<"expired">>, <<"cancelled">>]},
|
||||||
|
trial_used => #{type => boolean},
|
||||||
|
expires_at => #{type => string, format => <<"date-time">>, description => <<"New expiration date">>}
|
||||||
|
}
|
||||||
|
}.
|
||||||
|
|
||||||
|
%%% Internal functions
|
||||||
|
|
||||||
|
get_subscription(Req) ->
|
||||||
|
case handler_utils:auth_admin(Req) of
|
||||||
|
{ok, _AdminId, Req1} ->
|
||||||
|
Id = cowboy_req:binding(id, Req1),
|
||||||
|
case core_subscription:get_by_id(Id) of
|
||||||
|
{ok, Sub} ->
|
||||||
|
handler_utils:send_json(Req1, 200, handler_utils:subscription_to_json(Sub));
|
||||||
|
{error, not_found} ->
|
||||||
|
handler_utils:send_error(Req1, 404, <<"Subscription not found">>);
|
||||||
|
{error, _} ->
|
||||||
|
handler_utils:send_error(Req1, 500, <<"Internal server error">>)
|
||||||
|
end;
|
||||||
|
{error, Code, Msg, Req1} ->
|
||||||
|
handler_utils:send_error(Req1, Code, Msg)
|
||||||
|
end.
|
||||||
|
|
||||||
|
update_subscription(Req) ->
|
||||||
|
case handler_utils:auth_admin(Req) of
|
||||||
|
{ok, _AdminId, Req1} ->
|
||||||
|
Id = cowboy_req:binding(id, Req1),
|
||||||
|
{ok, Body, Req2} = cowboy_req:read_body(Req1),
|
||||||
|
try jsx:decode(Body, [return_maps]) of
|
||||||
|
Data when is_map(Data) ->
|
||||||
|
% Передаём карту напрямую, как ожидает core_subscription
|
||||||
|
case core_subscription:update_subscription(Id, Data) of
|
||||||
|
{ok, Updated} ->
|
||||||
|
handler_utils:send_json(Req2, 200, handler_utils:subscription_to_json(Updated));
|
||||||
|
{error, not_found} ->
|
||||||
|
handler_utils:send_error(Req2, 404, <<"Subscription not found">>);
|
||||||
|
{error, _} ->
|
||||||
|
handler_utils:send_error(Req2, 500, <<"Internal server error">>)
|
||||||
|
end;
|
||||||
|
_ ->
|
||||||
|
handler_utils:send_error(Req2, 400, <<"Invalid JSON">>)
|
||||||
|
catch
|
||||||
|
_:_ -> handler_utils:send_error(Req1, 400, <<"Invalid JSON format">>)
|
||||||
|
end;
|
||||||
|
{error, Code, Msg, Req1} ->
|
||||||
|
handler_utils:send_error(Req1, Code, Msg)
|
||||||
|
end.
|
||||||
|
|
||||||
|
delete_subscription(Req) ->
|
||||||
|
case handler_utils:auth_admin(Req) of
|
||||||
|
{ok, _AdminId, Req1} ->
|
||||||
|
Id = cowboy_req:binding(id, Req1),
|
||||||
|
case core_subscription:delete_subscription(Id) of
|
||||||
|
{ok, _} ->
|
||||||
|
handler_utils:send_json(Req1, 200, #{status => <<"deleted">>});
|
||||||
|
{error, not_found} ->
|
||||||
|
handler_utils:send_error(Req1, 404, <<"Subscription not found">>);
|
||||||
|
{error, _} ->
|
||||||
|
handler_utils:send_error(Req1, 500, <<"Internal server error">>)
|
||||||
|
end;
|
||||||
|
{error, Code, Msg, Req1} ->
|
||||||
|
handler_utils:send_error(Req1, Code, Msg)
|
||||||
|
end.
|
||||||
@@ -1,106 +1,206 @@
|
|||||||
|
%%%-------------------------------------------------------------------
|
||||||
|
%%% @doc Административный обработчик конкретного тикета.
|
||||||
|
%%% GET – получить тикет по ID.
|
||||||
|
%%% PUT – обновить тикет.
|
||||||
|
%%% DELETE – удалить тикет.
|
||||||
|
%%% @end
|
||||||
|
%%%-------------------------------------------------------------------
|
||||||
-module(admin_handler_ticket_by_id).
|
-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, []}.
|
|
||||||
@@ -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, []}.
|
|
||||||
@@ -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">>
|
||||||
|
}.
|
||||||
@@ -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, []}.
|
|
||||||
@@ -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">>
|
||||||
|
}.
|
||||||
@@ -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.
|
||||||
@@ -1,88 +0,0 @@
|
|||||||
-module(handler_banned_words).
|
|
||||||
-include("records.hrl").
|
|
||||||
|
|
||||||
-export([init/2]).
|
|
||||||
|
|
||||||
init(Req, Opts) ->
|
|
||||||
handle(Req, Opts).
|
|
||||||
|
|
||||||
handle(Req, _Opts) ->
|
|
||||||
case cowboy_req:method(Req) of
|
|
||||||
<<"GET">> -> list_banned_words(Req);
|
|
||||||
<<"POST">> -> add_banned_word(Req);
|
|
||||||
<<"DELETE">> -> remove_banned_word(Req);
|
|
||||||
_ -> send_error(Req, 405, <<"Method not allowed">>)
|
|
||||||
end.
|
|
||||||
|
|
||||||
%% GET /v1/admin/banned-words - список запрещённых слов
|
|
||||||
list_banned_words(Req) ->
|
|
||||||
case handler_auth:authenticate(Req) of
|
|
||||||
{ok, AdminId, Req1} ->
|
|
||||||
case logic_moderation:list_banned_words(AdminId) of
|
|
||||||
{ok, Words} ->
|
|
||||||
send_json(Req1, 200, Words);
|
|
||||||
{error, access_denied} ->
|
|
||||||
send_error(Req1, 403, <<"Admin access required">>);
|
|
||||||
{error, _} ->
|
|
||||||
send_error(Req1, 500, <<"Internal server error">>)
|
|
||||||
end;
|
|
||||||
{error, Code, Message, Req1} ->
|
|
||||||
send_error(Req1, Code, Message)
|
|
||||||
end.
|
|
||||||
|
|
||||||
%% POST /v1/admin/banned-words - добавить запрещённое слово
|
|
||||||
add_banned_word(Req) ->
|
|
||||||
case handler_auth:authenticate(Req) of
|
|
||||||
{ok, AdminId, Req1} ->
|
|
||||||
{ok, Body, Req2} = cowboy_req:read_body(Req1),
|
|
||||||
try jsx:decode(Body, [return_maps]) of
|
|
||||||
#{<<"word">> := Word} ->
|
|
||||||
case logic_moderation:add_banned_word(AdminId, Word) of
|
|
||||||
{ok, _} ->
|
|
||||||
send_json(Req2, 201, #{word => Word, status => <<"added">>});
|
|
||||||
{error, already_exists} ->
|
|
||||||
send_error(Req2, 409, <<"Word already exists">>);
|
|
||||||
{error, access_denied} ->
|
|
||||||
send_error(Req2, 403, <<"Admin access required">>);
|
|
||||||
{error, _} ->
|
|
||||||
send_error(Req2, 500, <<"Internal server error">>)
|
|
||||||
end;
|
|
||||||
_ ->
|
|
||||||
send_error(Req2, 400, <<"Missing 'word' field">>)
|
|
||||||
catch
|
|
||||||
_:_ ->
|
|
||||||
send_error(Req2, 400, <<"Invalid JSON format">>)
|
|
||||||
end;
|
|
||||||
{error, Code, Message, Req1} ->
|
|
||||||
send_error(Req1, Code, Message)
|
|
||||||
end.
|
|
||||||
|
|
||||||
%% DELETE /v1/admin/banned-words - удалить запрещённое слово
|
|
||||||
remove_banned_word(Req) ->
|
|
||||||
case handler_auth:authenticate(Req) of
|
|
||||||
{ok, AdminId, Req1} ->
|
|
||||||
Word = cowboy_req:binding(word, Req1),
|
|
||||||
case logic_moderation:remove_banned_word(AdminId, Word) of
|
|
||||||
{ok, removed} ->
|
|
||||||
send_json(Req1, 200, #{word => Word, status => <<"removed">>});
|
|
||||||
{error, not_found} ->
|
|
||||||
send_error(Req1, 404, <<"Word not found">>);
|
|
||||||
{error, access_denied} ->
|
|
||||||
send_error(Req1, 403, <<"Admin access required">>);
|
|
||||||
{error, _} ->
|
|
||||||
send_error(Req1, 500, <<"Internal server error">>)
|
|
||||||
end;
|
|
||||||
{error, Code, Message, Req1} ->
|
|
||||||
send_error(Req1, Code, Message)
|
|
||||||
end.
|
|
||||||
|
|
||||||
%% Вспомогательные функции
|
|
||||||
send_json(Req, Status, Data) ->
|
|
||||||
Body = jsx:encode(Data),
|
|
||||||
cowboy_req:reply(Status, #{<<"content-type">> => <<"application/json">>}, Body, Req),
|
|
||||||
{ok, Body, []}.
|
|
||||||
|
|
||||||
send_error(Req, Status, Message) ->
|
|
||||||
Body = jsx:encode(#{error => Message}),
|
|
||||||
cowboy_req:reply(Status, #{<<"content-type">> => <<"application/json">>}, Body, Req),
|
|
||||||
{ok, Body, []}.
|
|
||||||
@@ -1,85 +0,0 @@
|
|||||||
-module(handler_report_by_id).
|
|
||||||
-include("records.hrl").
|
|
||||||
|
|
||||||
-export([init/2]).
|
|
||||||
|
|
||||||
init(Req, Opts) ->
|
|
||||||
handle(Req, Opts).
|
|
||||||
|
|
||||||
handle(Req, _Opts) ->
|
|
||||||
case cowboy_req:method(Req) of
|
|
||||||
<<"PUT">> -> resolve_report(Req);
|
|
||||||
_ -> send_error(Req, 405, <<"Method not allowed">>)
|
|
||||||
end.
|
|
||||||
|
|
||||||
%% PUT /v1/admin/reports/:id - рассмотрение жалобы
|
|
||||||
resolve_report(Req) ->
|
|
||||||
case handler_auth:authenticate(Req) of
|
|
||||||
{ok, AdminId, Req1} ->
|
|
||||||
ReportId = cowboy_req:binding(id, Req1),
|
|
||||||
{ok, Body, Req2} = cowboy_req:read_body(Req1),
|
|
||||||
try jsx:decode(Body, [return_maps]) of
|
|
||||||
#{<<"action">> := Action} ->
|
|
||||||
ActionAtom = case Action of
|
|
||||||
<<"review">> -> reviewed;
|
|
||||||
<<"dismiss">> -> dismissed;
|
|
||||||
_ -> undefined
|
|
||||||
end,
|
|
||||||
case ActionAtom of
|
|
||||||
undefined ->
|
|
||||||
send_error(Req2, 400, <<"Invalid action. Use 'review' or 'dismiss'">>);
|
|
||||||
_ ->
|
|
||||||
case logic_moderation:resolve_report(AdminId, ReportId, ActionAtom) of
|
|
||||||
{ok, Report} ->
|
|
||||||
Response = report_to_json(Report),
|
|
||||||
send_json(Req2, 200, Response);
|
|
||||||
{error, access_denied} ->
|
|
||||||
send_error(Req2, 403, <<"Admin access required">>);
|
|
||||||
{error, already_resolved} ->
|
|
||||||
send_error(Req2, 409, <<"Report already resolved">>);
|
|
||||||
{error, not_found} ->
|
|
||||||
send_error(Req2, 404, <<"Report not found">>);
|
|
||||||
{error, _} ->
|
|
||||||
send_error(Req2, 500, <<"Internal server error">>)
|
|
||||||
end
|
|
||||||
end;
|
|
||||||
_ ->
|
|
||||||
send_error(Req2, 400, <<"Missing action field">>)
|
|
||||||
catch
|
|
||||||
_:_ ->
|
|
||||||
send_error(Req2, 400, <<"Invalid JSON format">>)
|
|
||||||
end;
|
|
||||||
{error, Code, Message, Req1} ->
|
|
||||||
send_error(Req1, Code, Message)
|
|
||||||
end.
|
|
||||||
|
|
||||||
%% Вспомогательные функции
|
|
||||||
report_to_json(Report) ->
|
|
||||||
#{
|
|
||||||
id => Report#report.id,
|
|
||||||
reporter_id => Report#report.reporter_id,
|
|
||||||
target_type => Report#report.target_type,
|
|
||||||
target_id => Report#report.target_id,
|
|
||||||
reason => Report#report.reason,
|
|
||||||
status => Report#report.status,
|
|
||||||
created_at => datetime_to_iso8601(Report#report.created_at),
|
|
||||||
resolved_at => case Report#report.resolved_at of
|
|
||||||
undefined -> null;
|
|
||||||
Dt -> datetime_to_iso8601(Dt)
|
|
||||||
end,
|
|
||||||
resolved_by => Report#report.resolved_by
|
|
||||||
}.
|
|
||||||
|
|
||||||
datetime_to_iso8601({{Year, Month, Day}, {Hour, Minute, Second}}) ->
|
|
||||||
iolist_to_binary(io_lib:format("~4..0B-~2..0B-~2..0BT~2..0B:~2..0B:~2..0BZ",
|
|
||||||
[Year, Month, Day, Hour, Minute, Second])).
|
|
||||||
|
|
||||||
send_json(Req, Status, Data) ->
|
|
||||||
Body = jsx:encode(Data),
|
|
||||||
cowboy_req:reply(Status, #{<<"content-type">> => <<"application/json">>}, Body, Req),
|
|
||||||
{ok, Body, []}.
|
|
||||||
|
|
||||||
send_error(Req, Status, Message) ->
|
|
||||||
Body = jsx:encode(#{error => Message}),
|
|
||||||
cowboy_req:reply(Status, #{<<"content-type">> => <<"application/json">>}, Body, Req),
|
|
||||||
{ok, Body, []}.
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
-module(handler_ticket_stats).
|
|
||||||
-include("records.hrl").
|
|
||||||
|
|
||||||
-export([init/2]).
|
|
||||||
|
|
||||||
init(Req, Opts) ->
|
|
||||||
handle(Req, Opts).
|
|
||||||
|
|
||||||
handle(Req, _Opts) ->
|
|
||||||
case cowboy_req:method(Req) of
|
|
||||||
<<"GET">> -> get_statistics(Req);
|
|
||||||
_ -> send_error(Req, 405, <<"Method not allowed">>)
|
|
||||||
end.
|
|
||||||
|
|
||||||
%% GET /v1/admin/tickets/stats - статистика по тикетам
|
|
||||||
get_statistics(Req) ->
|
|
||||||
case handler_auth:authenticate(Req) of
|
|
||||||
{ok, AdminId, Req1} ->
|
|
||||||
case logic_ticket:get_statistics(AdminId) of
|
|
||||||
Stats when is_map(Stats) ->
|
|
||||||
send_json(Req1, 200, Stats);
|
|
||||||
{error, access_denied} ->
|
|
||||||
send_error(Req1, 403, <<"Admin access required">>);
|
|
||||||
{error, _} ->
|
|
||||||
send_error(Req1, 500, <<"Internal server error">>)
|
|
||||||
end;
|
|
||||||
{error, Code, Message, Req1} ->
|
|
||||||
send_error(Req1, Code, Message)
|
|
||||||
end.
|
|
||||||
|
|
||||||
send_json(Req, Status, Data) ->
|
|
||||||
Body = jsx:encode(Data),
|
|
||||||
cowboy_req:reply(Status, #{<<"content-type">> => <<"application/json">>}, Body, Req),
|
|
||||||
{ok, Body, []}.
|
|
||||||
|
|
||||||
send_error(Req, Status, Message) ->
|
|
||||||
Body = jsx:encode(#{error => Message}),
|
|
||||||
cowboy_req:reply(Status, #{<<"content-type">> => <<"application/json">>}, Body, Req),
|
|
||||||
{ok, Body, []}.
|
|
||||||
378
src/handlers/handler_utils.erl
Normal file
378
src/handlers/handler_utils.erl
Normal file
@@ -0,0 +1,378 @@
|
|||||||
|
%%%-------------------------------------------------------------------
|
||||||
|
%%% @doc Общие утилиты для HTTP-обработчиков.
|
||||||
|
%%% Содержит повторяющиеся функции, которые раньше копировались
|
||||||
|
%%% в каждый обработчик: аутентификация, отправка ответов,
|
||||||
|
%%% парсинг параметров, сериализация записей и генерация трейлов.
|
||||||
|
%%% @end
|
||||||
|
%%%-------------------------------------------------------------------
|
||||||
|
-module(handler_utils).
|
||||||
|
|
||||||
|
-export([
|
||||||
|
auth_admin/1,
|
||||||
|
auth_user/1,
|
||||||
|
send_json/3,
|
||||||
|
send_json/4,
|
||||||
|
send_error/3,
|
||||||
|
parse_pagination_params/1,
|
||||||
|
parse_int_qs/2,
|
||||||
|
parse_datetime_qs/1,
|
||||||
|
parse_datetime/1,
|
||||||
|
event_to_json/1,
|
||||||
|
user_to_json/1,
|
||||||
|
review_to_json/1,
|
||||||
|
report_to_json/1,
|
||||||
|
ticket_to_json/1,
|
||||||
|
calendar_to_json/1,
|
||||||
|
subscription_to_json/1,
|
||||||
|
trails_for_crud/4
|
||||||
|
]).
|
||||||
|
|
||||||
|
-include("records.hrl").
|
||||||
|
|
||||||
|
%%%===================================================================
|
||||||
|
%%% Аутентификация и авторизация
|
||||||
|
%%%===================================================================
|
||||||
|
|
||||||
|
%% @doc Проверяет, что запрос содержит валидный токен администратора.
|
||||||
|
-spec auth_admin(cowboy_req:req()) ->
|
||||||
|
{ok, binary(), cowboy_req:req()} | {error, integer(), binary(), cowboy_req:req()}.
|
||||||
|
auth_admin(Req) ->
|
||||||
|
case handler_auth:authenticate(Req) of
|
||||||
|
{ok, UserId, Req1} ->
|
||||||
|
case admin_utils:is_admin(UserId) of
|
||||||
|
true -> {ok, UserId, Req1};
|
||||||
|
false -> {error, 403, <<"Admin access required">>, Req1}
|
||||||
|
end;
|
||||||
|
{error, Code, Msg, Req1} ->
|
||||||
|
{error, Code, Msg, Req1}
|
||||||
|
end.
|
||||||
|
|
||||||
|
%% @doc Проверяет, что запрос содержит валидный токен пользователя.
|
||||||
|
-spec auth_user(cowboy_req:req()) ->
|
||||||
|
{ok, binary(), cowboy_req:req()} | {error, integer(), binary(), cowboy_req:req()}.
|
||||||
|
auth_user(Req) ->
|
||||||
|
handler_auth:authenticate(Req).
|
||||||
|
|
||||||
|
%%%===================================================================
|
||||||
|
%%% HTTP‑ответы
|
||||||
|
%%%===================================================================
|
||||||
|
|
||||||
|
%% @doc Отправляет JSON-ответ с указанным статусом и стандартным заголовком.
|
||||||
|
-spec send_json(cowboy_req:req(), cowboy:http_status(), jsx:json_term()) ->
|
||||||
|
{ok, binary(), cowboy_req:req()}.
|
||||||
|
send_json(Req, Status, Data) ->
|
||||||
|
send_json(Req, Status, Data, #{}).
|
||||||
|
|
||||||
|
%% @doc Отправляет JSON-ответ с указанным статусом и дополнительными заголовками.
|
||||||
|
%% ExtraHeaders вставляются поверх стандартного `content-type`.
|
||||||
|
-spec send_json(cowboy_req:req(), cowboy:http_status(), jsx:json_term(), map()) ->
|
||||||
|
{ok, binary(), cowboy_req:req()}.
|
||||||
|
send_json(Req, Status, Data, ExtraHeaders) ->
|
||||||
|
Body = jsx:encode(Data),
|
||||||
|
BaseHeaders = #{<<"content-type">> => <<"application/json">>},
|
||||||
|
Headers = maps:merge(BaseHeaders, ExtraHeaders),
|
||||||
|
Req1 = cowboy_req:reply(Status, Headers, Body, Req),
|
||||||
|
{ok, Body, Req1}.
|
||||||
|
|
||||||
|
%% @doc Отправляет JSON-ошибку.
|
||||||
|
-spec send_error(cowboy_req:req(), cowboy:http_status(), binary()) ->
|
||||||
|
{ok, binary(), cowboy_req:req()}.
|
||||||
|
send_error(Req, Status, Message) ->
|
||||||
|
Body = jsx:encode(#{error => Message}),
|
||||||
|
Headers = #{<<"content-type">> => <<"application/json">>},
|
||||||
|
Req1 = cowboy_req:reply(Status, Headers, Body, Req),
|
||||||
|
{ok, Body, Req1}.
|
||||||
|
|
||||||
|
%%%===================================================================
|
||||||
|
%%% Парсинг параметров запроса
|
||||||
|
%%%===================================================================
|
||||||
|
|
||||||
|
%% @doc Извлекает стандартные параметры пагинации/сортировки.
|
||||||
|
-spec parse_pagination_params(cowboy_req:req()) ->
|
||||||
|
#{limit => integer(), offset => integer(), sort => binary(), order => binary()}.
|
||||||
|
parse_pagination_params(Req) ->
|
||||||
|
Qs = cowboy_req:parse_qs(Req),
|
||||||
|
#{
|
||||||
|
limit => parse_int_qs(proplists:get_value(<<"limit">>, Qs), 50),
|
||||||
|
offset => parse_int_qs(proplists:get_value(<<"offset">>, Qs), 0),
|
||||||
|
sort => proplists:get_value(<<"sort">>, Qs, <<"created_at">>),
|
||||||
|
order => proplists:get_value(<<"order">>, Qs, <<"desc">>)
|
||||||
|
}.
|
||||||
|
|
||||||
|
-spec parse_int_qs(binary() | undefined, integer()) -> integer().
|
||||||
|
parse_int_qs(undefined, Default) -> Default;
|
||||||
|
parse_int_qs(Bin, Default) ->
|
||||||
|
try binary_to_integer(Bin) catch _:_ -> Default end.
|
||||||
|
|
||||||
|
%% @doc Преобразует бинарный ISO8601 параметр в datetime().
|
||||||
|
-spec parse_datetime_qs(binary() | undefined) -> calendar:datetime() | undefined.
|
||||||
|
parse_datetime_qs(undefined) -> undefined;
|
||||||
|
parse_datetime_qs(Bin) ->
|
||||||
|
case parse_datetime(Bin) of
|
||||||
|
{ok, Dt} -> Dt;
|
||||||
|
_ -> undefined
|
||||||
|
end.
|
||||||
|
|
||||||
|
%% @doc Разбирает ISO8601 строку в datetime().
|
||||||
|
-spec parse_datetime(binary()) -> {ok, calendar:datetime()} | {error, invalid_format}.
|
||||||
|
parse_datetime(Str) ->
|
||||||
|
try
|
||||||
|
[DateStr, TimeStr] = string:split(Str, "T"),
|
||||||
|
TimeStrNoZ = string:trim(TimeStr, trailing, "Z"),
|
||||||
|
[YearStr, MonthStr, DayStr] = string:split(DateStr, "-", all),
|
||||||
|
[HourStr, MinuteStr, SecondStr] = string:split(TimeStrNoZ, ":", all),
|
||||||
|
Year = binary_to_integer(list_to_binary(YearStr)),
|
||||||
|
Month = binary_to_integer(list_to_binary(MonthStr)),
|
||||||
|
Day = binary_to_integer(list_to_binary(DayStr)),
|
||||||
|
Hour = binary_to_integer(list_to_binary(HourStr)),
|
||||||
|
Minute = binary_to_integer(list_to_binary(MinuteStr)),
|
||||||
|
Second = binary_to_integer(list_to_binary(SecondStr)),
|
||||||
|
{ok, {{Year, Month, Day}, {Hour, Minute, Second}}}
|
||||||
|
catch _:_ -> {error, invalid_format}
|
||||||
|
end.
|
||||||
|
|
||||||
|
%%%===================================================================
|
||||||
|
%%% Сериализация записей (все поля согласно records.hrl)
|
||||||
|
%%%===================================================================
|
||||||
|
|
||||||
|
%% @doc Преобразует #event{} в JSON-карту.
|
||||||
|
-spec event_to_json(#event{}) -> map().
|
||||||
|
event_to_json(Event) ->
|
||||||
|
LocationJson = case Event#event.location of
|
||||||
|
undefined -> null;
|
||||||
|
#location{address = Addr, lat = Lat, lon = Lon} ->
|
||||||
|
#{address => Addr, lat => Lat, lon => Lon}
|
||||||
|
end,
|
||||||
|
RecurrenceJson = case Event#event.recurrence_rule of
|
||||||
|
undefined -> null;
|
||||||
|
Rule -> try jsx:decode(Rule, [return_maps]) of
|
||||||
|
Map when is_map(Map) -> Map;
|
||||||
|
_ -> null
|
||||||
|
catch _:_ -> null end
|
||||||
|
end,
|
||||||
|
#{
|
||||||
|
id => Event#event.id,
|
||||||
|
calendar_id => Event#event.calendar_id,
|
||||||
|
title => Event#event.title,
|
||||||
|
description => Event#event.description,
|
||||||
|
event_type => Event#event.event_type,
|
||||||
|
start_time => datetime_to_iso8601(Event#event.start_time),
|
||||||
|
duration => Event#event.duration,
|
||||||
|
recurrence => RecurrenceJson,
|
||||||
|
master_id => Event#event.master_id,
|
||||||
|
is_instance => Event#event.is_instance,
|
||||||
|
specialist_id => Event#event.specialist_id,
|
||||||
|
location => LocationJson,
|
||||||
|
tags => Event#event.tags,
|
||||||
|
capacity => Event#event.capacity,
|
||||||
|
online_link => Event#event.online_link,
|
||||||
|
status => Event#event.status,
|
||||||
|
reason => Event#event.reason,
|
||||||
|
rating_avg => Event#event.rating_avg,
|
||||||
|
rating_count => Event#event.rating_count,
|
||||||
|
attachments => Event#event.attachments,
|
||||||
|
edit_history => Event#event.edit_history,
|
||||||
|
created_at => datetime_to_iso8601(Event#event.created_at),
|
||||||
|
updated_at => datetime_to_iso8601(Event#event.updated_at)
|
||||||
|
}.
|
||||||
|
|
||||||
|
%% @doc Преобразует #user{} в JSON-карту.
|
||||||
|
-spec user_to_json(#user{}) -> map().
|
||||||
|
user_to_json(User) ->
|
||||||
|
#{
|
||||||
|
id => User#user.id,
|
||||||
|
email => User#user.email,
|
||||||
|
role => User#user.role,
|
||||||
|
status => User#user.status,
|
||||||
|
reason => User#user.reason,
|
||||||
|
nickname => User#user.nickname,
|
||||||
|
avatar_url => User#user.avatar_url,
|
||||||
|
timezone => User#user.timezone,
|
||||||
|
language => User#user.language,
|
||||||
|
social_links => User#user.social_links,
|
||||||
|
phone => User#user.phone,
|
||||||
|
preferences => User#user.preferences,
|
||||||
|
last_login => datetime_to_iso8601(User#user.last_login),
|
||||||
|
created_at => datetime_to_iso8601(User#user.created_at),
|
||||||
|
updated_at => datetime_to_iso8601(User#user.updated_at)
|
||||||
|
}.
|
||||||
|
|
||||||
|
%% @doc Преобразует #review{} в JSON-карту.
|
||||||
|
-spec review_to_json(#review{}) -> map().
|
||||||
|
review_to_json(Review) ->
|
||||||
|
#{
|
||||||
|
id => Review#review.id,
|
||||||
|
user_id => Review#review.user_id,
|
||||||
|
target_type => Review#review.target_type,
|
||||||
|
target_id => Review#review.target_id,
|
||||||
|
rating => Review#review.rating,
|
||||||
|
comment => Review#review.comment,
|
||||||
|
status => Review#review.status,
|
||||||
|
reason => Review#review.reason,
|
||||||
|
likes => Review#review.likes,
|
||||||
|
dislikes => Review#review.dislikes,
|
||||||
|
created_at => datetime_to_iso8601(Review#review.created_at),
|
||||||
|
updated_at => datetime_to_iso8601(Review#review.updated_at)
|
||||||
|
}.
|
||||||
|
|
||||||
|
%% @doc Преобразует #report{} в JSON-карту.
|
||||||
|
-spec report_to_json(#report{}) -> map().
|
||||||
|
report_to_json(Report) ->
|
||||||
|
#{
|
||||||
|
id => Report#report.id,
|
||||||
|
reporter_id => Report#report.reporter_id,
|
||||||
|
target_type => Report#report.target_type,
|
||||||
|
target_id => Report#report.target_id,
|
||||||
|
reason => Report#report.reason,
|
||||||
|
status => Report#report.status,
|
||||||
|
created_at => datetime_to_iso8601(Report#report.created_at),
|
||||||
|
resolved_at => datetime_to_iso8601(Report#report.resolved_at),
|
||||||
|
resolved_by => Report#report.resolved_by
|
||||||
|
}.
|
||||||
|
|
||||||
|
%% @doc Преобразует #ticket{} в JSON-карту.
|
||||||
|
-spec ticket_to_json(#ticket{}) -> map().
|
||||||
|
ticket_to_json(Ticket) ->
|
||||||
|
#{
|
||||||
|
id => Ticket#ticket.id,
|
||||||
|
reporter_id => Ticket#ticket.reporter_id,
|
||||||
|
error_hash => Ticket#ticket.error_hash,
|
||||||
|
error_message => Ticket#ticket.error_message,
|
||||||
|
stacktrace => Ticket#ticket.stacktrace,
|
||||||
|
context => Ticket#ticket.context,
|
||||||
|
count => Ticket#ticket.count,
|
||||||
|
first_seen => datetime_to_iso8601(Ticket#ticket.first_seen),
|
||||||
|
last_seen => datetime_to_iso8601(Ticket#ticket.last_seen),
|
||||||
|
status => Ticket#ticket.status,
|
||||||
|
assigned_to => Ticket#ticket.assigned_to,
|
||||||
|
resolution_note => Ticket#ticket.resolution_note
|
||||||
|
}.
|
||||||
|
|
||||||
|
%% @doc Преобразует #calendar{} в JSON-карту.
|
||||||
|
-spec calendar_to_json(#calendar{}) -> map().
|
||||||
|
calendar_to_json(Calendar) ->
|
||||||
|
#{
|
||||||
|
id => Calendar#calendar.id,
|
||||||
|
owner_id => Calendar#calendar.owner_id,
|
||||||
|
title => Calendar#calendar.title,
|
||||||
|
description => Calendar#calendar.description,
|
||||||
|
short_name => Calendar#calendar.short_name,
|
||||||
|
category => Calendar#calendar.category,
|
||||||
|
color => Calendar#calendar.color,
|
||||||
|
image_url => Calendar#calendar.image_url,
|
||||||
|
settings => Calendar#calendar.settings,
|
||||||
|
tags => Calendar#calendar.tags,
|
||||||
|
type => Calendar#calendar.type,
|
||||||
|
confirmation => Calendar#calendar.confirmation,
|
||||||
|
rating_avg => Calendar#calendar.rating_avg,
|
||||||
|
rating_count => Calendar#calendar.rating_count,
|
||||||
|
status => Calendar#calendar.status,
|
||||||
|
reason => Calendar#calendar.reason,
|
||||||
|
created_at => datetime_to_iso8601(Calendar#calendar.created_at),
|
||||||
|
updated_at => datetime_to_iso8601(Calendar#calendar.updated_at)
|
||||||
|
}.
|
||||||
|
|
||||||
|
%% @doc Преобразует #subscription{} в JSON-карту.
|
||||||
|
-spec subscription_to_json(#subscription{}) -> map().
|
||||||
|
subscription_to_json(Subscription) ->
|
||||||
|
#{
|
||||||
|
id => Subscription#subscription.id,
|
||||||
|
user_id => Subscription#subscription.user_id,
|
||||||
|
plan => Subscription#subscription.plan,
|
||||||
|
status => Subscription#subscription.status,
|
||||||
|
trial_used => Subscription#subscription.trial_used,
|
||||||
|
started_at => datetime_to_iso8601(Subscription#subscription.started_at),
|
||||||
|
expires_at => datetime_to_iso8601(Subscription#subscription.expires_at),
|
||||||
|
created_at => datetime_to_iso8601(Subscription#subscription.created_at),
|
||||||
|
updated_at => datetime_to_iso8601(Subscription#subscription.updated_at)
|
||||||
|
}.
|
||||||
|
|
||||||
|
%%%===================================================================
|
||||||
|
%%% Вспомогательные внутренние функции
|
||||||
|
%%%===================================================================
|
||||||
|
|
||||||
|
%% @private
|
||||||
|
-spec datetime_to_iso8601(calendar:datetime() | undefined) -> binary() | undefined.
|
||||||
|
datetime_to_iso8601(undefined) -> undefined;
|
||||||
|
datetime_to_iso8601({{Year, Month, Day}, {Hour, Minute, Second}}) ->
|
||||||
|
iolist_to_binary(
|
||||||
|
io_lib:format("~4..0B-~2..0B-~2..0BT~2..0B:~2..0B:~2..0BZ",
|
||||||
|
[Year, Month, Day, Hour, Minute, Second])).
|
||||||
|
|
||||||
|
%%%===================================================================
|
||||||
|
%%% Генерация Swagger-трейлов для типового CRUD-ресурса
|
||||||
|
%%%===================================================================
|
||||||
|
|
||||||
|
%% @doc Генерирует трейлы для GET (list), GET /:id, POST, PUT, DELETE.
|
||||||
|
-spec trails_for_crud(binary(), binary(), map(), map()) -> [map()].
|
||||||
|
trails_for_crud(Path, _Resource, GetSchema, UpdateSchema) ->
|
||||||
|
IdParam = #{
|
||||||
|
name => <<"id">>,
|
||||||
|
in => <<"path">>,
|
||||||
|
description => <<"Resource ID">>,
|
||||||
|
required => true,
|
||||||
|
schema => #{type => string}
|
||||||
|
},
|
||||||
|
[
|
||||||
|
#{ % GET list
|
||||||
|
path => Path,
|
||||||
|
method => <<"GET">>,
|
||||||
|
description => <<"List all records">>,
|
||||||
|
parameters => [
|
||||||
|
#{name => <<"limit">>, in => <<"query">>, schema => #{type => integer}, description => <<"Page size">>},
|
||||||
|
#{name => <<"offset">>, in => <<"query">>, schema => #{type => integer}, description => <<"Offset">>}
|
||||||
|
],
|
||||||
|
responses => #{
|
||||||
|
200 => #{
|
||||||
|
description => <<"Array of records">>,
|
||||||
|
content => #{<<"application/json">> => #{schema => GetSchema}}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
#{ % GET by id
|
||||||
|
path => <<Path/binary, "/:id">>,
|
||||||
|
method => <<"GET">>,
|
||||||
|
description => <<"Get record by ID">>,
|
||||||
|
parameters => [IdParam],
|
||||||
|
responses => #{
|
||||||
|
200 => #{
|
||||||
|
description => <<"Record details">>,
|
||||||
|
content => #{<<"application/json">> => #{schema => GetSchema}}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
#{ % POST
|
||||||
|
path => Path,
|
||||||
|
method => <<"POST">>,
|
||||||
|
description => <<"Create a new record">>,
|
||||||
|
requestBody => #{
|
||||||
|
required => true,
|
||||||
|
content => #{<<"application/json">> => #{schema => UpdateSchema}}
|
||||||
|
},
|
||||||
|
responses => #{
|
||||||
|
201 => #{description => <<"Record created">>}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
#{ % PUT
|
||||||
|
path => <<Path/binary, "/:id">>,
|
||||||
|
method => <<"PUT">>,
|
||||||
|
description => <<"Update record by ID">>,
|
||||||
|
parameters => [IdParam],
|
||||||
|
requestBody => #{
|
||||||
|
required => true,
|
||||||
|
content => #{<<"application/json">> => #{schema => UpdateSchema}}
|
||||||
|
},
|
||||||
|
responses => #{
|
||||||
|
200 => #{description => <<"Record updated">>}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
#{ % DELETE
|
||||||
|
path => <<Path/binary, "/:id">>,
|
||||||
|
method => <<"DELETE">>,
|
||||||
|
description => <<"Delete record by ID">>,
|
||||||
|
parameters => [IdParam],
|
||||||
|
responses => #{
|
||||||
|
200 => #{description => <<"Record deleted">>}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
].
|
||||||
25
src/infra/infra_utils.erl
Normal file
25
src/infra/infra_utils.erl
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
%%%-------------------------------------------------------------------
|
||||||
|
%%% @doc Общие инфраструктурные утилиты.
|
||||||
|
%%% Содержит функцию генерации уникальных идентификаторов,
|
||||||
|
%%% используемых во всех основных сущностях (пользователи,
|
||||||
|
%%% администраторы, события и т.д.).
|
||||||
|
%%% @end
|
||||||
|
%%%-------------------------------------------------------------------
|
||||||
|
-module(infra_utils).
|
||||||
|
|
||||||
|
-export([generate_id/1]).
|
||||||
|
|
||||||
|
%%%===================================================================
|
||||||
|
%%% API
|
||||||
|
%%%===================================================================
|
||||||
|
|
||||||
|
%% @doc Генерирует уникальный идентификатор.
|
||||||
|
%% Формат: URL-безопасный base64 от n случайных байт,
|
||||||
|
%% без завершающих символов '='.
|
||||||
|
%% Длина строки: 22 символа.
|
||||||
|
%% Пример: <<"WyrF9DQm3YTksEJww4lyrQ">>
|
||||||
|
-spec generate_id(non_neg_integer()) -> binary().
|
||||||
|
generate_id(Bytes) ->
|
||||||
|
Base64 = base64:encode(crypto:strong_rand_bytes(Bytes), #{mode => urlsafe, padding => false}),
|
||||||
|
Id = binary:replace(Base64, <<"-">>, <<"0">>, [global]),
|
||||||
|
binary:replace(Id, <<"_">>, <<"9">>, [global]).
|
||||||
84
src/logic/logic_admin.erl
Normal file
84
src/logic/logic_admin.erl
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
-module(logic_admin).
|
||||||
|
|
||||||
|
-export([list_admins/2, get_admin/1, update_admin/2]).
|
||||||
|
|
||||||
|
-include("records.hrl").
|
||||||
|
|
||||||
|
%%%-------------------------------------------------------------------
|
||||||
|
%%% Административный список администраторов с пагинацией
|
||||||
|
%%%-------------------------------------------------------------------
|
||||||
|
-spec list_admins(map(), #{limit => integer(), offset => integer(), sort => binary(), order => binary()}) ->
|
||||||
|
{ok, non_neg_integer(), [#admin{}]}.
|
||||||
|
list_admins(Filters, Pagination) ->
|
||||||
|
#{limit := Limit, offset := Offset, sort := Sort, order := Order} = Pagination,
|
||||||
|
AllAdmins = core_admin:list_all(),
|
||||||
|
Filtered = apply_filters(AllAdmins, Filters),
|
||||||
|
Sorted = sort_admins(Filtered, Sort, Order),
|
||||||
|
Total = length(Sorted),
|
||||||
|
Page = lists:sublist(Sorted, Offset + 1, Limit),
|
||||||
|
{ok, Total, Page}.
|
||||||
|
|
||||||
|
%%%-------------------------------------------------------------------
|
||||||
|
%%% Получение администратора по ID
|
||||||
|
%%%-------------------------------------------------------------------
|
||||||
|
-spec get_admin(binary()) -> {ok, #admin{}} | {error, not_found}.
|
||||||
|
get_admin(AdminId) ->
|
||||||
|
core_admin:get_by_id(AdminId).
|
||||||
|
|
||||||
|
%%%-------------------------------------------------------------------
|
||||||
|
%%% Обновление администратора
|
||||||
|
%%%-------------------------------------------------------------------
|
||||||
|
-spec update_admin(binary(), proplists:proplist()) ->
|
||||||
|
{ok, #admin{}} | {error, not_found | invalid_field}.
|
||||||
|
update_admin(AdminId, Updates) ->
|
||||||
|
case core_admin:get_by_id(AdminId) of
|
||||||
|
{ok, _Admin} ->
|
||||||
|
ValidUpdates = validate_admin_updates(Updates),
|
||||||
|
core_admin:update(AdminId, ValidUpdates);
|
||||||
|
Error ->
|
||||||
|
Error
|
||||||
|
end.
|
||||||
|
|
||||||
|
%%%===================================================================
|
||||||
|
%%% Внутренние функции
|
||||||
|
%%%===================================================================
|
||||||
|
|
||||||
|
apply_filters(Admins, Filters) ->
|
||||||
|
Role = maps:get(role, Filters, undefined),
|
||||||
|
Status = maps:get(status, Filters, undefined),
|
||||||
|
F1 = case Role of
|
||||||
|
undefined -> Admins;
|
||||||
|
_ -> [A || A <- Admins, A#admin.role =:= Role]
|
||||||
|
end,
|
||||||
|
case Status of
|
||||||
|
undefined -> F1;
|
||||||
|
_ -> [A || A <- F1, A#admin.status =:= Status]
|
||||||
|
end.
|
||||||
|
|
||||||
|
sort_admins(Admins, SortField, Order) ->
|
||||||
|
Field = binary_to_existing_atom(SortField, utf8),
|
||||||
|
Sorted = lists:sort(
|
||||||
|
fun(A, B) ->
|
||||||
|
ValA = admin_field(A, Field),
|
||||||
|
ValB = admin_field(B, Field),
|
||||||
|
if Order == <<"asc">> -> ValA =< ValB;
|
||||||
|
true -> ValA >= ValB
|
||||||
|
end
|
||||||
|
end, Admins),
|
||||||
|
Sorted.
|
||||||
|
|
||||||
|
admin_field(#admin{created_at = V}, created_at) -> V;
|
||||||
|
admin_field(#admin{email = V}, email) -> V;
|
||||||
|
admin_field(#admin{role = V}, role) -> V;
|
||||||
|
admin_field(_, _) -> undefined.
|
||||||
|
|
||||||
|
validate_admin_updates(Updates) ->
|
||||||
|
lists:filter(fun validate_admin_update/1, Updates).
|
||||||
|
|
||||||
|
validate_admin_update({nickname, V}) when is_binary(V); V =:= undefined -> true;
|
||||||
|
validate_admin_update({avatar_url, V}) when is_binary(V); V =:= undefined -> true;
|
||||||
|
validate_admin_update({timezone, V}) when is_binary(V); V =:= undefined -> true;
|
||||||
|
validate_admin_update({language, V}) when is_binary(V); V =:= undefined -> true;
|
||||||
|
validate_admin_update({phone, V}) when is_binary(V); V =:= undefined -> true;
|
||||||
|
validate_admin_update({preferences, V}) when is_map(V); V =:= undefined -> true;
|
||||||
|
validate_admin_update(_) -> false.
|
||||||
47
src/logic/logic_report.erl
Normal file
47
src/logic/logic_report.erl
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
-module(logic_report).
|
||||||
|
-include("records.hrl").
|
||||||
|
|
||||||
|
-export([list_reports/1, get_report/2, update_report_status/3, delete_report/2]).
|
||||||
|
|
||||||
|
%% Получить список всех жалоб (только для админов)
|
||||||
|
-spec list_reports(binary()) -> {ok, [#report{}]} | {error, access_denied}.
|
||||||
|
list_reports(AdminId) ->
|
||||||
|
case admin_utils:is_admin(AdminId) of
|
||||||
|
true ->
|
||||||
|
{ok, Reports} = core_report:list_all(),
|
||||||
|
{ok, Reports};
|
||||||
|
false -> {error, access_denied}
|
||||||
|
end.
|
||||||
|
|
||||||
|
%% Получить конкретную жалобу по ID (только для админов)
|
||||||
|
-spec get_report(binary(), binary()) -> {ok, #report{}} | {error, not_found | access_denied}.
|
||||||
|
get_report(AdminId, ReportId) ->
|
||||||
|
case admin_utils:is_admin(AdminId) of
|
||||||
|
true -> core_report:get_by_id(ReportId);
|
||||||
|
false -> {error, access_denied}
|
||||||
|
end.
|
||||||
|
|
||||||
|
%% Обновить статус жалобы (только для админов)
|
||||||
|
-spec update_report_status(binary(), binary(), binary()) -> {ok, #report{}} | {error, not_found | access_denied | invalid_status}.
|
||||||
|
update_report_status(AdminId, ReportId, NewStatus) ->
|
||||||
|
case admin_utils:is_admin(AdminId) of
|
||||||
|
true ->
|
||||||
|
StatusAtom = case NewStatus of
|
||||||
|
<<"reviewed">> -> reviewed;
|
||||||
|
<<"dismissed">> -> dismissed;
|
||||||
|
_ -> undefined
|
||||||
|
end,
|
||||||
|
case StatusAtom of
|
||||||
|
undefined -> {error, invalid_status};
|
||||||
|
_ -> core_report:update_status(ReportId, StatusAtom, AdminId)
|
||||||
|
end;
|
||||||
|
false -> {error, access_denied}
|
||||||
|
end.
|
||||||
|
|
||||||
|
%% Удалить жалобу (только для админов)
|
||||||
|
-spec delete_report(binary(), binary()) -> {ok, deleted} | {error, not_found | access_denied}.
|
||||||
|
delete_report(AdminId, ReportId) ->
|
||||||
|
case admin_utils:is_admin(AdminId) of
|
||||||
|
true -> core_report:delete(ReportId);
|
||||||
|
false -> {error, access_denied}
|
||||||
|
end.
|
||||||
@@ -5,6 +5,7 @@
|
|||||||
update_review/3, delete_review/2, hide_review/2, hide_review/3, unhide_review/2, unhide_review/3]).
|
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, []) ->
|
||||||
@@ -272,3 +274,52 @@ 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.
|
||||||
@@ -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
107
src/logic/logic_user.erl
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
-module(logic_user).
|
||||||
|
|
||||||
|
-export([list_users_admin/2, get_user_admin/1, update_user_admin/2, delete_user_admin/1]).
|
||||||
|
|
||||||
|
-include("records.hrl").
|
||||||
|
|
||||||
|
%%%-------------------------------------------------------------------
|
||||||
|
%%% Административный список пользователей с пагинацией
|
||||||
|
%%%-------------------------------------------------------------------
|
||||||
|
-spec list_users_admin(map(), #{limit => integer(), offset => integer(), sort => binary(), order => binary()}) ->
|
||||||
|
{ok, non_neg_integer(), [#user{}]}.
|
||||||
|
list_users_admin(Filters, Pagination) ->
|
||||||
|
#{limit := Limit, offset := Offset, sort := Sort, order := Order} = Pagination,
|
||||||
|
AllUsers = core_user:list_all(),
|
||||||
|
Filtered = apply_filters(AllUsers, Filters),
|
||||||
|
Sorted = sort_users(Filtered, Sort, Order),
|
||||||
|
Total = length(Sorted),
|
||||||
|
Page = lists:sublist(Sorted, Offset + 1, Limit),
|
||||||
|
{ok, Total, Page}.
|
||||||
|
|
||||||
|
%%%-------------------------------------------------------------------
|
||||||
|
%%% Получение пользователя по ID (без проверки прав)
|
||||||
|
%%%-------------------------------------------------------------------
|
||||||
|
-spec get_user_admin(binary()) -> {ok, #user{}} | {error, not_found}.
|
||||||
|
get_user_admin(UserId) ->
|
||||||
|
core_user:get_by_id(UserId).
|
||||||
|
|
||||||
|
%%%-------------------------------------------------------------------
|
||||||
|
%%% Обновление пользователя (без проверки прав)
|
||||||
|
%%%-------------------------------------------------------------------
|
||||||
|
-spec update_user_admin(binary(), proplists:proplist()) ->
|
||||||
|
{ok, #user{}} | {error, not_found | invalid_field}.
|
||||||
|
update_user_admin(UserId, Updates) ->
|
||||||
|
case core_user:get_by_id(UserId) of
|
||||||
|
{ok, _User} ->
|
||||||
|
ValidUpdates = validate_user_updates(Updates),
|
||||||
|
core_user:update(UserId, ValidUpdates);
|
||||||
|
Error ->
|
||||||
|
Error
|
||||||
|
end.
|
||||||
|
|
||||||
|
%%%-------------------------------------------------------------------
|
||||||
|
%%% Мягкое удаление пользователя (установка статуса deleted)
|
||||||
|
%%%-------------------------------------------------------------------
|
||||||
|
-spec delete_user_admin(binary()) -> {ok, #user{}} | {error, not_found}.
|
||||||
|
delete_user_admin(UserId) ->
|
||||||
|
case core_user:get_by_id(UserId) of
|
||||||
|
{ok, User} ->
|
||||||
|
UpdatedUser = User#user{status = deleted},
|
||||||
|
core_user:update(UserId, UpdatedUser);
|
||||||
|
Error ->
|
||||||
|
Error
|
||||||
|
end.
|
||||||
|
|
||||||
|
%%%===================================================================
|
||||||
|
%%% Внутренние функции
|
||||||
|
%%%===================================================================
|
||||||
|
|
||||||
|
apply_filters(Users, Filters) ->
|
||||||
|
Role = maps:get(role, Filters, undefined),
|
||||||
|
Status = maps:get(status, Filters, undefined),
|
||||||
|
Q = maps:get(q, Filters, undefined),
|
||||||
|
F1 = case Role of
|
||||||
|
undefined -> Users;
|
||||||
|
_ -> [U || U <- Users, U#user.role =:= Role]
|
||||||
|
end,
|
||||||
|
F2 = case Status of
|
||||||
|
undefined -> F1;
|
||||||
|
_ -> [U || U <- F1, U#user.status =:= Status]
|
||||||
|
end,
|
||||||
|
case Q of
|
||||||
|
undefined -> F2;
|
||||||
|
_ -> [U || U <- F2,
|
||||||
|
string:str(binary_to_list(U#user.email), binary_to_list(Q)) > 0 orelse
|
||||||
|
(U#user.nickname /= undefined andalso string:str(binary_to_list(U#user.nickname), binary_to_list(Q)) > 0)]
|
||||||
|
end.
|
||||||
|
|
||||||
|
sort_users(Users, SortField, Order) ->
|
||||||
|
Field = binary_to_existing_atom(SortField, utf8),
|
||||||
|
Sorted = lists:sort(
|
||||||
|
fun(A, B) ->
|
||||||
|
ValA = user_field(A, Field),
|
||||||
|
ValB = user_field(B, Field),
|
||||||
|
if Order == <<"asc">> -> ValA =< ValB;
|
||||||
|
true -> ValA >= ValB
|
||||||
|
end
|
||||||
|
end, Users),
|
||||||
|
Sorted.
|
||||||
|
|
||||||
|
user_field(#user{created_at = V}, created_at) -> V;
|
||||||
|
user_field(#user{email = V}, email) -> V;
|
||||||
|
user_field(#user{role = V}, role) -> V;
|
||||||
|
user_field(#user{status = V}, status) -> V;
|
||||||
|
user_field(_, _) -> undefined.
|
||||||
|
|
||||||
|
validate_user_updates(Updates) ->
|
||||||
|
lists:filter(fun validate_user_update/1, Updates).
|
||||||
|
|
||||||
|
validate_user_update({role, V}) when V =:= user; V =:= bot -> true;
|
||||||
|
validate_user_update({status, V}) when V =:= active; V =:= frozen; V =:= deleted -> true;
|
||||||
|
validate_user_update({reason, V}) when is_binary(V); V =:= undefined -> true;
|
||||||
|
validate_user_update({nickname, V}) when is_binary(V); V =:= undefined -> true;
|
||||||
|
validate_user_update({timezone, V}) when is_binary(V); V =:= undefined -> true;
|
||||||
|
validate_user_update({language, V}) when is_binary(V); V =:= undefined -> true;
|
||||||
|
validate_user_update({phone, V}) when is_binary(V); V =:= undefined -> true;
|
||||||
|
validate_user_update({preferences, V}) when is_map(V); V =:= undefined -> true;
|
||||||
|
validate_user_update(_) -> false.
|
||||||
@@ -3,9 +3,37 @@
|
|||||||
|
|
||||||
admin() ->
|
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).
|
||||||
|
|
||||||
|
|||||||
@@ -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"),
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user