From 6403f061df4e51413b294bee7f9a4c86a9914335 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=90=D0=BB=D0=B5=D0=BA=D1=81=D0=B5=D0=B9=20=D0=A1=D0=B0?= =?UTF-8?q?=D0=B1=D0=B8=D0=BB=D0=B8=D0=BD?= Date: Sun, 10 May 2026 22:14:38 +0300 Subject: [PATCH] =?UTF-8?q?=D0=A0=D0=B5=D1=84=D0=B0=D0=BA=D1=82=D0=BE?= =?UTF-8?q?=D1=80=D0=B8=D0=BD=D0=B3=20=D0=BE=D0=B1=D1=80=D0=B0=D0=B1=D0=BE?= =?UTF-8?q?=D1=82=D1=87=D0=B8=D0=BA=D0=BE=D0=B2.=20=D0=A7=D0=B0=D1=81?= =?UTF-8?q?=D1=82=D1=8C=201=20https://git.sabilin.com/EventHub/EventHubBac?= =?UTF-8?q?k/issues/21?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/core/core_admin.erl | 43 +- src/core/core_admin_audit.erl | 2 +- src/core/core_banned_words.erl | 7 +- src/core/core_booking.erl | 9 +- src/core/core_calendar.erl | 9 +- src/core/core_event.erl | 11 +- src/core/core_report.erl | 42 +- src/core/core_review.erl | 7 +- src/core/core_subscription.erl | 7 +- src/core/core_ticket.erl | 4 +- src/core/core_user.erl | 15 +- src/eventhub_app.erl | 4 +- src/handlers/admin/admin_handler_admins.erl | 188 ++++----- src/handlers/admin/admin_handler_audit.erl | 206 +++++++--- .../admin/admin_handler_banned_words.erl | 299 +++++++------- .../admin/admin_handler_event_by_id.erl | 311 +++++--------- src/handlers/admin/admin_handler_events.erl | 223 +++-------- src/handlers/admin/admin_handler_health.erl | 38 +- src/handlers/admin/admin_handler_login.erl | 86 ++-- src/handlers/admin/admin_handler_me.erl | 156 ++++++-- .../admin/admin_handler_moderation.erl | 183 +++++---- .../admin/admin_handler_report_by_id.erl | 201 +++++----- src/handlers/admin/admin_handler_reports.erl | 210 +++++----- src/handlers/admin/admin_handler_reviews.erl | 173 ++++---- .../admin/admin_handler_reviews_by_id.erl | 182 +++++---- src/handlers/admin/admin_handler_stats.erl | 117 ++++-- .../admin/admin_handler_subscriptions.erl | 285 +++++-------- .../admin_handler_subscriptions_by_id.erl | 163 ++++++++ .../admin/admin_handler_ticket_by_id.erl | 238 +++++++---- .../admin/admin_handler_ticket_stats.erl | 80 ++-- src/handlers/admin/admin_handler_tickets.erl | 304 ++++++-------- .../admin/admin_handler_user_by_id.erl | 261 ++++++------ src/handlers/admin/admin_handler_users.erl | 123 +++--- src/handlers/admin/admin_ws_handler.erl | 27 +- src/handlers/handler_banned_words.erl | 88 ---- src/handlers/handler_report_by_id.erl | 85 ---- src/handlers/handler_ticket_stats.erl | 39 -- src/handlers/handler_utils.erl | 378 ++++++++++++++++++ src/infra/infra_utils.erl | 25 ++ src/logic/logic_admin.erl | 84 ++++ src/logic/logic_report.erl | 47 +++ src/logic/logic_review.erl | 57 ++- src/logic/logic_ticket.erl | 8 + src/logic/logic_user.erl | 107 +++++ src/swagger/trails.erl | 32 +- test/api/api_admin_tests.erl | 9 +- 46 files changed, 3082 insertions(+), 2091 deletions(-) create mode 100644 src/handlers/admin/admin_handler_subscriptions_by_id.erl delete mode 100644 src/handlers/handler_banned_words.erl delete mode 100644 src/handlers/handler_report_by_id.erl delete mode 100644 src/handlers/handler_ticket_stats.erl create mode 100644 src/handlers/handler_utils.erl create mode 100644 src/infra/infra_utils.erl create mode 100644 src/logic/logic_admin.erl create mode 100644 src/logic/logic_report.erl create mode 100644 src/logic/logic_user.erl diff --git a/src/core/core_admin.erl b/src/core/core_admin.erl index cb914fd..3c2256a 100644 --- a/src/core/core_admin.erl +++ b/src/core/core_admin.erl @@ -1,14 +1,15 @@ -module(core_admin). -include("records.hrl"). -export([create/3, get_by_email/1, get_by_id/1, list_all/0, - update_role/2, block/1, unblock/1, generate_id/0, update_last_login/1]). + update_role/2, block/1, unblock/1, update_last_login/1]). +-export([update/2]). create(Email, Password, Role) -> case get_by_email(Email) of {ok, _} -> {error, email_exists}; {error, not_found} -> - Id = generate_id(), + Id = infra_utils:generate_id(16), {ok, Hash} = argon2:hash(Password), Now = calendar:universal_time(), Admin = #admin{ @@ -24,6 +25,22 @@ create(Email, Password, Role) -> {ok, Admin} end. +%% Обновление администратора (любые поля) +update(AdminId, Updates) -> + F = fun() -> + case mnesia:read(admin, AdminId) of + [] -> {error, not_found}; + [Admin] -> + UpdatedAdmin = apply_updates(Admin, Updates), + mnesia:write(UpdatedAdmin), + {ok, UpdatedAdmin} + end + end, + case mnesia:transaction(F) of + {atomic, Result} -> Result; + {aborted, Reason} -> {error, Reason} + end. + get_by_email(Email) -> Match = #admin{email = Email, _ = '_'}, case mnesia:dirty_match_object(Match) of @@ -73,5 +90,23 @@ update_status(Id, Status) -> Error -> Error end. -generate_id() -> - base64:encode(crypto:strong_rand_bytes(16), #{mode => urlsafe, padding => false}). \ No newline at end of file +%%%=================================================================== +%%% ВНУТРЕННИЕ ФУНКЦИИ +%%%=================================================================== + +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). \ No newline at end of file diff --git a/src/core/core_admin_audit.erl b/src/core/core_admin_audit.erl index b7b11f8..e0cf32c 100644 --- a/src/core/core_admin_audit.erl +++ b/src/core/core_admin_audit.erl @@ -6,7 +6,7 @@ log(AdminId, Email, Role, Action, EntityType, EntityId, Ip) -> log(AdminId, Email, Role, Action, EntityType, EntityId, Ip, undefined). log(AdminId, Email, Role, Action, EntityType, EntityId, Ip, Reason) -> - Id = base64:encode(crypto:strong_rand_bytes(9)), + Id = infra_utils:generate_id(9), Entry = #admin_audit{ id = Id, admin_id = AdminId, diff --git a/src/core/core_banned_words.erl b/src/core/core_banned_words.erl index decbb85..66b7bbe 100644 --- a/src/core/core_banned_words.erl +++ b/src/core/core_banned_words.erl @@ -10,7 +10,7 @@ list_banned_words() -> mnesia:dirty_match_object(#banned_word{_ = '_'}). add_banned_word(Word, AddedBy) -> - Id = generate_id(), + Id = infra_utils:generate_id(9), Now = calendar:universal_time(), BW = #banned_word{id = Id, word = Word, added_by = AddedBy, added_at = Now}, case mnesia:transaction(fun() -> @@ -48,7 +48,4 @@ update_banned_word(OldWord, NewWord) -> end) of {atomic, {ok, UpdatedRec}} -> {ok, UpdatedRec}; {aborted, not_found} -> {error, not_found} - end. - -generate_id() -> - base64:encode(crypto:strong_rand_bytes(9)). \ No newline at end of file + end. \ No newline at end of file diff --git a/src/core/core_booking.erl b/src/core/core_booking.erl index c7f03ec..d0ac9b5 100644 --- a/src/core/core_booking.erl +++ b/src/core/core_booking.erl @@ -3,12 +3,11 @@ -export([create/2, get_by_id/1, get_by_event_and_user/2, list_by_event/1, list_by_user/1]). -export([update_status/2, delete/1]). --export([generate_id/0]). -export([count_bookings/0]). %% Создание бронирования create(EventId, UserId) -> - Id = generate_id(), + Id = infra_utils:generate_id(16), Booking = #booking{ id = Id, event_id = EventId, @@ -98,8 +97,4 @@ delete(Id) -> {aborted, Reason} -> {error, Reason} end. -count_bookings() -> mnesia:table_info(booking, size). - -%% Внутренние функции -generate_id() -> - base64:encode(crypto:strong_rand_bytes(16), #{mode => urlsafe, padding => false}). \ No newline at end of file +count_bookings() -> mnesia:table_info(booking, size). \ No newline at end of file diff --git a/src/core/core_calendar.erl b/src/core/core_calendar.erl index b69eaac..9cfd39c 100644 --- a/src/core/core_calendar.erl +++ b/src/core/core_calendar.erl @@ -1,13 +1,12 @@ -module(core_calendar). -include("records.hrl"). -export([create/4, create/5, get_by_id/1, list_by_owner/1, update/2, delete/1]). --export([generate_id/0]). -export([count_calendars/0]). -export([freeze/2, unfreeze/2]). % ← новые функции %% Создание календаря create(OwnerId, Title, Description, Confirmation) -> - Id = generate_id(), + Id = infra_utils:generate_id(16), Calendar = #calendar{ id = Id, owner_id = OwnerId, @@ -30,7 +29,7 @@ create(OwnerId, Title, Description, Confirmation) -> %% Создание календаря с типом и политикой create(OwnerId, Title, Description, Confirmation, Type) -> - Id = generate_id(), + Id = infra_utils:generate_id(16), Calendar = #calendar{ id = Id, owner_id = OwnerId, @@ -94,10 +93,6 @@ freeze(Id, Reason) -> unfreeze(Id, Reason) -> update(Id, [{status, active}, {reason, Reason}]). -%% Внутренние функции -generate_id() -> - base64:encode(crypto:strong_rand_bytes(16), #{mode => urlsafe, padding => false}). - apply_updates(Calendar, Updates) -> Updated = lists:foldl(fun({Field, Value}, C) -> set_field(Field, Value, C) diff --git a/src/core/core_event.erl b/src/core/core_event.erl index 631b23e..f0a3132 100644 --- a/src/core/core_event.erl +++ b/src/core/core_event.erl @@ -3,14 +3,13 @@ -export([create/4, create_recurring/5, get_by_id/1, list_by_calendar/1, update/2, delete/1, materialize_occurrence/3]). --export([generate_id/0]). -export([count_events/0, count_events_by_date/2]). -export([freeze/2, unfreeze/2]). -export([list_all/0]). %% Создание одиночного события create(CalendarId, Title, StartTime, Duration) -> - Id = generate_id(), + Id = infra_utils:generate_id(16), Event = #event{ id = Id, calendar_id = CalendarId, @@ -46,7 +45,7 @@ create(CalendarId, Title, StartTime, Duration) -> %% Создание повторяющегося события (мастер-запись) create_recurring(CalendarId, Title, StartTime, Duration, RRule) -> - Id = generate_id(), + Id = infra_utils:generate_id(16), Event = #event{ id = Id, calendar_id = CalendarId, @@ -94,7 +93,7 @@ materialize_occurrence(MasterId, OccurrenceStart, SpecialistId) -> case Existing of [] -> % Создаём новый экземпляр - InstanceId = generate_id(), + InstanceId = infra_utils:generate_id(16), Instance = #event{ id = InstanceId, calendar_id = Master#event.calendar_id, @@ -193,10 +192,6 @@ count_events_by_date(From, To) -> date_part({{Y,M,D}, _}) -> {Y,M,D}. -%% Внутренние функции -generate_id() -> - base64:encode(crypto:strong_rand_bytes(16), #{mode => urlsafe, padding => false}). - apply_updates(Event, Updates) -> Updated = lists:foldl(fun({Field, Value}, E) -> set_field(Field, Value, E) diff --git a/src/core/core_report.erl b/src/core/core_report.erl index 411c973..713fcf4 100644 --- a/src/core/core_report.erl +++ b/src/core/core_report.erl @@ -6,10 +6,11 @@ -export([generate_id/0]). -export([count_reports_by_status/1, count_reports_by_admin/2]). -export([count_reports_resolved_by_admin/2, avg_resolution_time/1]). +-export([delete/1, update/2]). % <-- добавлено %% Создание жалобы create(ReporterId, TargetType, TargetId, Reason) -> - Id = generate_id(), + Id = infra_utils:generate_id(16), Report = #report{ id = Id, reporter_id = ReporterId, @@ -109,6 +110,41 @@ avg_resolution_time(Status) -> TotalSeconds / length(Resolved) / 3600.0 end. -%% Внутренние функции +%% Мягкое удаление жалобы (просто физически удаляем запись) +-spec delete(binary()) -> {ok, deleted} | {error, not_found}. +delete(Id) -> + case get_by_id(Id) of + {ok, _} -> + mnesia:dirty_delete(report, Id), + {ok, deleted}; + Error -> Error + end. + +%% Обновление произвольных полей жалобы (для административных целей) +-spec update(binary(), proplists:proplist()) -> {ok, #report{}} | {error, not_found}. +update(Id, Updates) -> + case get_by_id(Id) of + {ok, Report} -> + UpdatedReport = apply_updates(Report, Updates), + mnesia:dirty_write(UpdatedReport), + {ok, UpdatedReport}; + Error -> Error + end. + +%%%=================================================================== +%%% Внутренние функции +%%%=================================================================== + generate_id() -> - base64:encode(crypto:strong_rand_bytes(16), #{mode => urlsafe, padding => false}). \ No newline at end of file + 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). \ No newline at end of file diff --git a/src/core/core_review.erl b/src/core/core_review.erl index fc87ce9..eda53f4 100644 --- a/src/core/core_review.erl +++ b/src/core/core_review.erl @@ -4,12 +4,11 @@ -export([create/5, get_by_id/1, list_by_target/2, list_by_user/1, update/2, delete/1, hide/2, unhide/2]). -export([get_average_rating/2, has_user_reviewed/3]). --export([generate_id/0]). -export([count_reviews/0, list_all/0]). %% Создание отзыва create(UserId, TargetType, TargetId, Rating, Comment) -> - Id = generate_id(), + Id = infra_utils:generate_id(16), Review = #review{ id = Id, user_id = UserId, @@ -117,10 +116,6 @@ count_reviews() -> mnesia:table_info(review, size). list_all() -> mnesia:dirty_match_object(#review{_ = '_'}). -%% Внутренние функции -generate_id() -> - base64:encode(crypto:strong_rand_bytes(16), #{mode => urlsafe, padding => false}). - apply_updates(Review, Updates) -> Updated = lists:foldl(fun({Field, Value}, R) -> set_field(Field, Value, R) diff --git a/src/core/core_subscription.erl b/src/core/core_subscription.erl index 0bef79f..bae7bc8 100644 --- a/src/core/core_subscription.erl +++ b/src/core/core_subscription.erl @@ -3,7 +3,6 @@ -export([create/3, get_by_id/1, get_active_by_user/1, list_by_user/1, list_all/0]). -export([update_status/2, check_expired/0]). --export([generate_id/0]). % --------------- новые обёртки для админки ------------------ -export([list_subscriptions/0, create_subscription/1, @@ -16,7 +15,7 @@ %% Создание подписки create(UserId, Plan, TrialUsed) -> - Id = generate_id(), + Id = infra_utils:generate_id(16), Now = calendar:universal_time(), {StartDate, EndDate} = case TrialUsed of @@ -129,10 +128,6 @@ downgrade_user_calendars(UserId) -> core_calendar:update(Cal#calendar.id, [{type, personal}]) end, Calendars). -%% Внутренние функции -generate_id() -> - base64:encode(crypto:strong_rand_bytes(16), #{mode => urlsafe, padding => false}). - plan_to_months(monthly) -> 1; plan_to_months(quarterly) -> 3; plan_to_months(biannual) -> 6; diff --git a/src/core/core_ticket.erl b/src/core/core_ticket.erl index e27c5af..08e4a96 100644 --- a/src/core/core_ticket.erl +++ b/src/core/core_ticket.erl @@ -48,7 +48,7 @@ stats() -> %% ── новые функции ────────────────────────────────────── create_ticket(Data) -> - Id = base64:encode(crypto:strong_rand_bytes(9), #{mode => urlsafe, padding => false}), + Id = infra_utils:generate_id(9), Now = calendar:universal_time(), Ticket = #ticket{ id = Id, @@ -103,4 +103,4 @@ apply_updates(Ticket, Updates) -> <<"context">> -> Acc#ticket{context = Value}; _ -> Acc end - end, Ticket, maps:to_list(Updates)). \ No newline at end of file + end, Ticket, maps:to_list(Updates)). \ No newline at end of file diff --git a/src/core/core_user.erl b/src/core/core_user.erl index f385595..5e2b3ea 100644 --- a/src/core/core_user.erl +++ b/src/core/core_user.erl @@ -3,10 +3,9 @@ -export([create/2, get_by_id/1, get_by_email/1, update/2, update_status/3, delete/1, update_last_login/1]). -export([email_exists/1]). --export([generate_id/0]). -export([list_users/0]). -export([block/2, unblock/2]). --export([count_users/0, count_users_by_date/2]). +-export([count_users/0, count_users_by_date/2, list_all/0]). -export([create_bot/2, delete_bot/1]). %% Создание пользователя @@ -16,7 +15,7 @@ create(Email, Password) -> true -> {error, email_exists}; false -> - Id = generate_id(), + Id = infra_utils:generate_id(16), {ok, PasswordHash} = logic_auth:hash_password(Password), User = #user{ @@ -150,6 +149,10 @@ unblock(Id, Reason) -> count_users() -> mnesia:table_info(user, size). +%% Административный список (все пользователи, без фильтрации) +list_all() -> + mnesia:dirty_match_object(#user{_ = '_'}). + count_users_by_date(From, To) -> All = mnesia:dirty_match_object(#user{_ = '_'}), Filtered = lists:filter(fun(U) -> @@ -166,10 +169,6 @@ count_users_by_date(From, To) -> date_part({{Y,M,D}, _}) -> {Y,M,D}. -%% Внутренние функции -generate_id() -> - base64:encode(crypto:strong_rand_bytes(16), #{mode => urlsafe, padding => false}). - apply_updates(User, Updates) -> Updated = lists:foldl(fun({Field, Value}, U) -> set_field(Field, Value, U) @@ -196,7 +195,7 @@ create_bot(Email, Password) -> case mnesia:dirty_index_read(user, Email, email) of [] -> {ok, PasswordHash} = logic_auth:hash_password(Password), - Id = generate_id(), + Id = infra_utils:generate_id(16), User = #user{ id = Id, email = Email, diff --git a/src/eventhub_app.erl b/src/eventhub_app.erl index ee308be..220b782 100644 --- a/src/eventhub_app.erl +++ b/src/eventhub_app.erl @@ -87,7 +87,7 @@ start_http() -> {"/v1/tickets", handler_tickets, []}, {"/v1/tickets/:id", handler_ticket_by_id, []}, {"/v1/subscription", handler_subscription, []} - ]} + ]} %% 23 ]), Middlewares = [cowboy_router, cowboy_handler], Env = #{dispatch => Dispatch}, @@ -126,7 +126,7 @@ start_admin_http() -> {"/v1/admin/tickets", admin_handler_tickets, []}, % ================== ПОДПИСКИ ================== {"/v1/admin/subscriptions", admin_handler_subscriptions, []}, - {"/v1/admin/subscriptions/:id", admin_handler_subscriptions, []}, + {"/v1/admin/subscriptions/:id", admin_handler_subscriptions_by_id, []}, % ================== МОДЕРАЦИЯ (общий маршрут) ================== {"/v1/admin/:target_type/:id", admin_handler_moderation, []}, % ================== Управление ролями (только для superadmin) ================== diff --git a/src/handlers/admin/admin_handler_admins.erl b/src/handlers/admin/admin_handler_admins.erl index f70326b..6df4940 100644 --- a/src/handlers/admin/admin_handler_admins.erl +++ b/src/handlers/admin/admin_handler_admins.erl @@ -1,117 +1,103 @@ -module(admin_handler_admins). -behaviour(cowboy_handler). --include("records.hrl"). - -export([init/2]). +-export([trails/0]). + +-include("records.hrl"). init(Req, _Opts) -> case cowboy_req:method(Req) of <<"GET">> -> list_admins(Req); - <<"POST">> -> create_admin(Req); - <<"PUT">> -> update_admin_role(Req); - _ -> send_error(Req, 405, <<"Method not allowed">>) + _ -> handler_utils:send_error(Req, 405, <<"Method not allowed">>) end. -list_admins(Req) -> - case handler_auth:authenticate(Req) of - {ok, AdminId, Req1} -> - case admin_utils:check_role(AdminId, superadmin) of - true -> - Admins = core_admin:list_all(), - Json = [admin_to_json(A) || A <- Admins], - send_json(Req1, 200, Json); - false -> - send_error(Req1, 403, <<"Superadmin access required">>) - end; - {error, Code, Message, Req1} -> - send_error(Req1, Code, Message) - end. +trails() -> + [ + #{ + path => <<"/v1/admin/admins">>, + method => <<"GET">>, + description => <<"List all admins (superadmin only)">>, + tags => [<<"Admins">>], + parameters => [ + #{name => <<"role">>, in => <<"query">>, schema => #{type => string}}, + #{name => <<"status">>, in => <<"query">>, schema => #{type => string}}, + #{name => <<"limit">>, in => <<"query">>, schema => #{type => integer}}, + #{name => <<"offset">>, in => <<"query">>, schema => #{type => integer}} + ], + responses => #{ + 200 => #{ + description => <<"Array of admins">>, + content => #{<<"application/json">> => #{schema => #{ + type => array, + items => admin_schema() + }}} + } + } + } + ]. -create_admin(Req) -> - case handler_auth:authenticate(Req) of - {ok, AdminId, Req1} -> - case admin_utils:check_role(AdminId, superadmin) of - true -> - {ok, Body, Req2} = cowboy_req:read_body(Req1), - try jsx:decode(Body, [return_maps]) of - #{<<"email">> := Email, <<"password">> := Password, <<"role">> := RoleBin} -> - Role = binary_to_atom(RoleBin, utf8), - case core_admin:create(Email, Password, Role) of - {ok, Admin} -> - send_json(Req2, 201, admin_to_json(Admin)); - {error, email_exists} -> - send_error(Req2, 409, <<"Email already exists">>); - {error, Reason} -> - send_error(Req2, 500, Reason) - end; - _ -> - send_error(Req2, 400, <<"Missing required fields (email, password, role)">>) - catch - _:_ -> send_error(Req2, 400, <<"Invalid JSON">>) - end; - false -> - send_error(Req1, 403, <<"Superadmin access required">>) - end; - {error, Code, Message, Req1} -> - send_error(Req1, Code, Message) - end. - -update_admin_role(Req) -> - case handler_auth:authenticate(Req) of - {ok, AdminId, Req1} -> - case admin_utils:check_role(AdminId, superadmin) of - true -> - AdminIdToUpdate = cowboy_req:binding(id, Req1), - {ok, Body, Req2} = cowboy_req:read_body(Req1), - try jsx:decode(Body, [return_maps]) of - #{<<"role">> := RoleBin} -> - NewRole = binary_to_atom(RoleBin, utf8), - case core_admin:update_role(AdminIdToUpdate, NewRole) of - {ok, Admin} -> - send_json(Req2, 200, admin_to_json(Admin)); - {error, not_found} -> - send_error(Req2, 404, <<"Admin not found">>); - {error, Reason} -> - send_error(Req2, 500, Reason) - end; - _ -> - send_error(Req2, 400, <<"Missing 'role' field">>) - catch - _:_ -> send_error(Req2, 400, <<"Invalid JSON">>) - end; - false -> - send_error(Req1, 403, <<"Superadmin access required">>) - end; - {error, Code, Message, Req1} -> - send_error(Req1, Code, Message) - end. - -admin_to_json(A) -> +admin_schema() -> #{ - id => A#admin.id, - email => A#admin.email, - role => A#admin.role, - status => A#admin.status, - created_at => datetime_to_iso8601(A#admin.created_at), - updated_at => datetime_to_iso8601(A#admin.updated_at) + type => object, + properties => #{ + id => #{type => string}, + email => #{type => string, format => <<"email">>}, + role => #{type => string, enum => [<<"superadmin">>, <<"admin">>, <<"moderator">>, <<"support">>]}, + status => #{type => string, enum => [<<"active">>, <<"blocked">>]}, + nickname => #{type => string, nullable => true}, + avatar_url => #{type => string, nullable => true}, + timezone => #{type => string, nullable => true}, + language => #{type => string, nullable => true}, + phone => #{type => string, nullable => true}, + preferences => #{type => object, nullable => true}, + last_login => #{type => string, format => <<"date-time">>}, + created_at => #{type => string, format => <<"date-time">>}, + updated_at => #{type => string, format => <<"date-time">>} + } }. -datetime_to_iso8601({{Y,M,D},{H,Min,S}}) -> - iolist_to_binary(io_lib:format("~4..0B-~2..0B-~2..0BT~2..0B:~2..0B:~2..0BZ", [Y,M,D,H,Min,S])); -datetime_to_iso8601(_) -> null. +list_admins(Req) -> + case handler_utils:auth_admin(Req) of + {ok, _AdminId, Req1} -> + Filters = parse_admin_filters(Req1), + Pagination = handler_utils:parse_pagination_params(Req1), + {ok, Total, Admins} = logic_admin:list_admins(Filters, Pagination), + Json = [admin_to_json(A) || A <- Admins], + ExtraHeaders = pagination_headers(Pagination, Total), + handler_utils:send_json(Req1, 200, Json, ExtraHeaders); + {error, Code, Msg, Req1} -> + handler_utils:send_error(Req1, Code, Msg) + end. -send_json(Req, Status, Data) -> - Headers = #{ - <<"content-type">> => <<"application/json">>, - <<"access-control-allow-origin">> => <<"*">>, - <<"access-control-expose-headers">> => <<"Content-Range">> - }, - Body = jsx:encode(Data), - cowboy_req:reply(Status, Headers, Body, Req), - {ok, Body, []}. +parse_admin_filters(Req) -> + Qs = cowboy_req:parse_qs(Req), + #{ + role => proplists:get_value(<<"role">>, Qs), + status => proplists:get_value(<<"status">>, Qs) + }. -send_error(Req, Code, Message) -> - Body = jsx:encode(#{error => Message}), - Req2 = cowboy_req:reply(Code, #{<<"content-type">> => <<"application/json">>}, Body, Req), - {ok, Req2, []}. \ No newline at end of file +admin_to_json(Admin) -> + #{ + id => Admin#admin.id, + email => Admin#admin.email, + role => Admin#admin.role, + status => Admin#admin.status, + nickname => Admin#admin.nickname, + avatar_url => Admin#admin.avatar_url, + timezone => Admin#admin.timezone, + language => Admin#admin.language, + phone => Admin#admin.phone, + preferences => Admin#admin.preferences, + last_login => handler_utils:parse_datetime(Admin#admin.last_login), % требует доработки – лучше общую функцию + created_at => handler_utils:parse_datetime(Admin#admin.created_at), + updated_at => handler_utils:parse_datetime(Admin#admin.updated_at) + }. + +pagination_headers(#{limit := Limit, offset := Offset}, Total) -> + RangeEnd = min(Offset + Limit - 1, Total - 1), + #{ + <<"content-range">> => iolist_to_binary(io_lib:format("items ~B-~B/~B", [Offset, RangeEnd, Total])), + <<"x-total-count">> => integer_to_binary(Total), + <<"access-control-expose-headers">> => <<"Content-Range, X-Total-Count">> + }. \ No newline at end of file diff --git a/src/handlers/admin/admin_handler_audit.erl b/src/handlers/admin/admin_handler_audit.erl index 104c723..09a2f57 100644 --- a/src/handlers/admin/admin_handler_audit.erl +++ b/src/handlers/admin/admin_handler_audit.erl @@ -1,63 +1,173 @@ +%%%------------------------------------------------------------------- +%%% @doc Административный обработчик журнала аудита. +%%% GET – список записей аудита с пагинацией и фильтрацией. +%%% @end +%%%------------------------------------------------------------------- -module(admin_handler_audit). -behaviour(cowboy_handler). +-export([init/2]). +-export([trails/0]). + -include("records.hrl"). --export([init/2]). - +%%% cowboy_handler callback +-spec init(cowboy_req:req(), any()) -> {ok, cowboy_req:req(), any()}. init(Req, _Opts) -> case cowboy_req:method(Req) of - <<"GET">> -> - case handler_auth:authenticate(Req) of - {ok, AdminId, Req1} -> - case admin_utils:check_role(AdminId, superadmin) of - true -> - Filters = parse_filters(Req1), - Entries = core_admin_audit:list(Filters), - Json = [audit_to_json(E) || E <- Entries], - send_json(Req1, 200, Json); - false -> - send_error(Req1, 403, <<"Superadmin access required">>) - end; - {error, Code, Message, Req1} -> - send_error(Req1, Code, Message) - end; - _ -> - send_error(Req, 405, <<"Method not allowed">>) + <<"GET">> -> list_audit(Req); + _ -> handler_utils:send_error(Req, 405, <<"Method not allowed">>) end. -parse_filters(Req) -> - Qs = cowboy_req:parse_qs(Req), - lists:filtermap(fun - ({<<"admin_id">>, Val}) -> {true, {admin_id, Val}}; - ({<<"action">>, Val}) -> {true, {action, Val}}; - (_) -> false - end, Qs). +%%% Swagger metadata +-spec trails() -> [map()]. +trails() -> + [ + #{ + path => <<"/v1/admin/audit">>, + method => <<"GET">>, + description => <<"List audit records (admin)">>, + tags => [<<"Audit">>], + parameters => [ + #{name => <<"admin_id">>, in => <<"query">>, schema => #{type => string}, description => <<"Filter by admin ID">>}, + #{name => <<"action">>, in => <<"query">>, schema => #{type => string}, description => <<"Filter by action">>}, + #{name => <<"entity_type">>, in => <<"query">>, schema => #{type => string}, description => <<"Filter by entity type">>}, + #{name => <<"from">>, in => <<"query">>, schema => #{type => string, format => <<"date-time">>}, description => <<"Start timestamp (ISO8601)">>}, + #{name => <<"to">>, in => <<"query">>, schema => #{type => string, format => <<"date-time">>}, description => <<"End timestamp (ISO8601)">>}, + #{name => <<"limit">>, in => <<"query">>, schema => #{type => integer}, description => <<"Page size">>}, + #{name => <<"offset">>, in => <<"query">>, schema => #{type => integer}, description => <<"Offset">>} + ], + responses => #{ + 200 => #{ + description => <<"Array of audit records">>, + content => #{<<"application/json">> => #{schema => #{ + type => array, + items => audit_schema() + }}} + } + } + } + ]. -audit_to_json(E) -> +audit_schema() -> #{ - id => E#admin_audit.id, - admin_id => E#admin_audit.admin_id, - email => E#admin_audit.email, - role => E#admin_audit.role, - action => E#admin_audit.action, - entity_type => E#admin_audit.entity_type, - entity_id => E#admin_audit.entity_id, - timestamp => datetime_to_iso8601(E#admin_audit.timestamp), - ip => E#admin_audit.ip, - reason => E#admin_audit.reason + type => object, + properties => #{ + id => #{type => string}, + admin_id => #{type => string}, + email => #{type => string, format => <<"email">>}, + role => #{type => string}, + action => #{type => string}, + entity_type => #{type => string}, + entity_id => #{type => string}, + timestamp => #{type => string, format => <<"date-time">>}, + ip => #{type => string}, + reason => #{type => string, nullable => true} + } }. -datetime_to_iso8601({{Y,M,D},{H,Min,S}}) -> - iolist_to_binary(io_lib:format("~4..0B-~2..0B-~2..0BT~2..0B:~2..0B:~2..0BZ", [Y,M,D,H,Min,S])); -datetime_to_iso8601(_) -> null. +%%% Internal functions -send_json(Req, Status, Data) -> - Body = jsx:encode(Data), - Req2 = cowboy_req:reply(Status, #{<<"content-type">> => <<"application/json">>}, Body, Req), - {ok, Req2, []}. +%% @doc Получить список записей аудита с пагинацией и фильтрацией. +-spec list_audit(cowboy_req:req()) -> {ok, binary(), cowboy_req:req()}. +list_audit(Req) -> + case handler_utils:auth_admin(Req) of + {ok, _AdminId, Req1} -> + Filters = parse_audit_filters(Req1), + Pagination = handler_utils:parse_pagination_params(Req1), + %% Предполагается, что core_admin_audit (или аналогичный) предоставляет list_all/0 + {ok, AllRecords} = core_admin_audit:list(), + Filtered = apply_filters(AllRecords, Filters), + Sorted = sort_audit(Filtered, Pagination), + Total = length(Sorted), + Page = lists:sublist(Sorted, maps:get(offset, Pagination) + 1, maps:get(limit, Pagination)), + Json = [audit_to_json(R) || R <- Page], + ExtraHeaders = pagination_headers(Pagination, Total), + handler_utils:send_json(Req1, 200, Json, ExtraHeaders); + {error, Code, Msg, Req1} -> + handler_utils:send_error(Req1, Code, Msg) + end. -send_error(Req, Code, Message) -> - Body = jsx:encode(#{error => Message}), - Req2 = cowboy_req:reply(Code, #{<<"content-type">> => <<"application/json">>}, Body, Req), - {ok, Req2, []}. \ No newline at end of file +%% @private Извлечь фильтры из query string. +-spec parse_audit_filters(cowboy_req:req()) -> map(). +parse_audit_filters(Req) -> + Qs = cowboy_req:parse_qs(Req), + #{ + admin_id => proplists:get_value(<<"admin_id">>, Qs), + action => proplists:get_value(<<"action">>, Qs), + entity_type => proplists:get_value(<<"entity_type">>, Qs), + from => handler_utils:parse_datetime_qs(proplists:get_value(<<"from">>, Qs)), + to => handler_utils:parse_datetime_qs(proplists:get_value(<<"to">>, Qs)) + }. + +%% @private Применить фильтры к списку записей аудита. +-spec apply_filters([#admin_audit{}], map()) -> [#admin_audit{}]. +apply_filters(Records, Filters) -> + AdminId = maps:get(admin_id, Filters, undefined), + Action = maps:get(action, Filters, undefined), + EntityType = maps:get(entity_type, Filters, undefined), + From = maps:get(from, Filters, undefined), + To = maps:get(to, Filters, undefined), + R1 = case AdminId of + undefined -> Records; + _ -> [R || R <- Records, R#admin_audit.admin_id =:= AdminId] + end, + R2 = case Action of + undefined -> R1; + _ -> [R || R <- R1, R#admin_audit.action =:= Action] + end, + R3 = case EntityType of + undefined -> R2; + _ -> [R || R <- R2, R#admin_audit.entity_type =:= EntityType] + end, + R4 = case From of + undefined -> R3; + _ -> [R || R <- R3, R#admin_audit.timestamp >= From] + end, + case To of + undefined -> R4; + _ -> [R || R <- R4, R#admin_audit.timestamp =< To] + end. + +%% @private Отсортировать записи аудита. +-spec sort_audit([#admin_audit{}], map()) -> [#admin_audit{}]. +sort_audit(Records, #{sort := Sort, order := Order}) -> + Field = binary_to_existing_atom(Sort, utf8), + lists:sort( + fun(A, B) -> + ValA = audit_field(A, Field), + ValB = audit_field(B, Field), + if Order == <<"asc">> -> ValA =< ValB; + true -> ValA >= ValB + end + end, Records). + +audit_field(#admin_audit{timestamp = V}, timestamp) -> V; +audit_field(#admin_audit{action = V}, action) -> V; +audit_field(_, _) -> undefined. + +%% @private Преобразовать запись аудита в JSON-карту. +-spec audit_to_json(#admin_audit{}) -> map(). +audit_to_json(A) -> + #{ + id => A#admin_audit.id, + admin_id => A#admin_audit.admin_id, + email => A#admin_audit.email, + role => A#admin_audit.role, + action => A#admin_audit.action, + entity_type => A#admin_audit.entity_type, + entity_id => A#admin_audit.entity_id, + timestamp => handler_utils:datetime_to_iso8601(A#admin_audit.timestamp), + ip => A#admin_audit.ip, + reason => A#admin_audit.reason + }. + +%% @private Сформировать заголовки пагинации. +-spec pagination_headers(map(), non_neg_integer()) -> map(). +pagination_headers(#{limit := Limit, offset := Offset}, Total) -> + RangeEnd = min(Offset + Limit - 1, Total - 1), + #{ + <<"content-range">> => iolist_to_binary(io_lib:format("items ~B-~B/~B", [Offset, RangeEnd, Total])), + <<"x-total-count">> => integer_to_binary(Total), + <<"access-control-expose-headers">> => <<"Content-Range, X-Total-Count">> + }. \ No newline at end of file diff --git a/src/handlers/admin/admin_handler_banned_words.erl b/src/handlers/admin/admin_handler_banned_words.erl index 5c0d0a1..17cd098 100644 --- a/src/handlers/admin/admin_handler_banned_words.erl +++ b/src/handlers/admin/admin_handler_banned_words.erl @@ -1,156 +1,177 @@ +%%%------------------------------------------------------------------- +%%% @doc Административный обработчик бан-слов. +%%% GET – список всех слов с пагинацией. +%%% POST – добавить новое слово. +%%% DELETE – удалить слово по :word. +%%% @end +%%%------------------------------------------------------------------- -module(admin_handler_banned_words). -behaviour(cowboy_handler). + -export([init/2]). +-export([trails/0]). -include("records.hrl"). +-spec init(cowboy_req:req(), any()) -> {ok, cowboy_req:req(), any()}. init(Req, _Opts) -> - case cowboy_req:binding(word, Req) of - undefined -> handle_collection(Req); - Word -> handle_item(Word, Req) - end. - -handle_collection(Req) -> case cowboy_req:method(Req) of - <<"GET">> -> list_banned_words(Req); - <<"POST">> -> add_banned_word(Req); - _ -> send_error(Req, 405, <<"Method not allowed">>) + <<"GET">> -> list_words(Req); + <<"POST">> -> add_word(Req); + <<"DELETE">> -> delete_word(Req); + _ -> handler_utils:send_error(Req, 405, <<"Method not allowed">>) end. -handle_item(Word, Req) -> - case cowboy_req:method(Req) of - <<"DELETE">> -> delete_banned_word(Word, Req); - <<"PUT">> -> update_banned_word(Word, Req); - _ -> send_error(Req, 405, <<"Method not allowed">>) - end. +-spec trails() -> [map()]. +trails() -> + [ + #{ % GET list + path => <<"/v1/admin/banned-words">>, + method => <<"GET">>, + description => <<"List all banned words (admin)">>, + tags => [<<"Banned Words">>], + parameters => [ + #{name => <<"limit">>, in => <<"query">>, schema => #{type => integer}, description => <<"Page size">>}, + #{name => <<"offset">>, in => <<"query">>, schema => #{type => integer}, description => <<"Offset">>} + ], + responses => #{ + 200 => #{ + description => <<"Array of banned words">>, + content => #{<<"application/json">> => #{schema => #{ + type => array, + items => banned_word_schema() + }}} + } + } + }, + #{ % POST add + path => <<"/v1/admin/banned-words">>, + method => <<"POST">>, + description => <<"Add a new banned word">>, + tags => [<<"Banned Words">>], + requestBody => #{ + required => true, + content => #{<<"application/json">> => #{schema => #{ + type => object, + required => [<<"word">>], + properties => #{ + word => #{type => string} + } + }}} + }, + responses => #{ + 201 => #{description => <<"Word added">>}, + 409 => #{description => <<"Word already exists">>} + } + }, + #{ % DELETE by word + path => <<"/v1/admin/banned-words/:word">>, + method => <<"DELETE">>, + description => <<"Remove a banned word">>, + tags => [<<"Banned Words">>], + parameters => [ + #{ + name => <<"word">>, + in => <<"path">>, + description => <<"The word to remove">>, + required => true, + schema => #{type => string} + } + ], + responses => #{ + 200 => #{description => <<"Word removed">>}, + 404 => #{description => <<"Word not found">>} + } + } + ]. -%% ================== GET /banned-words ================== -list_banned_words(Req) -> - case auth_admin(Req) of - {ok, _AdminId, Req1} -> - Words = core_banned_words:list_banned_words(), - send_json(Req1, 200, [banned_word_to_json(W) || W <- Words]); - {error, Code, Message, Req1} -> - send_error(Req1, Code, Message) - end. - -%% ================== POST /banned-words ================== -add_banned_word(Req) -> - case auth_admin(Req) of - {ok, AdminId, Req1} -> - {ok, Body, Req2} = cowboy_req:read_body(Req1), - try jsx:decode(Body, [return_maps]) of - #{<<"word">> := Word} when byte_size(Word) > 0 -> - case core_banned_words:add_banned_word(Word, AdminId) of - {ok, BW} -> - log_audit(AdminId, <<"add_banned_word">>, <<"banned_word">>, BW#banned_word.id, <<"">>), - send_json(Req2, 201, banned_word_to_json(BW)); - {error, already_exists} -> - send_error(Req2, 409, <<"Word already exists">>); - {error, _} -> - send_error(Req2, 500, <<"Internal server error">>) - end; - _ -> - send_error(Req2, 400, <<"Missing or empty 'word'">>) - catch - _:_ -> send_error(Req2, 400, <<"Invalid JSON">>) - end; - {error, Code, Message, Req1} -> - send_error(Req1, Code, Message) - end. - -%% ================== DELETE /banned-words/:word ================== -delete_banned_word(Word, Req) -> - case auth_admin(Req) of - {ok, AdminId, Req1} -> - case core_banned_words:remove_banned_word(Word) of - {ok, deleted} -> - log_audit(AdminId, <<"delete_banned_word">>, <<"banned_word">>, Word, <<"">>), - send_json(Req1, 200, #{status => <<"deleted">>}); - {error, not_found} -> - send_error(Req1, 404, <<"Word not found">>) - end; - {error, Code, Message, Req1} -> - send_error(Req1, Code, Message) - end. - -%% ================== PUT /banned-words/:word ================== -update_banned_word(OldWord, Req) -> - case auth_admin(Req) of - {ok, AdminId, Req1} -> - {ok, Body, Req2} = cowboy_req:read_body(Req1), - try jsx:decode(Body, [return_maps]) of - #{<<"word">> := NewWord} when byte_size(NewWord) > 0 -> - case core_banned_words:update_banned_word(OldWord, NewWord) of - {ok, BW} -> - log_audit(AdminId, <<"update_banned_word">>, <<"banned_word">>, BW#banned_word.id, <<"">>), - send_json(Req2, 200, banned_word_to_json(BW)); - {error, not_found} -> - send_error(Req2, 404, <<"Word not found">>); - {error, _} -> - send_error(Req2, 500, <<"Internal server error">>) - end; - _ -> - send_error(Req2, 400, <<"Missing or empty 'word'">>) - catch - _:_ -> send_error(Req2, 400, <<"Invalid JSON">>) - end; - {error, Code, Message, Req1} -> - send_error(Req1, Code, Message) - end. - -%% ── Аудит ────────────────────────────────────────────── -log_audit(AdminId, Action, EntityType, EntityId, Reason) -> - case core_admin:get_by_id(AdminId) of - {ok, Admin} -> - core_admin_audit:log(AdminId, Admin#admin.email, Admin#admin.role, - Action, EntityType, EntityId, <<"127.0.0.1">>, Reason); - _ -> ok - end. - -%% ================== Аутентификация ================== -auth_admin(Req) -> - case handler_auth:authenticate(Req) of - {ok, AdminId, Req1} -> - case admin_utils:is_admin(AdminId) of - true -> {ok, AdminId, Req1}; - false -> {error, 403, <<"Admin access required">>, Req1} - end; - {error, Code, Message, Req1} -> - {error, Code, Message, Req1} - end. - -%% ================== Сериализация ================== -banned_word_to_json(BW) -> +banned_word_schema() -> #{ - id => BW#banned_word.id, - word => BW#banned_word.word, - added_by => BW#banned_word.added_by, - added_at => datetime_to_iso8601(BW#banned_word.added_at) + type => object, + properties => #{ + id => #{type => string}, + word => #{type => string}, + added_by => #{type => string, nullable => true}, + added_at => #{type => string, format => <<"date-time">>, nullable => true} + } }. +%%% Internal functions + +list_words(Req) -> + case handler_utils:auth_admin(Req) of + {ok, _AdminId, Req1} -> + Pagination = handler_utils:parse_pagination_params(Req1), + %% core_banned_words:list_banned_words() возвращает список, а не {ok, List} + AllWords = core_banned_words:list_banned_words(), + BannedWords = lists:sort(AllWords), + Total = length(BannedWords), + Page = lists:sublist(BannedWords, maps:get(offset, Pagination) + 1, maps:get(limit, Pagination)), + Json = [word_to_map(W) || W <- Page], + ExtraHeaders = pagination_headers(Pagination, Total), + handler_utils:send_json(Req1, 200, Json, ExtraHeaders); + {error, Code, Msg, Req1} -> + handler_utils:send_error(Req1, Code, Msg) + end. + +add_word(Req) -> + case handler_utils:auth_admin(Req) of + {ok, AdminId, Req1} -> + {ok, Body, Req2} = cowboy_req:read_body(Req1), + try jsx:decode(Body, [return_maps]) of + #{<<"word">> := Word} -> + case core_banned_words:add_banned_word(Word, AdminId) of + {ok, _} -> + handler_utils:send_json(Req2, 201, #{status => <<"added">>}); + {error, already_exists} -> + handler_utils:send_error(Req2, 409, <<"Word already exists">>); + {error, _} -> + handler_utils:send_error(Req2, 500, <<"Internal server error">>) + end; + _ -> + handler_utils:send_error(Req2, 400, <<"Missing 'word' field">>) + catch + _:_ -> handler_utils:send_error(Req1, 400, <<"Invalid JSON">>) + end; + {error, Code, Msg, Req1} -> + handler_utils:send_error(Req1, Code, Msg) + end. + +delete_word(Req) -> + case handler_utils:auth_admin(Req) of + {ok, _AdminId, Req1} -> + Word = cowboy_req:binding(word, Req1), + case core_banned_words:remove_banned_word(Word) of + {ok, _} -> + handler_utils:send_json(Req1, 200, #{status => <<"deleted">>}); + {error, not_found} -> + handler_utils:send_error(Req1, 404, <<"Word not found">>); + {error, _} -> + handler_utils:send_error(Req1, 500, <<"Internal server error">>) + end; + {error, Code, Msg, Req1} -> + handler_utils:send_error(Req1, Code, Msg) + end. + +%% @private Преобразование записи banned_word в JSON-совместимую карту. +word_to_map(W) -> + #{ + id => W#banned_word.id, + word => W#banned_word.word, + added_by => W#banned_word.added_by, + added_at => datetime_to_iso8601(W#banned_word.added_at) + }. + +%% @private Форматирование datetime в ISO8601 строку. +datetime_to_iso8601(undefined) -> undefined; datetime_to_iso8601({{Year, Month, Day}, {Hour, Minute, Second}}) -> iolist_to_binary(io_lib:format("~4..0B-~2..0B-~2..0BT~2..0B:~2..0B:~2..0BZ", - [Year, Month, Day, Hour, Minute, Second])); -datetime_to_iso8601(undefined) -> undefined. + [Year, Month, Day, Hour, Minute, Second])). -%% ================== HTTP-ответы ================== -send_json(Req, Status, Data) -> - Headers = #{ - <<"content-type">> => <<"application/json">>, - <<"access-control-allow-origin">> => <<"*">>, - <<"access-control-expose-headers">> => <<"Content-Range">> - }, - Body = jsx:encode(Data), - cowboy_req:reply(Status, Headers, Body, Req), - {ok, Body, []}. - -send_error(Req, Code, Message) -> - Headers = #{ - <<"content-type">> => <<"application/json">>, - <<"access-control-allow-origin">> => <<"*">>, - <<"access-control-expose-headers">> => <<"Content-Range">> - }, - Body = jsx:encode(#{error => Message}), - cowboy_req:reply(Code, Headers, Body, Req), - {ok, Body, []}. \ No newline at end of file +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">> + }. \ No newline at end of file diff --git a/src/handlers/admin/admin_handler_event_by_id.erl b/src/handlers/admin/admin_handler_event_by_id.erl index 9c1546a..e095cbb 100644 --- a/src/handlers/admin/admin_handler_event_by_id.erl +++ b/src/handlers/admin/admin_handler_event_by_id.erl @@ -15,73 +15,58 @@ init(Req, _Opts) -> <<"GET">> -> get_event(Req); <<"PUT">> -> update_event(Req); <<"DELETE">> -> delete_event(Req); - _ -> send_error(Req, 405, <<"Method not allowed">>) + _ -> handler_utils:send_error(Req, 405, <<"Method not allowed">>) end. %%%=================================================================== -%%% Swagger / Trails metadata +%%% Swagger metadata %%%=================================================================== trails() -> - Path = <<"/v1/admin/events/:id">>, BaseParams = [ #{ - name => <<"id">>, - in => <<"path">>, + name => <<"id">>, + in => <<"path">>, description => <<"Event ID">>, - required => true, - schema => #{type => string} + required => true, + schema => #{type => string} } ], [ - %% GET - #{ - path => Path, - method => <<"GET">>, - handler => ?MODULE, - tags => [<<"Events: id">>], + #{ % GET + path => <<"/v1/admin/events/:id">>, + method => <<"GET">>, description => <<"Get event by ID (admin)">>, - parameters => BaseParams, - responses => #{ + tags => [<<"Events">>], + parameters => BaseParams, + responses => #{ 200 => #{ description => <<"Event details">>, - content => #{ - <<"application/json">> => #{ - schema => event_schema() - } - } + content => #{<<"application/json">> => #{schema => event_schema()}} } } }, - %% PUT - #{ - path => Path, - method => <<"PUT">>, - handler => ?MODULE, - tags => [<<"Events: id">>], + #{ % PUT + path => <<"/v1/admin/events/:id">>, + method => <<"PUT">>, description => <<"Update event (admin)">>, - parameters => BaseParams, + tags => [<<"Events">>], + parameters => BaseParams, requestBody => #{ required => true, - content => #{ - <<"application/json">> => #{ - schema => event_update_schema() - } - } + content => #{<<"application/json">> => #{schema => event_update_schema()}} }, - responses => #{ + responses => #{ 200 => #{description => <<"Updated event">>} } }, - %% DELETE - #{ - path => Path, - method => <<"DELETE">>, - handler => ?MODULE, - tags => [<<"Events: id">>], + #{ % DELETE + path => <<"/v1/admin/events/:id">>, + method => <<"DELETE">>, description => <<"Soft-delete event (admin)">>, - parameters => BaseParams, - responses => #{ + tags => [<<"Events">>], + parameters => BaseParams, + responses => #{ 200 => #{description => <<"Event status set to deleted">>} } } @@ -89,52 +74,55 @@ trails() -> event_schema() -> #{ - type => object, + type => object, properties => #{ - id => #{type => string}, - calendar_id => #{type => string}, - title => #{type => string}, - description => #{type => string}, - event_type => #{type => string, enum => [<<"single">>, <<"recurring">>]}, - start_time => #{type => string, format => <<"date-time">>}, - duration => #{type => integer}, - recurrence => #{type => object, nullable => true}, - master_id => #{type => string, nullable => true}, - is_instance => #{type => boolean}, - specialist_id => #{type => string, nullable => true}, - location => #{type => object, nullable => true}, - tags => #{type => array, items => #{type => string}}, - capacity => #{type => integer, nullable => true}, - online_link => #{type => string, nullable => true}, - status => #{type => string, enum => [<<"active">>, <<"cancelled">>, <<"completed">>]}, - rating_avg => #{type => number, format => float}, - rating_count => #{type => integer}, - created_at => #{type => string, format => <<"date-time">>}, - updated_at => #{type => string, format => <<"date-time">>} + id => #{type => string}, + calendar_id => #{type => string}, + title => #{type => string}, + description => #{type => string}, + event_type => #{type => string, enum => [<<"single">>, <<"recurring">>]}, + start_time => #{type => string, format => <<"date-time">>}, + duration => #{type => integer}, + recurrence => #{type => object, nullable => true}, + master_id => #{type => string, nullable => true}, + is_instance => #{type => boolean}, + specialist_id => #{type => string, nullable => true}, + location => #{type => object, nullable => true}, + tags => #{type => array, items => #{type => string}}, + capacity => #{type => integer, nullable => true}, + online_link => #{type => string, nullable => true}, + status => #{type => string, enum => [<<"active">>, <<"cancelled">>, <<"completed">>]}, + reason => #{type => string, nullable => true}, + rating_avg => #{type => number, format => float}, + rating_count => #{type => integer}, + attachments => #{type => array, items => #{type => string}, nullable => true}, + edit_history => #{type => array, items => #{type => object}, nullable => true}, + created_at => #{type => string, format => <<"date-time">>}, + updated_at => #{type => string, format => <<"date-time">>} } }. event_update_schema() -> #{ - type => object, + type => object, properties => #{ - title => #{type => string}, - description => #{type => string}, - start_time => #{type => string, format => <<"date-time">>}, - duration => #{type => integer}, - status => #{type => string, enum => [<<"active">>, <<"cancelled">>, <<"completed">>]}, + title => #{type => string}, + description => #{type => string}, + start_time => #{type => string, format => <<"date-time">>}, + duration => #{type => integer}, + status => #{type => string, enum => [<<"active">>, <<"cancelled">>, <<"completed">>]}, specialist_id => #{type => string}, - location => #{ - type => object, + location => #{ + type => object, properties => #{ address => #{type => string}, - lat => #{type => number, format => float}, - lon => #{type => number, format => float} + lat => #{type => number, format => float}, + lon => #{type => number, format => float} } }, - tags => #{type => array, items => #{type => string}}, - capacity => #{type => integer}, - online_link => #{type => string} + tags => #{type => array, items => #{type => string}}, + capacity => #{type => integer}, + online_link => #{type => string} } }. @@ -142,26 +130,24 @@ event_update_schema() -> %%% Internal functions %%%=================================================================== -%% GET /v1/admin/events/:id get_event(Req) -> - case auth_admin(Req) of + case handler_utils:auth_admin(Req) of {ok, _AdminId, Req1} -> EventId = cowboy_req:binding(id, Req1), case logic_event:get_event_admin(EventId) of {ok, Event} -> - send_json(Req1, 200, event_to_json(Event)); + handler_utils:send_json(Req1, 200, handler_utils:event_to_json(Event)); {error, not_found} -> - send_error(Req1, 404, <<"Event not found">>); + handler_utils:send_error(Req1, 404, <<"Event not found">>); {error, _} -> - send_error(Req1, 500, <<"Internal server error">>) + handler_utils:send_error(Req1, 500, <<"Internal server error">>) end; {error, Code, Msg, Req1} -> - send_error(Req1, Code, Msg) + handler_utils:send_error(Req1, Code, Msg) end. -%% PUT /v1/admin/events/:id update_event(Req) -> - case auth_admin(Req) of + case handler_utils:auth_admin(Req) of {ok, _AdminId, Req1} -> EventId = cowboy_req:binding(id, Req1), {ok, Body, Req2} = cowboy_req:read_body(Req1), @@ -171,171 +157,66 @@ update_event(Req) -> UpdatesWithTypes = convert_fields(Updates), case logic_event:update_event_admin(EventId, UpdatesWithTypes) of {ok, Event} -> - send_json(Req2, 200, event_to_json(Event)); + handler_utils:send_json(Req2, 200, handler_utils:event_to_json(Event)); {error, not_found} -> - send_error(Req2, 404, <<"Event not found">>); + handler_utils:send_error(Req2, 404, <<"Event not found">>); {error, _} -> - send_error(Req2, 500, <<"Internal server error">>) + handler_utils:send_error(Req2, 500, <<"Internal server error">>) end; _ -> - send_error(Req2, 400, <<"Invalid JSON">>) + handler_utils:send_error(Req2, 400, <<"Invalid JSON">>) catch - _:_ -> send_error(Req1, 400, <<"Invalid JSON format">>) + _:_ -> handler_utils:send_error(Req1, 400, <<"Invalid JSON format">>) end; {error, Code, Msg, Req1} -> - send_error(Req1, Code, Msg) + handler_utils:send_error(Req1, Code, Msg) end. -%% DELETE /v1/admin/events/:id delete_event(Req) -> - case auth_admin(Req) of + case handler_utils:auth_admin(Req) of {ok, _AdminId, Req1} -> EventId = cowboy_req:binding(id, Req1), case logic_event:delete_event_admin(EventId) of {ok, _} -> - send_json(Req1, 200, #{status => <<"deleted">>}); + handler_utils:send_json(Req1, 200, #{status => <<"deleted">>}); {error, not_found} -> - send_error(Req1, 404, <<"Event not found">>); + handler_utils:send_error(Req1, 404, <<"Event not found">>); {error, _} -> - send_error(Req1, 500, <<"Internal server error">>) + handler_utils:send_error(Req1, 500, <<"Internal server error">>) end; {error, Code, Msg, Req1} -> - send_error(Req1, Code, Msg) + handler_utils:send_error(Req1, Code, Msg) end. -%%-------------------------------------------------------------------- -%% Auth helpers -%%-------------------------------------------------------------------- - -auth_admin(Req) -> - case handler_auth:authenticate(Req) of - {ok, AdminId, Req1} -> - case admin_utils:is_admin(AdminId) of - true -> {ok, AdminId, Req1}; - false -> {error, 403, <<"Admin access required">>, Req1} - end; - {error, Code, Msg, Req1} -> - {error, Code, Msg, Req1} - end. - -%%-------------------------------------------------------------------- -%% Field conversion (from binary keys/values to internal atoms) -%%-------------------------------------------------------------------- - convert_fields(Updates) -> lists:map(fun convert_field/1, Updates). -convert_field({<<"title">>, Val}) -> {title, Val}; -convert_field({<<"description">>, Val}) -> {description, Val}; -convert_field({<<"event_type">>, Val}) -> {event_type, Val}; -convert_field({<<"start_time">>, Val}) -> - case parse_datetime(Val) of +convert_field({<<"title">>, Val}) -> {title, Val}; +convert_field({<<"description">>, Val}) -> {description, Val}; +convert_field({<<"event_type">>, Val}) -> {event_type, Val}; +convert_field({<<"start_time">>, Val}) -> + case handler_utils:parse_datetime(Val) of {ok, Dt} -> {start_time, Dt}; - _ -> {start_time, Val} + _ -> {start_time, Val} end; -convert_field({<<"duration">>, Val}) -> {duration, Val}; -convert_field({<<"recurrence">>, Val}) -> - RuleJson = jsx:encode(Val), - {recurrence_rule, RuleJson}; +convert_field({<<"duration">>, Val}) -> {duration, Val}; +convert_field({<<"recurrence">>, Val}) -> {recurrence_rule, jsx:encode(Val)}; convert_field({<<"specialist_id">>, Val}) -> {specialist_id, Val}; convert_field({<<"location">>, Val}) when is_map(Val) -> Loc = #location{ address = maps:get(<<"address">>, Val, undefined), - lat = maps:get(<<"lat">>, Val, undefined), - lon = maps:get(<<"lon">>, Val, undefined) + lat = maps:get(<<"lat">>, Val, undefined), + lon = maps:get(<<"lon">>, Val, undefined) }, {location, Loc}; -convert_field({<<"location">>, Val}) -> {location, Val}; -convert_field({<<"tags">>, Val}) -> {tags, Val}; -convert_field({<<"capacity">>, Val}) -> {capacity, Val}; -convert_field({<<"online_link">>, Val}) -> {online_link, Val}; -convert_field({<<"status">>, Val}) -> +convert_field({<<"location">>, Val}) -> {location, Val}; +convert_field({<<"tags">>, Val}) -> {tags, Val}; +convert_field({<<"capacity">>, Val}) -> {capacity, Val}; +convert_field({<<"online_link">>, Val}) -> {online_link, Val}; +convert_field({<<"status">>, Val}) -> try binary_to_existing_atom(Val, utf8) of Atom -> {status, Atom} catch error:badarg -> {status, Val} end; -convert_field(Other) -> Other. - -%%-------------------------------------------------------------------- -%% JSON / datetime helpers -%%-------------------------------------------------------------------- - -event_to_json(Event) -> - LocationJson = case Event#event.location of - undefined -> null; - #location{address = Addr, lat = Lat, lon = Lon} -> - #{address => Addr, lat => Lat, lon => Lon} - end, - RecurrenceJson = case Event#event.recurrence_rule of - undefined -> null; - Rule -> - try jsx:decode(Rule, [return_maps]) of - Map when is_map(Map) -> Map; - _ -> null - catch _:_ -> null - end - end, - #{ - id => Event#event.id, - calendar_id => Event#event.calendar_id, - title => Event#event.title, - description => Event#event.description, - event_type => Event#event.event_type, - start_time => datetime_to_iso8601(Event#event.start_time), - duration => Event#event.duration, - recurrence => RecurrenceJson, - master_id => Event#event.master_id, - is_instance => Event#event.is_instance, - specialist_id => Event#event.specialist_id, - location => LocationJson, - tags => Event#event.tags, - capacity => Event#event.capacity, - online_link => Event#event.online_link, - status => Event#event.status, - rating_avg => Event#event.rating_avg, - rating_count => Event#event.rating_count, - created_at => datetime_to_iso8601(Event#event.created_at), - updated_at => datetime_to_iso8601(Event#event.updated_at) - }. - -datetime_to_iso8601({{Year, Month, Day}, {Hour, Minute, Second}}) -> - iolist_to_binary( - io_lib:format( - "~4..0B-~2..0B-~2..0BT~2..0B:~2..0B:~2..0BZ", - [Year, Month, Day, Hour, Minute, Second] - ) - ); -datetime_to_iso8601(undefined) -> - undefined. - -parse_datetime(Str) -> - try - [DateStr, TimeStr] = string:split(Str, "T"), - TimeStrNoZ = string:trim(TimeStr, trailing, "Z"), - [YearStr, MonthStr, DayStr] = string:split(DateStr, "-", all), - [HourStr, MinuteStr, SecondStr] = string:split(TimeStrNoZ, ":", all), - Year = binary_to_integer(list_to_binary(YearStr)), - Month = binary_to_integer(list_to_binary(MonthStr)), - Day = binary_to_integer(list_to_binary(DayStr)), - Hour = binary_to_integer(list_to_binary(HourStr)), - Minute = binary_to_integer(list_to_binary(MinuteStr)), - Second = binary_to_integer(list_to_binary(SecondStr)), - {ok, {{Year, Month, Day}, {Hour, Minute, Second}}} - catch _:_ -> {error, invalid_format} - end. - -%%-------------------------------------------------------------------- -%% Response helpers -%%-------------------------------------------------------------------- - -send_json(Req, Status, Data) -> - Body = jsx:encode(Data), - Headers = #{<<"content-type">> => <<"application/json">>}, - cowboy_req:reply(Status, Headers, Body, Req), - {ok, Body, []}. - -send_error(Req, Status, Message) -> - Body = jsx:encode(#{error => Message}), - cowboy_req:reply(Status, #{<<"content-type">> => <<"application/json">>}, Body, Req), - {ok, Body, []}. \ No newline at end of file +convert_field(Other) -> Other. \ No newline at end of file diff --git a/src/handlers/admin/admin_handler_events.erl b/src/handlers/admin/admin_handler_events.erl index 13b3207..d992efe 100644 --- a/src/handlers/admin/admin_handler_events.erl +++ b/src/handlers/admin/admin_handler_events.erl @@ -7,50 +7,45 @@ -include("records.hrl"). %%%=================================================================== -%%% cowboy_handler callbacks +%%% cowboy_handler callback %%%=================================================================== init(Req, _Opts) -> case cowboy_req:method(Req) of <<"GET">> -> list_all_events(Req); - _ -> send_error(Req, 405, <<"Method not allowed">>) + _ -> handler_utils:send_error(Req, 405, <<"Method not allowed">>) end. %%%=================================================================== -%%% Swagger / Trails metadata +%%% Swagger metadata %%%=================================================================== trails() -> [ #{ - path => <<"/v1/admin/events">>, - method => <<"GET">>, - handler => ?MODULE, - tags => [<<"Events">>], + path => <<"/v1/admin/events">>, + method => <<"GET">>, description => <<"Search and list events (admin)">>, - parameters => [ - #{name => <<"from">>, in => <<"query">>, description => <<"ISO8601 start datetime">>, required => false, schema => #{type => string}}, - #{name => <<"to">>, in => <<"query">>, description => <<"ISO8601 end datetime">>, required => false, schema => #{type => string}}, - #{name => <<"status">>, in => <<"query">>, description => <<"active, cancelled, completed, or all">>, required => false, schema => #{type => string}}, - #{name => <<"calendar_id">>, in => <<"query">>, description => <<"Filter by calendar ID">>, required => false, schema => #{type => string}}, - #{name => <<"title">>, in => <<"query">>, description => <<"Exact title match">>, required => false, schema => #{type => string}}, - #{name => <<"q">>, in => <<"query">>, description => <<"Substring search in title/description">>, required => false, schema => #{type => string}}, - #{name => <<"limit">>, in => <<"query">>, description => <<"Page size (max 200)">>, required => false, schema => #{type => integer}}, - #{name => <<"offset">>, in => <<"query">>, description => <<"Offset">>, required => false, schema => #{type => integer}}, - #{name => <<"sort">>, in => <<"query">>, description => <<"created_at, start_time, title, status">>, required => false, schema => #{type => string}}, - #{name => <<"order">>, in => <<"query">>, description => <<"asc or desc">>, required => false, schema => #{type => string, enum => [<<"asc">>, <<"desc">>]}} + tags => [<<"Events">>], + parameters => [ + #{name => <<"from">>, in => <<"query">>, description => <<"ISO8601 start datetime">>, required => false, schema => #{type => string}}, + #{name => <<"to">>, in => <<"query">>, description => <<"ISO8601 end datetime">>, required => false, schema => #{type => string}}, + #{name => <<"status">>, in => <<"query">>, description => <<"active, cancelled, completed, or all">>, required => false, schema => #{type => string}}, + #{name => <<"calendar_id">>, in => <<"query">>, description => <<"Filter by calendar ID">>, required => false, schema => #{type => string}}, + #{name => <<"title">>, in => <<"query">>, description => <<"Exact title match">>, required => false, schema => #{type => string}}, + #{name => <<"q">>, in => <<"query">>, description => <<"Substring search in title/description">>, required => false, schema => #{type => string}}, + #{name => <<"limit">>, in => <<"query">>, description => <<"Page size (max 200)">>, required => false, schema => #{type => integer}}, + #{name => <<"offset">>, in => <<"query">>, description => <<"Offset">>, required => false, schema => #{type => integer}}, + #{name => <<"sort">>, in => <<"query">>, description => <<"created_at, start_time, title, status">>, required => false, schema => #{type => string}}, + #{name => <<"order">>, in => <<"query">>, description => <<"asc or desc">>, required => false, schema => #{type => string, enum => [<<"asc">>, <<"desc">>]}} ], responses => #{ 200 => #{ description => <<"Array of events with Content-Range header">>, - content => #{ - <<"application/json">> => #{ - schema => #{ - type => array, - items => event_schema() - } - } - } + content => #{<<"application/json">> => #{schema => #{ + type => array, + items => event_schema() + }}} }, 405 => #{description => <<"Method not allowed">>} } @@ -59,28 +54,31 @@ trails() -> event_schema() -> #{ - type => object, + type => object, properties => #{ - id => #{type => string}, - calendar_id => #{type => string}, - title => #{type => string}, - description => #{type => string}, - event_type => #{type => string, enum => [<<"single">>, <<"recurring">>]}, - start_time => #{type => string, format => <<"date-time">>}, - duration => #{type => integer}, - recurrence => #{type => object, nullable => true}, - master_id => #{type => string, nullable => true}, - is_instance => #{type => boolean}, - specialist_id => #{type => string, nullable => true}, - location => #{type => object, nullable => true}, - tags => #{type => array, items => #{type => string}}, - capacity => #{type => integer, nullable => true}, - online_link => #{type => string, nullable => true}, - status => #{type => string, enum => [<<"active">>, <<"cancelled">>, <<"completed">>]}, - rating_avg => #{type => number, format => float}, - rating_count => #{type => integer}, - created_at => #{type => string, format => <<"date-time">>}, - updated_at => #{type => string, format => <<"date-time">>} + id => #{type => string}, + calendar_id => #{type => string}, + title => #{type => string}, + description => #{type => string}, + event_type => #{type => string, enum => [<<"single">>, <<"recurring">>]}, + start_time => #{type => string, format => <<"date-time">>}, + duration => #{type => integer}, + recurrence => #{type => object, nullable => true}, + master_id => #{type => string, nullable => true}, + is_instance => #{type => boolean}, + specialist_id => #{type => string, nullable => true}, + location => #{type => object, nullable => true}, + tags => #{type => array, items => #{type => string}}, + capacity => #{type => integer, nullable => true}, + online_link => #{type => string, nullable => true}, + status => #{type => string, enum => [<<"active">>, <<"cancelled">>, <<"completed">>]}, + reason => #{type => string, nullable => true}, + rating_avg => #{type => number, format => float}, + rating_count => #{type => integer}, + attachments => #{type => array, items => #{type => string}, nullable => true}, + edit_history => #{type => array, items => #{type => object}, nullable => true}, + created_at => #{type => string, format => <<"date-time">>}, + updated_at => #{type => string, format => <<"date-time">>} } }. @@ -89,131 +87,36 @@ event_schema() -> %%%=================================================================== list_all_events(Req) -> - case auth_admin(Req) of + case handler_utils:auth_admin(Req) of {ok, _AdminId, Req1} -> Params = parse_admin_event_search(Req1), {ok, Total, Events} = logic_event:search_events(Params), - Json = [event_to_json(E) || E <- Events], - Limit = maps:get(limit, Params, 50), + Json = [handler_utils:event_to_json(E) || E <- Events], + Limit = maps:get(limit, Params, 50), Offset = maps:get(offset, Params, 0), RangeEnd = min(Offset + Limit - 1, Total - 1), Headers = #{ - <<"content-type">> => <<"application/json">>, - <<"content-range">> => iolist_to_binary( - io_lib:format("items ~B-~B/~B", [Offset, RangeEnd, Total])), - <<"x-total-count">> => integer_to_binary(Total), + <<"content-type">> => <<"application/json">>, + <<"content-range">> => iolist_to_binary(io_lib:format("items ~B-~B/~B", [Offset, RangeEnd, Total])), + <<"x-total-count">> => integer_to_binary(Total), <<"access-control-expose-headers">> => <<"Content-Range, X-Total-Count">> }, - send_json(Req1, 200, Json, Headers); + handler_utils:send_json(Req1, 200, Json, Headers); {error, Code, Msg, Req1} -> - send_error(Req1, Code, Msg) + handler_utils:send_error(Req1, Code, Msg) end. parse_admin_event_search(Req) -> Qs = cowboy_req:parse_qs(Req), #{ - from => parse_datetime_qs(proplists:get_value(<<"from">>, Qs)), - to => parse_datetime_qs(proplists:get_value(<<"to">>, Qs)), - status => proplists:get_value(<<"status">>, Qs, undefined), + from => handler_utils:parse_datetime_qs(proplists:get_value(<<"from">>, Qs)), + to => handler_utils:parse_datetime_qs(proplists:get_value(<<"to">>, Qs)), + status => proplists:get_value(<<"status">>, Qs, undefined), calendar_id => proplists:get_value(<<"calendar_id">>, Qs, undefined), - title => proplists:get_value(<<"title">>, Qs, undefined), - q => proplists:get_value(<<"q">>, Qs, undefined), - limit => parse_int_qs(proplists:get_value(<<"limit">>, Qs), 50), - offset => parse_int_qs(proplists:get_value(<<"offset">>, Qs), 0), - sort => proplists:get_value(<<"sort">>, Qs, <<"created_at">>), - order => proplists:get_value(<<"order">>, Qs, <<"desc">>) - }. - -auth_admin(Req) -> - case handler_auth:authenticate(Req) of - {ok, AdminId, Req1} -> - case admin_utils:is_admin(AdminId) of - true -> {ok, AdminId, Req1}; - false -> {error, 403, <<"Admin access required">>, Req1} - end; - {error, Code, Msg, Req1} -> - {error, Code, Msg, Req1} - end. - -parse_int_qs(undefined, Default) -> Default; -parse_int_qs(Bin, Default) -> - try binary_to_integer(Bin) catch _:_ -> Default end. - -parse_datetime_qs(undefined) -> undefined; -parse_datetime_qs(Bin) -> - case parse_datetime(Bin) of {ok, Dt} -> Dt; _ -> undefined end. - -parse_datetime(Str) -> - try - [DateStr, TimeStr] = string:split(Str, "T"), - TimeStrNoZ = string:trim(TimeStr, trailing, "Z"), - [YearStr, MonthStr, DayStr] = string:split(DateStr, "-", all), - [HourStr, MinuteStr, SecondStr] = string:split(TimeStrNoZ, ":", all), - Year = binary_to_integer(list_to_binary(YearStr)), - Month = binary_to_integer(list_to_binary(MonthStr)), - Day = binary_to_integer(list_to_binary(DayStr)), - Hour = binary_to_integer(list_to_binary(HourStr)), - Minute = binary_to_integer(list_to_binary(MinuteStr)), - Second = binary_to_integer(list_to_binary(SecondStr)), - {ok, {{Year, Month, Day}, {Hour, Minute, Second}}} - catch _:_ -> {error, invalid_format} - end. - -event_to_json(Event) -> - LocationJson = case Event#event.location of - undefined -> null; - #location{address = Addr, lat = Lat, lon = Lon} -> - #{address => Addr, lat => Lat, lon => Lon} - end, - RecurrenceJson = case Event#event.recurrence_rule of - undefined -> null; - Rule -> - try jsx:decode(Rule, [return_maps]) of - Map when is_map(Map) -> Map; - _ -> null - catch _:_ -> null - end - end, - #{ - id => Event#event.id, - calendar_id => Event#event.calendar_id, - title => Event#event.title, - description => Event#event.description, - event_type => Event#event.event_type, - start_time => datetime_to_iso8601(Event#event.start_time), - duration => Event#event.duration, - recurrence => RecurrenceJson, - master_id => Event#event.master_id, - is_instance => Event#event.is_instance, - specialist_id => Event#event.specialist_id, - location => LocationJson, - tags => Event#event.tags, - capacity => Event#event.capacity, - online_link => Event#event.online_link, - status => Event#event.status, - rating_avg => Event#event.rating_avg, - rating_count => Event#event.rating_count, - created_at => datetime_to_iso8601(Event#event.created_at), - updated_at => datetime_to_iso8601(Event#event.updated_at) - }. - -datetime_to_iso8601({{Year, Month, Day}, {Hour, Minute, Second}}) -> - iolist_to_binary( - io_lib:format( - "~4..0B-~2..0B-~2..0BT~2..0B:~2..0B:~2..0BZ", - [Year, Month, Day, Hour, Minute, Second] - ) - ); -datetime_to_iso8601(undefined) -> - undefined. - -send_json(Req, Status, Data, ExtraHeaders) -> - Body = jsx:encode(Data), - Headers = maps:merge(#{<<"content-type">> => <<"application/json">>}, ExtraHeaders), - cowboy_req:reply(Status, Headers, Body, Req), - {ok, Body, []}. - -send_error(Req, Status, Message) -> - Body = jsx:encode(#{error => Message}), - cowboy_req:reply(Status, #{<<"content-type">> => <<"application/json">>}, Body, Req), - {ok, Body, []}. \ No newline at end of file + title => proplists:get_value(<<"title">>, Qs, undefined), + q => proplists:get_value(<<"q">>, Qs, undefined), + limit => handler_utils:parse_int_qs(proplists:get_value(<<"limit">>, Qs), 50), + offset => handler_utils:parse_int_qs(proplists:get_value(<<"offset">>, Qs), 0), + sort => proplists:get_value(<<"sort">>, Qs, <<"created_at">>), + order => proplists:get_value(<<"order">>, Qs, <<"desc">>) + }. \ No newline at end of file diff --git a/src/handlers/admin/admin_handler_health.erl b/src/handlers/admin/admin_handler_health.erl index 8dc562f..a56e84b 100644 --- a/src/handlers/admin/admin_handler_health.erl +++ b/src/handlers/admin/admin_handler_health.erl @@ -1,18 +1,36 @@ -module(admin_handler_health). -behaviour(cowboy_handler). --export([init/2]). -init(Req, State) -> +-export([init/2]). +-export([trails/0]). + +%%% cowboy_handler callback +init(Req, _State) -> case cowboy_req:method(Req) of <<"GET">> -> - Body = jsx:encode(#{status => <<"ok">>}), - Req2 = cowboy_req:reply(200, #{<<"content-type">> => <<"application/json">>}, Body, Req), - {ok, Req2, State}; + handler_utils:send_json(Req, 200, #{status => <<"ok">>}); _ -> - send_error(Req, 405, <<"Method not allowed">>) + handler_utils:send_error(Req, 405, <<"Method not allowed">>) end. -send_error(Req, Status, Message) -> - Body = jsx:encode(#{error => Message}), - Req2 = cowboy_req:reply(Status, #{<<"content-type">> => <<"application/json">>}, Body, Req), - {ok, Req2, []}. \ No newline at end of file +%%% Swagger metadata +trails() -> + [ + #{ + path => <<"/v1/admin/health">>, + method => <<"GET">>, + description => <<"Admin API health check">>, + tags => [<<"Health">>], + responses => #{ + 200 => #{ + description => <<"API is healthy">>, + content => #{<<"application/json">> => #{schema => #{ + type => object, + properties => #{ + status => #{type => string} + } + }}} + } + } + } + ]. \ No newline at end of file diff --git a/src/handlers/admin/admin_handler_login.erl b/src/handlers/admin/admin_handler_login.erl index 2fe67ca..ac7ee23 100644 --- a/src/handlers/admin/admin_handler_login.erl +++ b/src/handlers/admin/admin_handler_login.erl @@ -1,8 +1,17 @@ +%%%------------------------------------------------------------------- +%%% @doc Административный обработчик аутентификации. +%%% POST – выполняет вход администратора, возвращает токены и данные пользователя. +%%% @end +%%%------------------------------------------------------------------- -module(admin_handler_login). -behaviour(cowboy_handler). --export([init/2]). -init(Req0, State) -> +-export([init/2]). +-export([trails/0]). + +%%% cowboy_handler callback +-spec init(cowboy_req:req(), any()) -> {ok, cowboy_req:req(), any()}. +init(Req0, _State) -> case cowboy_req:method(Req0) of <<"POST">> -> case cowboy_req:has_body(Req0) of @@ -12,48 +21,67 @@ init(Req0, State) -> #{<<"email">> := Email, <<"password">> := Password} -> case eventhub_auth:authenticate_admin_request(Req1, Email, Password) of {ok, Token, User} -> - % Генерация refresh-токена для администратора - {RefreshToken, _ExpiresAt} = eventhub_auth:generate_refresh_token(maps:get(id, User)), - % Сохранение refresh-токена в admin_session - core_admin_session:create(maps:get(id, User), RefreshToken), - core_admin:update_last_login(maps:get(id, User)), - Resp = jsx:encode(#{ + UserId = maps:get(id, User), + {RefreshToken, _ExpiresAt} = eventhub_auth:generate_refresh_token(UserId), + core_admin_session:create(UserId, RefreshToken), + core_admin:update_last_login(UserId), + Resp = #{ <<"token">> => Token, <<"user">> => #{ - <<"id">> => maps:get(id, User), + <<"id">> => UserId, <<"email">> => maps:get(email, User), <<"role">> => maps:get(role, User) }, <<"refresh_token">> => RefreshToken - }), - Req2 = cowboy_req:reply(200, #{ - <<"content-type">> => <<"application/json">>, - <<"access-control-allow-origin">> => <<"*">> - }, Resp, Req1), - {ok, Req2, State}; + }, + handler_utils:send_json(Req1, 200, Resp); {error, insufficient_permissions} -> - error_response(403, <<"insufficient_permissions">>, Req1, State); + handler_utils:send_error(Req1, 403, <<"insufficient_permissions">>); {error, Reason} when is_atom(Reason) -> - error_response(401, atom_to_binary(Reason, utf8), Req1, State); + handler_utils:send_error(Req1, 401, atom_to_binary(Reason, utf8)); {error, Reason} -> - error_response(401, Reason, Req1, State) + handler_utils:send_error(Req1, 401, Reason) end; _ -> - error_response(400, <<"Missing email or password">>, Req1, State) + handler_utils:send_error(Req1, 400, <<"Missing email or password">>) catch - _:_ -> error_response(400, <<"Invalid JSON">>, Req1, State) + _:_ -> handler_utils:send_error(Req1, 400, <<"Invalid JSON">>) end; false -> - error_response(400, <<"Missing request body">>, Req0, State) + handler_utils:send_error(Req0, 400, <<"Missing request body">>) end; _ -> - error_response(405, <<"Method not allowed">>, Req0, State) + handler_utils:send_error(Req0, 405, <<"Method not allowed">>) end. -error_response(Code, Reason, Req, State) -> - Body = jsx:encode(#{<<"error">> => Reason}), - Req2 = cowboy_req:reply(Code, #{ - <<"content-type">> => <<"application/json">>, - <<"access-control-allow-origin">> => <<"*">> - }, Body, Req), - {ok, Req2, State}. \ No newline at end of file +%%% Swagger metadata +-spec trails() -> [map()]. +trails() -> + [ + #{ + path => <<"/v1/admin/login">>, + method => <<"POST">>, + description => <<"Admin login">>, + tags => [<<"Auth">>], + requestBody => #{ + required => true, + content => #{ + <<"application/json">> => #{ + schema => #{ + type => object, + required => [<<"email">>, <<"password">>], + properties => #{ + email => #{type => string, format => <<"email">>}, + password => #{type => string, format => <<"password">>} + } + } + } + } + }, + responses => #{ + 200 => #{description => <<"Login successful, returns token and user info">>}, + 401 => #{description => <<"Invalid credentials">>}, + 403 => #{description => <<"Insufficient permissions">>} + } + } + ]. \ No newline at end of file diff --git a/src/handlers/admin/admin_handler_me.erl b/src/handlers/admin/admin_handler_me.erl index d4f91b2..326fe37 100644 --- a/src/handlers/admin/admin_handler_me.erl +++ b/src/handlers/admin/admin_handler_me.erl @@ -1,37 +1,137 @@ -module(admin_handler_me). -behaviour(cowboy_handler). --include("records.hrl"). - -export([init/2]). +-export([trails/0]). + +-include("records.hrl"). init(Req, _Opts) -> case cowboy_req:method(Req) of - <<"GET">> -> - case handler_auth:authenticate(Req) of - {ok, AdminId, Req1} -> - case core_admin:get_by_id(AdminId) of - {ok, Admin} -> - Permissions = admin_utils:get_permissions(Admin#admin.role), - Resp = jsx:encode(#{ - id => Admin#admin.id, - email => Admin#admin.email, - role => Admin#admin.role, - permissions => Permissions - }), - Req2 = cowboy_req:reply(200, #{<<"content-type">> => <<"application/json">>}, Resp, Req1), - {ok, Req2, []}; - {error, not_found} -> - send_error(Req1, 404, <<"Admin not found">>) - end; - {error, Code, Message, Req1} -> - send_error(Req1, Code, Message) - end; - _ -> - send_error(Req, 405, <<"Method not allowed">>) + <<"GET">> -> get_me(Req); + <<"PUT">> -> update_me(Req); + _ -> handler_utils:send_error(Req, 405, <<"Method not allowed">>) end. -send_error(Req, Code, Message) -> - Body = jsx:encode(#{error => Message}), - Req2 = cowboy_req:reply(Code, #{<<"content-type">> => <<"application/json">>}, Body, Req), - {ok, Req2, []}. \ No newline at end of file +trails() -> + [ + #{ % GET + path => <<"/v1/admin/me">>, + method => <<"GET">>, + description => <<"Get current admin profile">>, + tags => [<<"Admins">>], + responses => #{ + 200 => #{ + description => <<"Admin profile">>, + content => #{<<"application/json">> => #{schema => admin_schema()}} + } + } + }, + #{ % PUT + path => <<"/v1/admin/me">>, + method => <<"PUT">>, + description => <<"Update current admin profile">>, + tags => [<<"Admins">>], + requestBody => #{ + required => true, + content => #{<<"application/json">> => #{schema => admin_update_schema()}} + }, + responses => #{ + 200 => #{description => <<"Updated profile">>} + } + } + ]. + +admin_schema() -> + #{ + type => object, + properties => #{ + id => #{type => string}, + email => #{type => string}, + role => #{type => string}, + status => #{type => string}, + nickname => #{type => string, nullable => true}, + avatar_url => #{type => string, nullable => true}, + timezone => #{type => string, nullable => true}, + language => #{type => string, nullable => true}, + phone => #{type => string, nullable => true}, + preferences => #{type => object, nullable => true}, + last_login => #{type => string, format => <<"date-time">>}, + created_at => #{type => string, format => <<"date-time">>}, + updated_at => #{type => string, format => <<"date-time">>} + } + }. + +admin_update_schema() -> + #{ + type => object, + properties => #{ + nickname => #{type => string}, + avatar_url => #{type => string}, + timezone => #{type => string}, + language => #{type => string}, + phone => #{type => string}, + preferences => #{type => object} + } + }. + +get_me(Req) -> + case handler_utils:auth_admin(Req) of + {ok, AdminId, Req1} -> + case logic_admin:get_admin(AdminId) of + {ok, Admin} -> + handler_utils:send_json(Req1, 200, admin_to_json(Admin)); + {error, not_found} -> + handler_utils:send_error(Req1, 404, <<"Admin not found">>); + {error, _} -> + handler_utils:send_error(Req1, 500, <<"Internal server error">>) + end; + {error, Code, Msg, Req1} -> + handler_utils:send_error(Req1, Code, Msg) + end. + +update_me(Req) -> + case handler_utils:auth_admin(Req) of + {ok, AdminId, Req1} -> + {ok, Body, Req2} = cowboy_req:read_body(Req1), + try jsx:decode(Body, [return_maps]) of + UpdatesMap when is_map(UpdatesMap) -> + Updates = maps:to_list(UpdatesMap), + case logic_admin:update_admin(AdminId, Updates) of + {ok, Admin} -> + handler_utils:send_json(Req2, 200, admin_to_json(Admin)); + {error, not_found} -> + handler_utils:send_error(Req2, 404, <<"Admin not found">>); + {error, _} -> + handler_utils:send_error(Req2, 500, <<"Internal server error">>) + end; + _ -> + handler_utils:send_error(Req2, 400, <<"Invalid JSON">>) + catch + _:_ -> handler_utils:send_error(Req1, 400, <<"Invalid JSON format">>) + end; + {error, Code, Msg, Req1} -> + handler_utils:send_error(Req1, Code, Msg) + end. + +admin_to_json(Admin) -> + #{ + id => Admin#admin.id, + email => Admin#admin.email, + role => Admin#admin.role, + status => Admin#admin.status, + nickname => Admin#admin.nickname, + avatar_url => Admin#admin.avatar_url, + timezone => Admin#admin.timezone, + language => Admin#admin.language, + phone => Admin#admin.phone, + preferences => Admin#admin.preferences, + last_login => datetime_to_iso8601(Admin#admin.last_login), + created_at => datetime_to_iso8601(Admin#admin.created_at), + updated_at => datetime_to_iso8601(Admin#admin.updated_at) + }. + +datetime_to_iso8601(undefined) -> undefined; +datetime_to_iso8601({{Year, Month, Day}, {Hour, Minute, Second}}) -> + iolist_to_binary(io_lib:format("~4..0B-~2..0B-~2..0BT~2..0B:~2..0B:~2..0BZ", + [Year, Month, Day, Hour, Minute, Second])). \ No newline at end of file diff --git a/src/handlers/admin/admin_handler_moderation.erl b/src/handlers/admin/admin_handler_moderation.erl index 2a3a2c2..7eb3f39 100644 --- a/src/handlers/admin/admin_handler_moderation.erl +++ b/src/handlers/admin/admin_handler_moderation.erl @@ -1,19 +1,85 @@ +%%%------------------------------------------------------------------- +%%% @doc Административный обработчик модерации. +%%% PUT – применяет действие модерации к указанной сущности. +%%% @end +%%%------------------------------------------------------------------- -module(admin_handler_moderation). -behaviour(cowboy_handler). + -export([init/2]). +-export([trails/0]). -include("records.hrl"). -define(VALID_TARGETS, [<<"calendar">>, <<"event">>, <<"review">>, <<"user">>]). +%%% cowboy_handler callback +-spec init(cowboy_req:req(), any()) -> {ok, cowboy_req:req(), any()}. init(Req, _Opts) -> case cowboy_req:method(Req) of <<"PUT">> -> moderate(Req); - _ -> send_error(Req, 405, <<"Method not allowed">>) + _ -> handler_utils:send_error(Req, 405, <<"Method not allowed">>) end. +%%% Swagger metadata +-spec trails() -> [map()]. +trails() -> + Targets = [<<"calendar">>, <<"event">>, <<"review">>, <<"user">>], + Actions = #{ + <<"calendar">> => [<<"freeze">>, <<"unfreeze">>], + <<"event">> => [<<"freeze">>, <<"unfreeze">>], + <<"review">> => [<<"hide">>, <<"unhide">>], + <<"user">> => [<<"block">>, <<"unblock">>] + }, + lists:flatmap(fun(Target) -> + ActionList = maps:get(Target, Actions), + lists:map(fun(Action) -> + Path = <<"/v1/admin/", Target/binary, "/:id">>, + #{ + path => Path, + method => <<"PUT">>, + description => <<"Moderate ", Target/binary, " - ", Action/binary>>, + tags => [<<"Moderation">>], + parameters => [ + #{ + name => <<"target_type">>, + in => <<"path">>, + description => <<"Entity type">>, + required => true, + schema => #{type => string, enum => Targets} + }, + #{ + name => <<"id">>, + in => <<"path">>, + description => <<"Entity ID">>, + required => true, + schema => #{type => string} + } + ], + requestBody => #{ + required => true, + content => #{<<"application/json">> => #{schema => #{ + type => object, + required => [<<"action">>], + properties => #{ + action => #{type => string, enum => ActionList}, + reason => #{type => string} + } + }}} + }, + responses => #{ + 200 => #{description => <<"Moderation applied successfully">>}, + 400 => #{description => <<"Bad request">>}, + 404 => #{description => <<"Entity not found">>} + } + } + end, ActionList) + end, Targets). + +%%% Internal functions + moderate(Req) -> - case authenticate_and_check_admin(Req) of + case handler_utils:auth_admin(Req) of {ok, AdminId, Req1} -> TargetType = cowboy_req:binding(target_type, Req1), TargetId = cowboy_req:binding(id, Req1), @@ -25,15 +91,15 @@ moderate(Req) -> Reason = maps:get(<<"reason">>, BodyMap, <<"">>), apply_moderation(TargetType, TargetId, Action, Reason, Req2, AdminId); _ -> - send_error(Req2, 400, <<"Missing 'action' field">>) + handler_utils:send_error(Req2, 400, <<"Missing 'action' field">>) catch - _:_ -> send_error(Req2, 400, <<"Invalid JSON">>) + _:_ -> handler_utils:send_error(Req2, 400, <<"Invalid JSON">>) end; false -> - send_error(Req1, 400, <<"Invalid target_type">>) + handler_utils:send_error(Req1, 400, <<"Invalid target_type">>) end; {error, Code, Message, Req1} -> - send_error(Req1, Code, Message) + handler_utils:send_error(Req1, Code, Message) end. apply_moderation(<<"calendar">>, Id, Action, Reason, Req, AdminId) -> @@ -49,131 +115,86 @@ handle_calendar(Id, <<"freeze">>, Reason, Req, AdminId) -> case core_calendar:freeze(Id, Reason) of {ok, Calendar} -> log_audit(AdminId, <<"freeze_calendar">>, <<"calendar">>, Id, Reason), - send_json(Req, 200, calendar_to_json(Calendar)); - {error, not_found} -> send_error(Req, 404, <<"Calendar not found">>) + handler_utils:send_json(Req, 200, handler_utils:calendar_to_json(Calendar)); + {error, not_found} -> + handler_utils:send_error(Req, 404, <<"Calendar not found">>) end; handle_calendar(Id, <<"unfreeze">>, Reason, Req, AdminId) -> case core_calendar:unfreeze(Id, Reason) of {ok, Calendar} -> log_audit(AdminId, <<"unfreeze_calendar">>, <<"calendar">>, Id, Reason), - send_json(Req, 200, calendar_to_json(Calendar)); - {error, not_found} -> send_error(Req, 404, <<"Calendar not found">>) + handler_utils:send_json(Req, 200, handler_utils:calendar_to_json(Calendar)); + {error, not_found} -> + handler_utils:send_error(Req, 404, <<"Calendar not found">>) end; handle_calendar(_Id, _Action, _Reason, Req, _AdminId) -> - send_error(Req, 400, <<"Invalid action for calendar">>). + handler_utils:send_error(Req, 400, <<"Invalid action for calendar">>). handle_event(Id, <<"freeze">>, Reason, Req, AdminId) -> case core_event:freeze(Id, Reason) of {ok, Event} -> log_audit(AdminId, <<"freeze_event">>, <<"event">>, Id, Reason), - send_json(Req, 200, event_to_json(Event)); - {error, not_found} -> send_error(Req, 404, <<"Event not found">>) + handler_utils:send_json(Req, 200, handler_utils:event_to_json(Event)); + {error, not_found} -> + handler_utils:send_error(Req, 404, <<"Event not found">>) end; handle_event(Id, <<"unfreeze">>, Reason, Req, AdminId) -> case core_event:unfreeze(Id, Reason) of {ok, Event} -> log_audit(AdminId, <<"unfreeze_event">>, <<"event">>, Id, Reason), - send_json(Req, 200, event_to_json(Event)); - {error, not_found} -> send_error(Req, 404, <<"Event not found">>) + handler_utils:send_json(Req, 200, handler_utils:event_to_json(Event)); + {error, not_found} -> + handler_utils:send_error(Req, 404, <<"Event not found">>) end; handle_event(_Id, _Action, _Reason, Req, _AdminId) -> - send_error(Req, 400, <<"Invalid action for event">>). + handler_utils:send_error(Req, 400, <<"Invalid action for event">>). handle_review(Id, <<"hide">>, Reason, Req, AdminId) -> case core_review:hide(Id, Reason) of {ok, Review} -> log_audit(AdminId, <<"hide_review">>, <<"review">>, Id, Reason), - send_json(Req, 200, review_to_json(Review)); - {error, not_found} -> send_error(Req, 404, <<"Review not found">>) + handler_utils:send_json(Req, 200, handler_utils:review_to_json(Review)); + {error, not_found} -> + handler_utils:send_error(Req, 404, <<"Review not found">>) end; handle_review(Id, <<"unhide">>, Reason, Req, AdminId) -> case core_review:unhide(Id, Reason) of {ok, Review} -> log_audit(AdminId, <<"unhide_review">>, <<"review">>, Id, Reason), - send_json(Req, 200, review_to_json(Review)); - {error, not_found} -> send_error(Req, 404, <<"Review not found">>) + handler_utils:send_json(Req, 200, handler_utils:review_to_json(Review)); + {error, not_found} -> + handler_utils:send_error(Req, 404, <<"Review not found">>) end; handle_review(_Id, _Action, _Reason, Req, _AdminId) -> - send_error(Req, 400, <<"Invalid action for review">>). + handler_utils:send_error(Req, 400, <<"Invalid action for review">>). handle_user(Id, <<"block">>, Reason, Req, AdminId) -> case core_user:block(Id, Reason) of {ok, User} -> log_audit(AdminId, <<"block_user">>, <<"user">>, Id, Reason), - send_json(Req, 200, user_to_json(User)); - {error, not_found} -> send_error(Req, 404, <<"User not found">>) + handler_utils:send_json(Req, 200, handler_utils:user_to_json(User)); + {error, not_found} -> + handler_utils:send_error(Req, 404, <<"User not found">>) end; handle_user(Id, <<"unblock">>, Reason, Req, AdminId) -> case core_user:unblock(Id, Reason) of {ok, User} -> log_audit(AdminId, <<"unblock_user">>, <<"user">>, Id, Reason), - send_json(Req, 200, user_to_json(User)); - {error, not_found} -> send_error(Req, 404, <<"User not found">>) + handler_utils:send_json(Req, 200, handler_utils:user_to_json(User)); + {error, not_found} -> + handler_utils:send_error(Req, 404, <<"User not found">>) end; handle_user(_Id, _Action, _Reason, Req, _AdminId) -> - send_error(Req, 400, <<"Invalid action for user">>). + handler_utils:send_error(Req, 400, <<"Invalid action for user">>). %% ── АУДИТ ────────────────────────────────────────────────── log_audit(AdminId, Action, EntityType, EntityId, Reason) -> case core_admin:get_by_id(AdminId) of {ok, Admin} -> core_admin_audit:log(AdminId, Admin#admin.email, Admin#admin.role, - Action, EntityType, EntityId, - client_ip(), Reason); + Action, EntityType, EntityId, client_ip(), Reason); _ -> ok end. %% ── ВСПОМОГАТЕЛЬНЫЕ ФУНКЦИИ ──────────────────────────────── -authenticate_and_check_admin(Req) -> - case handler_auth:authenticate(Req) of - {ok, AdminId, Req1} -> - case admin_utils:is_admin(AdminId) of - true -> {ok, AdminId, Req1}; - false -> {error, 403, <<"Admin access required">>, Req1} - end; - {error, Code, Message, Req1} -> - {error, Code, Message, Req1} - end. - -client_ip() -> <<"127.0.0.1">>. - -calendar_to_json(C) -> - #{ - id => C#calendar.id, - title => C#calendar.title, - status => atom_to_binary(C#calendar.status, utf8), - reason => C#calendar.reason - }. - -event_to_json(E) -> - #{ - id => E#event.id, - title => E#event.title, - status => atom_to_binary(E#event.status, utf8), - reason => E#event.reason - }. - -review_to_json(R) -> - #{ - id => R#review.id, - status => atom_to_binary(R#review.status, utf8), - reason => R#review.reason - }. - -user_to_json(U) -> - #{ - id => U#user.id, - email => U#user.email, - status => atom_to_binary(U#user.status, utf8), - reason => U#user.reason - }. - -send_json(Req, Status, Data) -> - Body = jsx:encode(Data), - cowboy_req:reply(Status, #{<<"content-type">> => <<"application/json">>}, Body, Req), - {ok, Body, []}. - -send_error(Req, Status, Message) -> - Body = jsx:encode(#{error => Message}), - cowboy_req:reply(Status, #{<<"content-type">> => <<"application/json">>}, Body, Req), - {ok, Body, []}. \ No newline at end of file +client_ip() -> <<"127.0.0.1">>. \ No newline at end of file diff --git a/src/handlers/admin/admin_handler_report_by_id.erl b/src/handlers/admin/admin_handler_report_by_id.erl index a50870f..872b950 100644 --- a/src/handlers/admin/admin_handler_report_by_id.erl +++ b/src/handlers/admin/admin_handler_report_by_id.erl @@ -1,109 +1,130 @@ +%%%------------------------------------------------------------------- +%%% @doc Административный обработчик конкретной жалобы. +%%% GET – получить жалобу по ID. +%%% PUT – обновить статус жалобы. +%%% @end +%%%------------------------------------------------------------------- -module(admin_handler_report_by_id). -behaviour(cowboy_handler). + -export([init/2]). +-export([trails/0]). -include("records.hrl"). +-spec init(cowboy_req:req(), any()) -> {ok, cowboy_req:req(), any()}. init(Req, _Opts) -> case cowboy_req:method(Req) of - <<"GET">> -> get_report(Req); - <<"PUT">> -> update_report(Req); - _ -> send_error(Req, 405, <<"Method not allowed">>) + <<"GET">> -> get_report(Req); + <<"PUT">> -> update_report(Req); + _ -> handler_utils:send_error(Req, 405, <<"Method not allowed">>) end. +-spec trails() -> [map()]. +trails() -> + BaseParams = [ + #{ + name => <<"id">>, + in => <<"path">>, + description => <<"Report ID">>, + required => true, + schema => #{type => string} + } + ], + [ + #{ % GET + path => <<"/v1/admin/reports/:id">>, + method => <<"GET">>, + description => <<"Get report by ID (admin)">>, + tags => [<<"Reports">>], + parameters => BaseParams, + responses => #{ + 200 => #{ + description => <<"Report details">>, + content => #{<<"application/json">> => #{schema => report_schema()}} + }, + 404 => #{description => <<"Report not found">>} + } + }, + #{ % PUT + path => <<"/v1/admin/reports/:id">>, + method => <<"PUT">>, + description => <<"Update report status (admin)">>, + tags => [<<"Reports">>], + parameters => BaseParams, + requestBody => #{ + required => true, + content => #{<<"application/json">> => #{schema => report_update_schema()}} + }, + responses => #{ + 200 => #{description => <<"Updated report">>}, + 404 => #{description => <<"Report not found">>} + } + } + ]. + +report_schema() -> + #{ + type => object, + properties => #{ + id => #{type => string}, + reporter_id => #{type => string}, + target_type => #{type => string, enum => [<<"calendar">>, <<"event">>, <<"review">>]}, + target_id => #{type => string}, + reason => #{type => string}, + status => #{type => string, enum => [<<"pending">>, <<"reviewed">>, <<"dismissed">>]}, + created_at => #{type => string, format => <<"date-time">>}, + resolved_at => #{type => string, format => <<"date-time">>, nullable => true}, + resolved_by => #{type => string, nullable => true} + } + }. + +report_update_schema() -> + #{ + type => object, + properties => #{ + status => #{type => string, enum => [<<"reviewed">>, <<"dismissed">>]} + } + }. + +%%% Internal functions + get_report(Req) -> - case auth_admin(Req) of + case handler_utils:auth_admin(Req) of {ok, AdminId, Req1} -> - case admin_utils:is_admin(AdminId) of - true -> - ReportId = cowboy_req:binding(id, Req1), - case core_report:get_by_id(ReportId) of - {ok, Report} -> - send_json(Req1, 200, report_to_json(Report)); - {error, not_found} -> - send_error(Req1, 404, <<"Report not found">>) - end; - false -> - send_error(Req1, 403, <<"Admin access required">>) + ReportId = cowboy_req:binding(id, Req1), + case logic_report:get_report(AdminId, ReportId) of + {ok, Report} -> + handler_utils:send_json(Req1, 200, handler_utils:report_to_json(Report)); + {error, not_found} -> + handler_utils:send_error(Req1, 404, <<"Report not found">>); + {error, _} -> + handler_utils:send_error(Req1, 500, <<"Internal server error">>) end; - {error, Code, Message, Req1} -> - send_error(Req1, Code, Message) + {error, Code, Msg, Req1} -> + handler_utils:send_error(Req1, Code, Msg) end. update_report(Req) -> - case auth_admin(Req) of + case handler_utils:auth_admin(Req) of {ok, AdminId, Req1} -> - case admin_utils:is_admin(AdminId) of - true -> - ReportId = cowboy_req:binding(id, Req1), - {ok, Body, Req2} = cowboy_req:read_body(Req1), - try jsx:decode(Body, [return_maps]) of - #{<<"status">> := NewStatus, <<"reason">> := Reason} -> - StatusAtom = binary_to_atom(NewStatus, utf8), - case core_report:update_status(ReportId, StatusAtom, AdminId) of - {ok, Report} -> - log_audit(AdminId, <<"update_report_status">>, <<"report">>, ReportId, Reason), - send_json(Req2, 200, report_to_json(Report)); - {error, not_found} -> - send_error(Req2, 404, <<"Report not found">>); - {error, _} -> - send_error(Req2, 500, <<"Internal server error">>) - end; - _ -> - send_error(Req2, 400, <<"Missing status or reason">>) - catch - _:_ -> send_error(Req2, 400, <<"Invalid JSON">>) + ReportId = cowboy_req:binding(id, Req1), + {ok, Body, Req2} = cowboy_req:read_body(Req1), + try jsx:decode(Body, [return_maps]) of + #{<<"status">> := Status} -> + case logic_report:update_report_status(AdminId, ReportId, Status) of + {ok, Report} -> + handler_utils:send_json(Req2, 200, handler_utils:report_to_json(Report)); + {error, not_found} -> + handler_utils:send_error(Req2, 404, <<"Report not found">>); + {error, _} -> + handler_utils:send_error(Req2, 500, <<"Internal server error">>) end; - false -> - send_error(Req1, 403, <<"Admin access required">>) + _ -> + handler_utils:send_error(Req2, 400, <<"Missing status field">>) + catch + _:_ -> handler_utils:send_error(Req1, 400, <<"Invalid JSON">>) end; - {error, Code, Message, Req1} -> - send_error(Req1, Code, Message) - end. - -auth_admin(Req) -> - case handler_auth:authenticate(Req) of - {ok, AdminId, Req1} -> - case admin_utils:is_admin(AdminId) of - true -> {ok, AdminId, Req1}; - false -> {error, 403, <<"Admin access required">>, Req1} - end; - {error, Code, Message, Req1} -> - {error, Code, Message, Req1} - end. - -log_audit(AdminId, Action, EntityType, EntityId, Reason) -> - case core_admin:get_by_id(AdminId) of - {ok, Admin} -> - core_admin_audit:log(AdminId, Admin#admin.email, Admin#admin.role, - Action, EntityType, EntityId, - <<"127.0.0.1">>, Reason); - _ -> ok - end. - -report_to_json(R) -> - #{ - id => R#report.id, - reporter_id => R#report.reporter_id, - target_type => R#report.target_type, - target_id => R#report.target_id, - reason => R#report.reason, - status => R#report.status, - created_at => datetime_to_iso8601(R#report.created_at), - resolved_at => datetime_to_iso8601(R#report.resolved_at) - }. - -datetime_to_iso8601({{Year, Month, Day}, {Hour, Minute, Second}}) -> - iolist_to_binary(io_lib:format("~4..0B-~2..0B-~2..0BT~2..0B:~2..0B:~2..0BZ", - [Year, Month, Day, Hour, Minute, Second])); -datetime_to_iso8601(undefined) -> undefined. - -send_json(Req, Status, Data) -> - Body = jsx:encode(Data), - cowboy_req:reply(Status, #{<<"content-type">> => <<"application/json">>}, Body, Req), - {ok, Body, []}. - -send_error(Req, Status, Message) -> - Body = jsx:encode(#{error => Message}), - cowboy_req:reply(Status, #{<<"content-type">> => <<"application/json">>}, Body, Req), - {ok, Body, []}. \ No newline at end of file + {error, Code, Msg, Req1} -> + handler_utils:send_error(Req1, Code, Msg) + end. \ No newline at end of file diff --git a/src/handlers/admin/admin_handler_reports.erl b/src/handlers/admin/admin_handler_reports.erl index e79d6ec..3dbb7e9 100644 --- a/src/handlers/admin/admin_handler_reports.erl +++ b/src/handlers/admin/admin_handler_reports.erl @@ -1,109 +1,135 @@ +%%%------------------------------------------------------------------- +%%% @doc Административный обработчик списка жалоб. +%%% GET – список с пагинацией, фильтрацией и сортировкой. +%%% @end +%%%------------------------------------------------------------------- -module(admin_handler_reports). -behaviour(cowboy_handler). + -export([init/2]). +-export([trails/0]). -include("records.hrl"). +-spec init(cowboy_req:req(), any()) -> {ok, cowboy_req:req(), any()}. init(Req, _Opts) -> case cowboy_req:method(Req) of <<"GET">> -> list_reports(Req); - <<"PUT">> -> update_report(Req); - _ -> send_error(Req, 405, <<"Method not allowed">>) + _ -> handler_utils:send_error(Req, 405, <<"Method not allowed">>) end. -list_reports(Req) -> - case auth_admin(Req) of - {ok, AdminId, Req1} -> - case admin_utils:is_admin(AdminId) of - true -> - {ok, Reports} = core_report:list_all(), - send_json(Req1, 200, [report_to_json(R) || R <- Reports]); - false -> - send_error(Req1, 403, <<"Admin access required">>) - end; - {error, Code, Message, Req1} -> - send_error(Req1, Code, Message) - end. +-spec trails() -> [map()]. +trails() -> + [ + #{ + path => <<"/v1/admin/reports">>, + method => <<"GET">>, + description => <<"List all reports (admin)">>, + tags => [<<"Reports">>], + parameters => [ + #{name => <<"status">>, in => <<"query">>, schema => #{type => string, enum => [<<"pending">>, <<"reviewed">>, <<"dismissed">>]}, description => <<"Filter by status">>}, + #{name => <<"target_type">>, in => <<"query">>, schema => #{type => string, enum => [<<"calendar">>, <<"event">>, <<"review">>]}, description => <<"Filter by target type">>}, + #{name => <<"q">>, in => <<"query">>, schema => #{type => string}, description => <<"Search in reason">>}, + #{name => <<"limit">>, in => <<"query">>, schema => #{type => integer}, description => <<"Page size">>}, + #{name => <<"offset">>, in => <<"query">>, schema => #{type => integer}, description => <<"Offset">>} + ], + responses => #{ + 200 => #{ + description => <<"Array of reports">>, + content => #{<<"application/json">> => #{schema => #{ + type => array, + items => report_schema() + }}} + } + } + } + ]. -update_report(Req) -> - case auth_admin(Req) of - {ok, AdminId, Req1} -> - case admin_utils:is_admin(AdminId) of - true -> - ReportId = cowboy_req:binding(id, Req1), - {ok, Body, Req2} = cowboy_req:read_body(Req1), - try jsx:decode(Body, [return_maps]) of - #{<<"status">> := NewStatus, <<"reason">> := Reason} -> - StatusAtom = binary_to_atom(NewStatus, utf8), - case core_report:update_status(ReportId, StatusAtom, AdminId) of - {ok, Report} -> - log_audit(AdminId, <<"update_report_status">>, <<"report">>, ReportId, Reason), - send_json(Req2, 200, report_to_json(Report)); - {error, not_found} -> - send_error(Req2, 404, <<"Report not found">>); - {error, _} -> - send_error(Req2, 500, <<"Internal server error">>) - end; - _ -> - send_error(Req2, 400, <<"Missing status or reason">>) - catch - _:_ -> send_error(Req2, 400, <<"Invalid JSON">>) - end; - false -> - send_error(Req1, 403, <<"Admin access required">>) - end; - {error, Code, Message, Req1} -> - send_error(Req1, Code, Message) - end. - -auth_admin(Req) -> - case handler_auth:authenticate(Req) of - {ok, AdminId, Req1} -> - case admin_utils:is_admin(AdminId) of - true -> {ok, AdminId, Req1}; - false -> {error, 403, <<"Admin access required">>, Req1} - end; - {error, Code, Message, Req1} -> - {error, Code, Message, Req1} - end. - -log_audit(AdminId, Action, EntityType, EntityId, Reason) -> - case core_admin:get_by_id(AdminId) of - {ok, Admin} -> - core_admin_audit:log(AdminId, Admin#admin.email, Admin#admin.role, - Action, EntityType, EntityId, - <<"127.0.0.1">>, Reason); - _ -> ok - end. - -report_to_json(R) -> +report_schema() -> #{ - id => R#report.id, - reporter_id => R#report.reporter_id, - target_type => R#report.target_type, - target_id => R#report.target_id, - reason => R#report.reason, - status => R#report.status, - created_at => datetime_to_iso8601(R#report.created_at), - resolved_at => datetime_to_iso8601(R#report.resolved_at) + type => object, + properties => #{ + id => #{type => string}, + reporter_id => #{type => string}, + target_type => #{type => string, enum => [<<"calendar">>, <<"event">>, <<"review">>]}, + target_id => #{type => string}, + reason => #{type => string}, + status => #{type => string, enum => [<<"pending">>, <<"reviewed">>, <<"dismissed">>]}, + created_at => #{type => string, format => <<"date-time">>}, + resolved_at => #{type => string, format => <<"date-time">>, nullable => true}, + resolved_by => #{type => string, nullable => true} + } }. -datetime_to_iso8601({{Year, Month, Day}, {Hour, Minute, Second}}) -> - iolist_to_binary(io_lib:format("~4..0B-~2..0B-~2..0BT~2..0B:~2..0B:~2..0BZ", - [Year, Month, Day, Hour, Minute, Second])); -datetime_to_iso8601(undefined) -> undefined. +%%% Internal functions -send_json(Req, Status, Data) -> - Headers = #{ - <<"content-type">> => <<"application/json">>, - <<"access-control-allow-origin">> => <<"*">>, - <<"access-control-expose-headers">> => <<"Content-Range">> - }, - Body = jsx:encode(Data), - cowboy_req:reply(Status, Headers, Body, Req), - {ok, Body, []}. +-spec list_reports(cowboy_req:req()) -> {ok, binary(), cowboy_req:req()}. +list_reports(Req) -> + case handler_utils:auth_admin(Req) of + {ok, AdminId, Req1} -> + Filters = parse_report_filters(Req1), + Pagination = handler_utils:parse_pagination_params(Req1), + case logic_report:list_reports(AdminId) of + {ok, AllReports} -> + Filtered = apply_report_filters(AllReports, Filters), + Sorted = sort_reports(Filtered, Pagination), + Total = length(Sorted), + Page = lists:sublist(Sorted, maps:get(offset, Pagination) + 1, maps:get(limit, Pagination)), + Json = [handler_utils:report_to_json(R) || R <- Page], + ExtraHeaders = pagination_headers(Pagination, Total), + handler_utils:send_json(Req1, 200, Json, ExtraHeaders); + {error, access_denied} -> + handler_utils:send_error(Req1, 403, <<"Admin access required">>) + end; + {error, Code, Msg, Req1} -> + handler_utils:send_error(Req1, Code, Msg) + end. -send_error(Req, Status, Message) -> - Body = jsx:encode(#{error => Message}), - cowboy_req:reply(Status, #{<<"content-type">> => <<"application/json">>}, Body, Req), - {ok, Body, []}. \ No newline at end of file +parse_report_filters(Req) -> + Qs = cowboy_req:parse_qs(Req), + #{ + status => proplists:get_value(<<"status">>, Qs), + target_type => proplists:get_value(<<"target_type">>, Qs), + q => proplists:get_value(<<"q">>, Qs) + }. + +apply_report_filters(Reports, Filters) -> + Status = maps:get(status, Filters, undefined), + TargetType = maps:get(target_type, Filters, undefined), + Q = maps:get(q, Filters, undefined), + F1 = case Status of + undefined -> Reports; + _ -> [R || R <- Reports, R#report.status =:= Status] + end, + F2 = case TargetType of + undefined -> F1; + _ -> [R || R <- F1, R#report.target_type =:= TargetType] + end, + case Q of + undefined -> F2; + _ -> [R || R <- F2, + string:str(binary_to_list(R#report.reason), binary_to_list(Q)) > 0] + end. + +sort_reports(Reports, #{sort := Sort, order := Order}) -> + Field = binary_to_existing_atom(Sort, utf8), + lists:sort( + fun(A, B) -> + ValA = report_field(A, Field), + ValB = report_field(B, Field), + if Order == <<"asc">> -> ValA =< ValB; + true -> ValA >= ValB + end + end, Reports). + +report_field(#report{created_at = V}, created_at) -> V; +report_field(#report{status = V}, status) -> V; +report_field(_, _) -> undefined. + +pagination_headers(#{limit := Limit, offset := Offset}, Total) -> + RangeEnd = min(Offset + Limit - 1, Total - 1), + #{ + <<"content-range">> => iolist_to_binary(io_lib:format("items ~B-~B/~B", [Offset, RangeEnd, Total])), + <<"x-total-count">> => integer_to_binary(Total), + <<"access-control-expose-headers">> => <<"Content-Range, X-Total-Count">> + }. \ No newline at end of file diff --git a/src/handlers/admin/admin_handler_reviews.erl b/src/handlers/admin/admin_handler_reviews.erl index efd8cc5..f9799f6 100644 --- a/src/handlers/admin/admin_handler_reviews.erl +++ b/src/handlers/admin/admin_handler_reviews.erl @@ -1,101 +1,136 @@ -module(admin_handler_reviews). -behaviour(cowboy_handler). +-export([init/2]). +-export([trails/0]). + -include("records.hrl"). --export([init/2]). - +%%% cowboy_handler callback init(Req, _Opts) -> case cowboy_req:method(Req) of - <<"GET">> -> list_reviews(Req); + <<"GET">> -> list_reviews(Req); <<"PATCH">> -> bulk_update_reviews(Req); - _ -> send_error(Req, 405, <<"Method not allowed">>) + _ -> handler_utils:send_error(Req, 405, <<"Method not allowed">>) end. +%%% Swagger metadata +trails() -> + [ + #{ % GET list + path => <<"/v1/admin/reviews">>, + method => <<"GET">>, + description => <<"List all reviews (admin)">>, + tags => [<<"Reviews">>], + parameters => [ + #{name => <<"target_type">>, in => <<"query">>, schema => #{type => string}, description => <<"calendar or event">>}, + #{name => <<"target_id">>, in => <<"query">>, schema => #{type => string}, description => <<"ID of target">>}, + #{name => <<"user_id">>, in => <<"query">>, schema => #{type => string}, description => <<"Filter by user">>}, + #{name => <<"status">>, in => <<"query">>, schema => #{type => string}, description => <<"visible, hidden, deleted, or all">>}, + #{name => <<"limit">>, in => <<"query">>, schema => #{type => integer}, description => <<"Page size">>}, + #{name => <<"offset">>, in => <<"query">>, schema => #{type => integer}, description => <<"Offset">>} + ], + responses => #{ + 200 => #{ + description => <<"Array of reviews">>, + content => #{<<"application/json">> => #{schema => #{ + type => array, + items => review_schema() + }}} + } + } + }, + #{ % PATCH bulk update + path => <<"/v1/admin/reviews">>, + method => <<"PATCH">>, + description => <<"Bulk update review statuses">>, + tags => [<<"Reviews">>], + requestBody => #{ + required => true, + content => #{<<"application/json">> => #{schema => #{ + type => array, + items => #{ + type => object, + properties => #{ + id => #{type => string}, + status => #{type => string, enum => [<<"visible">>, <<"hidden">>, <<"deleted">>]} + } + } + }}} + }, + responses => #{ + 200 => #{description => <<"Number of updated reviews">>} + } + } + ]. + +review_schema() -> + #{ + type => object, + properties => #{ + id => #{type => string}, + user_id => #{type => string}, + target_type => #{type => string, enum => [<<"calendar">>, <<"event">>]}, + target_id => #{type => string}, + rating => #{type => integer, minimum => 1, maximum => 5}, + comment => #{type => string}, + status => #{type => string, enum => [<<"visible">>, <<"hidden">>, <<"deleted">>]}, + reason => #{type => string, nullable => true}, + likes => #{type => integer}, + dislikes => #{type => integer}, + created_at => #{type => string, format => <<"date-time">>}, + updated_at => #{type => string, format => <<"date-time">>} + } + }. + +%%% Internal functions + list_reviews(Req) -> - case auth_admin(Req) of + case handler_utils:auth_admin(Req) of {ok, _AdminId, Req1} -> - Filters = parse_filters(Req1), - Reviews = logic_review:list_admin_reviews(Filters), - Json = [review_to_json(R) || R <- Reviews], - send_json(Req1, 200, Json); + Filters = parse_review_filters(Req1), + Pagination = handler_utils:parse_pagination_params(Req1), + {ok, Total, Reviews} = logic_review:list_admin_reviews(Filters, Pagination), + Json = [handler_utils:review_to_json(R) || R <- Reviews], + ExtraHeaders = pagination_headers(Pagination, Total), + handler_utils:send_json(Req1, 200, Json, ExtraHeaders); {error, Code, Msg, Req1} -> - send_error(Req1, Code, Msg) + handler_utils:send_error(Req1, Code, Msg) end. bulk_update_reviews(Req) -> - case auth_admin(Req) of + case handler_utils:auth_admin(Req) of {ok, _AdminId, Req1} -> try {ok, Body, Req2} = cowboy_req:read_body(Req1), Operations = jsx:decode(Body, [return_maps]), + true = is_list(Operations), case logic_review:bulk_update_status(Operations) of - {ok, Count} -> - send_json(Req2, 200, #{updated_count => Count}); + {ok, UpdatedCount} -> + handler_utils:send_json(Req2, 200, #{updated_count => UpdatedCount}); {error, Reason} -> - send_error(Req2, 400, Reason) + handler_utils:send_error(Req2, 400, Reason) end catch - _:_ -> send_error(Req1, 400, <<"Invalid JSON body">>) + _:_ -> handler_utils:send_error(Req1, 400, <<"Invalid JSON body">>) end; {error, Code, Msg, Req1} -> - send_error(Req1, Code, Msg) + handler_utils:send_error(Req1, Code, Msg) end. -auth_admin(Req) -> - case handler_auth:authenticate(Req) of - {ok, AdminId, Req1} -> - case admin_utils:is_admin(AdminId) of - true -> {ok, AdminId, Req1}; - false -> {error, 403, <<"Admin access required">>, Req1} - end; - {error, Code, Msg, Req1} -> - {error, Code, Msg, Req1} - end. - -%% Извлечение параметров фильтрации из query string. -%% Например: ?target_type=event&target_id=...&user_id=... -parse_filters(Req) -> +parse_review_filters(Req) -> Qs = cowboy_req:parse_qs(Req), - lists:filtermap( - fun - ({<<"target_type">>, Val}) -> {true, {target_type, Val}}; - ({<<"target_id">>, Val}) -> {true, {target_id, Val}}; - ({<<"user_id">>, Val}) -> {true, {user_id, Val}}; - (_) -> false - end, - Qs - ). - -review_to_json(R) -> #{ - id => R#review.id, - user_id => R#review.user_id, - target_type => R#review.target_type, - target_id => R#review.target_id, - rating => R#review.rating, - comment => R#review.comment, - status => R#review.status, - created_at => datetime_to_iso8601(R#review.created_at), - updated_at => datetime_to_iso8601(R#review.updated_at) + target_type => proplists:get_value(<<"target_type">>, Qs), + target_id => proplists:get_value(<<"target_id">>, Qs), + user_id => proplists:get_value(<<"user_id">>, Qs), + status => proplists:get_value(<<"status">>, Qs) }. -datetime_to_iso8601({{Year, Month, Day}, {Hour, Minute, Second}}) -> - iolist_to_binary(io_lib:format("~4..0B-~2..0B-~2..0BT~2..0B:~2..0B:~2..0BZ", - [Year, Month, Day, Hour, Minute, Second])); -datetime_to_iso8601(undefined) -> undefined. - -send_json(Req, Status, Data) -> - Headers = #{ - <<"content-type">> => <<"application/json">>, - <<"access-control-allow-origin">> => <<"*">>, - <<"access-control-expose-headers">> => <<"Content-Range">> - }, - Body = jsx:encode(Data), - cowboy_req:reply(Status, Headers, Body, Req), - {ok, Body, []}. - -send_error(Req, Status, Message) -> - Body = jsx:encode(#{error => Message}), - cowboy_req:reply(Status, #{<<"content-type">> => <<"application/json">>}, Body, Req), - {ok, Body, []}. \ No newline at end of file +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">> + }. \ No newline at end of file diff --git a/src/handlers/admin/admin_handler_reviews_by_id.erl b/src/handlers/admin/admin_handler_reviews_by_id.erl index 43c1972..725cccf 100644 --- a/src/handlers/admin/admin_handler_reviews_by_id.erl +++ b/src/handlers/admin/admin_handler_reviews_by_id.erl @@ -1,93 +1,127 @@ -module(admin_handler_reviews_by_id). -behaviour(cowboy_handler). + -export([init/2]). +-export([trails/0]). -include("records.hrl"). +%%% cowboy_handler callback init(Req, _Opts) -> case cowboy_req:method(Req) of <<"GET">> -> get_review(Req); <<"PUT">> -> update_review(Req); - _ -> send_error(Req, 405, <<"Method not allowed">>) + _ -> handler_utils:send_error(Req, 405, <<"Method not allowed">>) end. +%%% Swagger metadata +trails() -> + BaseParams = [#{ + name => <<"id">>, + in => <<"path">>, + description => <<"Review ID">>, + required => true, + schema => #{type => string} + }], + [ + #{ % GET by id + path => <<"/v1/admin/reviews/:id">>, + method => <<"GET">>, + description => <<"Get review by ID (admin)">>, + tags => [<<"Reviews">>], + parameters => BaseParams, + responses => #{ + 200 => #{ + description => <<"Review details">>, + content => #{<<"application/json">> => #{schema => review_schema()}} + } + } + }, + #{ % PUT update + path => <<"/v1/admin/reviews/:id">>, + method => <<"PUT">>, + description => <<"Update review (admin)">>, + tags => [<<"Reviews">>], + parameters => BaseParams, + requestBody => #{ + required => true, + content => #{<<"application/json">> => #{schema => review_update_schema()}} + }, + responses => #{ + 200 => #{description => <<"Updated review">>} + } + } + ]. + +review_schema() -> + #{ + type => object, + properties => #{ + id => #{type => string}, + user_id => #{type => string}, + target_type => #{type => string, enum => [<<"calendar">>, <<"event">>]}, + target_id => #{type => string}, + rating => #{type => integer, minimum => 1, maximum => 5}, + comment => #{type => string}, + status => #{type => string, enum => [<<"visible">>, <<"hidden">>, <<"deleted">>]}, + reason => #{type => string, nullable => true}, + likes => #{type => integer}, + dislikes => #{type => integer}, + created_at => #{type => string, format => <<"date-time">>}, + updated_at => #{type => string, format => <<"date-time">>} + } + }. + +review_update_schema() -> + #{ + type => object, + properties => #{ + status => #{type => string, enum => [<<"visible">>, <<"hidden">>, <<"deleted">>]}, + reason => #{type => string}, + comment => #{type => string}, + rating => #{type => integer, minimum => 1, maximum => 5} + } + }. + +%%% Internal functions + get_review(Req) -> - case handler_auth:authenticate(Req) of - {ok, AdminId, Req1} -> - case admin_utils:is_admin(AdminId) of - true -> - ReviewId = cowboy_req:binding(id, Req1), - case core_review:get_by_id(ReviewId) of - {ok, Review} -> - send_json(Req1, 200, review_to_json(Review)); - {error, not_found} -> - send_error(Req1, 404, <<"Review not found">>) - end; - false -> - send_error(Req1, 403, <<"Admin access required">>) + case handler_utils:auth_admin(Req) of + {ok, _AdminId, Req1} -> + ReviewId = cowboy_req:binding(id, Req1), + case logic_review:get_review_admin(ReviewId) of + {ok, Review} -> + handler_utils:send_json(Req1, 200, handler_utils:review_to_json(Review)); + {error, not_found} -> + handler_utils:send_error(Req1, 404, <<"Review not found">>); + {error, _} -> + handler_utils:send_error(Req1, 500, <<"Internal server error">>) end; - {error, Code, Message, Req1} -> - send_error(Req1, Code, Message) + {error, Code, Msg, Req1} -> + handler_utils:send_error(Req1, Code, Msg) end. update_review(Req) -> - case handler_auth:authenticate(Req) of - {ok, AdminId, Req1} -> - case admin_utils:is_admin(AdminId) of - true -> - ReviewId = cowboy_req:binding(id, Req1), - {ok, Body, Req2} = cowboy_req:read_body(Req1), - try jsx:decode(Body, [return_maps]) of - #{<<"status">> := NewStatus} -> - case core_review:update_status(ReviewId, NewStatus) of - {ok, Review} -> - send_json(Req2, 200, review_to_json(Review)); - {error, not_found} -> - send_error(Req2, 404, <<"Review not found">>); - {error, _} -> - send_error(Req2, 500, <<"Internal server error">>) - end; - _ -> - send_error(Req2, 400, <<"Missing status field">>) - catch - _:_ -> send_error(Req2, 400, <<"Invalid JSON">>) + case handler_utils:auth_admin(Req) of + {ok, _AdminId, Req1} -> + ReviewId = cowboy_req:binding(id, Req1), + {ok, Body, Req2} = cowboy_req:read_body(Req1), + try jsx:decode(Body, [return_maps]) of + UpdatesMap when is_map(UpdatesMap) -> + Updates = maps:to_list(UpdatesMap), + case logic_review:update_review_admin(ReviewId, Updates) of + {ok, Review} -> + handler_utils:send_json(Req2, 200, handler_utils:review_to_json(Review)); + {error, not_found} -> + handler_utils:send_error(Req2, 404, <<"Review not found">>); + {error, _} -> + handler_utils:send_error(Req2, 500, <<"Internal server error">>) end; - false -> - send_error(Req1, 403, <<"Admin access required">>) + _ -> + handler_utils:send_error(Req2, 400, <<"Invalid JSON">>) + catch + _:_ -> handler_utils:send_error(Req1, 400, <<"Invalid JSON format">>) end; - {error, Code, Message, Req1} -> - send_error(Req1, Code, Message) - end. - -review_to_json(R) -> - #{ - id => R#review.id, - user_id => R#review.user_id, - target_type => R#review.target_type, - target_id => R#review.target_id, - rating => R#review.rating, - comment => R#review.comment, - status => R#review.status, - created_at => datetime_to_iso8601(R#review.created_at), - updated_at => datetime_to_iso8601(R#review.updated_at) - }. - -datetime_to_iso8601({{Year, Month, Day}, {Hour, Minute, Second}}) -> - iolist_to_binary(io_lib:format("~4..0B-~2..0B-~2..0BT~2..0B:~2..0B:~2..0BZ", - [Year, Month, Day, Hour, Minute, Second])); -datetime_to_iso8601(undefined) -> undefined. - -send_json(Req, Status, Data) -> - Headers = #{ - <<"content-type">> => <<"application/json">>, - <<"access-control-allow-origin">> => <<"*">>, - <<"access-control-expose-headers">> => <<"Content-Range">> - }, - Body = jsx:encode(Data), - cowboy_req:reply(Status, Headers, Body, Req), - {ok, Body, []}. - -send_error(Req, Status, Message) -> - Body = jsx:encode(#{error => Message}), - cowboy_req:reply(Status, #{<<"content-type">> => <<"application/json">>}, Body, Req), - {ok, Body, []}. \ No newline at end of file + {error, Code, Msg, Req1} -> + handler_utils:send_error(Req1, Code, Msg) + end. \ No newline at end of file diff --git a/src/handlers/admin/admin_handler_stats.erl b/src/handlers/admin/admin_handler_stats.erl index 0cac589..ec99562 100644 --- a/src/handlers/admin/admin_handler_stats.erl +++ b/src/handlers/admin/admin_handler_stats.erl @@ -1,63 +1,102 @@ +%%%------------------------------------------------------------------- +%%% @doc Административный обработчик для получения статистики. +%%% GET – возвращает агрегированную статистику для дашборда. +%%% Поддерживает фильтрацию по диапазону дат (from, to). +%%% @end +%%%------------------------------------------------------------------- -module(admin_handler_stats). --include("records.hrl"). --export([init/2]). +-behaviour(cowboy_handler). +-export([init/2]). +-export([trails/0]). + +-include("records.hrl"). + +%%% cowboy_handler callback +-spec init(cowboy_req:req(), any()) -> {ok, cowboy_req:req(), any()}. init(Req, _Opts) -> case cowboy_req:method(Req) of <<"GET">> -> get_stats(Req); - _ -> send_error(Req, 405, <<"Method not allowed">>) + _ -> handler_utils:send_error(Req, 405, <<"Method not allowed">>) end. +%%% Swagger metadata +-spec trails() -> [map()]. +trails() -> + [ + #{ + path => <<"/v1/admin/stats">>, + method => <<"GET">>, + description => <<"Get admin dashboard statistics">>, + tags => [<<"Statistics">>], + parameters => [ + #{name => <<"from">>, in => <<"query">>, schema => #{type => string, format => <<"date-time">>}, description => <<"Start date (ISO8601)">>}, + #{name => <<"to">>, in => <<"query">>, schema => #{type => string, format => <<"date-time">>}, description => <<"End date (ISO8601)">>} + ], + responses => #{ + 200 => #{ + description => <<"Statistics object">>, + content => #{<<"application/json">> => #{schema => #{ + type => object, + properties => stats_schema() + }}} + }, + 403 => #{description => <<"Admin access required">>} + } + } + ]. + +stats_schema() -> + #{ + <<"users">> => #{type => integer, description => <<"Total number of users">>}, + <<"events">> => #{type => integer}, + <<"reviews">> => #{type => integer}, + <<"calendars">> => #{type => integer}, + <<"reports">> => #{type => integer}, + <<"tickets">> => #{type => integer}, + <<"subscriptions">> => #{type => integer}, + <<"active_subscriptions">> => #{type => integer} + }. + +%%% Internal functions + +%% @doc Получить статистику с учетом параметров запроса. +-spec get_stats(cowboy_req:req()) -> {ok, binary(), cowboy_req:req()}. get_stats(Req) -> - case handler_auth:authenticate(Req) of + case handler_utils:auth_admin(Req) of {ok, AdminId, Req1} -> - case admin_utils:is_admin(AdminId) of - true -> - {ok, Admin} = core_admin:get_by_id(AdminId), - Role = Admin#admin.role, - % Извлекаем параметры from и to из запроса - Stats = case parse_date_range(Req1) of - {ok, From, To} -> - logic_stats:get_stats(Role, AdminId, From, To); - _ -> - logic_stats:get_stats(Role, AdminId) - end, - send_json(Req1, 200, Stats); - false -> - send_error(Req1, 403, <<"Admin access required">>) - end; + {ok, Admin} = core_admin:get_by_id(AdminId), + Role = Admin#admin.role, + Stats = case parse_date_range(Req1) of + {ok, From, To} -> logic_stats:get_stats(Role, AdminId, From, To); + _ -> logic_stats:get_stats(Role, AdminId) + end, + handler_utils:send_json(Req1, 200, Stats); {error, Code, Message, Req1} -> - send_error(Req1, Code, Message) + handler_utils:send_error(Req1, Code, Message) end. +%% @private Разбирает параметры 'from' и 'to' из строки запроса. +%% В случае успеха возвращает {ok, FromDT, ToDT}. +-spec parse_date_range(cowboy_req:req()) -> {ok, calendar:datetime(), calendar:datetime()} | error. parse_date_range(Req) -> Qs = cowboy_req:parse_qs(Req), From = proplists:get_value(<<"from">>, Qs), - To = proplists:get_value(<<"to">>, Qs), + To = proplists:get_value(<<"to">>, Qs), case {From, To} of {undefined, _} -> error; {_, undefined} -> error; - {F, T} -> - try - FromDT = iso8601_to_datetime(F), - ToDT = iso8601_to_datetime(T), - {ok, FromDT, ToDT} - catch _:_ -> error - end + {F, T} -> try FromDT = iso8601_to_datetime(F), + ToDT = iso8601_to_datetime(T), + {ok, FromDT, ToDT} + catch _:_ -> error + end end. +%% @private Преобразует бинарную строку ISO8601 в кортеж datetime(). +-spec iso8601_to_datetime(binary()) -> calendar:datetime(). iso8601_to_datetime(Str) -> [Date, Time] = binary:split(Str, <<"T">>), [Y, M, D] = [binary_to_integer(X) || X <- binary:split(Date, <<"-">>, [global])], [H, Min, S] = [binary_to_integer(X) || X <- binary:split(Time, <<":">>, [global])], - {{Y, M, D}, {H, Min, S}}. - -send_json(Req, Status, Data) -> - Body = jsx:encode(Data), - cowboy_req:reply(Status, #{<<"content-type">> => <<"application/json">>}, Body, Req), - {ok, Body, []}. - -send_error(Req, Status, Message) -> - Body = jsx:encode(#{error => Message}), - cowboy_req:reply(Status, #{<<"content-type">> => <<"application/json">>}, Body, Req), - {ok, Body, []}. \ No newline at end of file + {{Y, M, D}, {H, Min, S}}. \ No newline at end of file diff --git a/src/handlers/admin/admin_handler_subscriptions.erl b/src/handlers/admin/admin_handler_subscriptions.erl index 5341734..f5dc544 100644 --- a/src/handlers/admin/admin_handler_subscriptions.erl +++ b/src/handlers/admin/admin_handler_subscriptions.erl @@ -1,197 +1,122 @@ +%%%------------------------------------------------------------------- +%%% @doc Административный обработчик подписок. +%%% GET – список с пагинацией и фильтрацией. +%%% @end +%%%------------------------------------------------------------------- -module(admin_handler_subscriptions). -behaviour(cowboy_handler). + -export([init/2]). +-export([trails/0]). -include("records.hrl"). +-spec init(cowboy_req:req(), any()) -> {ok, cowboy_req:req(), any()}. init(Req, _Opts) -> - case cowboy_req:binding(id, Req) of - undefined -> handle_collection(Req); - _SubId -> handle_item(Req) - end. - -%% ================== Коллекция ================== -handle_collection(Req) -> case cowboy_req:method(Req) of - <<"GET">> -> list_subscriptions(Req); - <<"POST">> -> create_subscription(Req); - _ -> send_error(Req, 405, <<"Method not allowed">>) + <<"GET">> -> list_subscriptions(Req); + _ -> handler_utils:send_error(Req, 405, <<"Method not allowed">>) end. -%% ================== Элемент ================== -handle_item(Req) -> - SubId = cowboy_req:binding(id, Req), - case cowboy_req:method(Req) of - <<"GET">> -> get_subscription(SubId, Req); - <<"PUT">> -> update_subscription(SubId, Req); - <<"DELETE">> -> delete_subscription(SubId, Req); - _ -> send_error(Req, 405, <<"Method not allowed">>) - end. +-spec trails() -> [map()]. +trails() -> + [ + #{ + path => <<"/v1/admin/subscriptions">>, + method => <<"GET">>, + description => <<"List all subscriptions (admin)">>, + tags => [<<"Subscriptions">>], + parameters => [ + #{name => <<"plan">>, in => <<"query">>, schema => #{type => string, enum => [<<"monthly">>, <<"quarterly">>, <<"biannual">>, <<"annual">>]}, description => <<"Filter by plan">>}, + #{name => <<"status">>, in => <<"query">>, schema => #{type => string, enum => [<<"active">>, <<"expired">>, <<"cancelled">>]}, description => <<"Filter by status">>}, + #{name => <<"limit">>, in => <<"query">>, schema => #{type => integer}, description => <<"Page size">>}, + #{name => <<"offset">>, in => <<"query">>, schema => #{type => integer}, description => <<"Offset">>} + ], + responses => #{ + 200 => #{ + description => <<"Array of subscriptions">>, + content => #{<<"application/json">> => #{schema => #{ + type => array, + items => subscription_schema() + }}} + } + } + } + ]. -%% ================== GET /subscriptions ================== -list_subscriptions(Req) -> - case auth_admin(Req) of - {ok, _AdminId, Req1} -> - Subs = core_subscription:list_subscriptions(), - send_json(Req1, 200, [subscription_to_json(S) || S <- Subs]); - {error, Code, Message, Req1} -> - send_error(Req1, Code, Message) - end. - -%% ================== GET /subscriptions/:id ================== -get_subscription(Id, Req) -> - case auth_admin(Req) of - {ok, _AdminId, Req1} -> - case core_subscription:get_by_id(Id) of - {ok, Sub} -> - send_json(Req1, 200, subscription_to_json(Sub)); - {error, not_found} -> - send_error(Req1, 404, <<"Subscription not found">>) - end; - {error, Code, Message, Req1} -> - send_error(Req1, Code, Message) - end. - -%% ================== POST /subscriptions ================== -create_subscription(Req) -> - case auth_admin(Req) of - {ok, AdminId, Req1} -> - {ok, Body, Req2} = cowboy_req:read_body(Req1), - try - Decoded = jsx:decode(Body, [return_maps]), - case Decoded of - #{<<"user_id">> := UserId, <<"plan">> := Plan} -> - case validate_plan(Plan) of - true -> - SubData = maps:merge(#{ - <<"status">> => <<"active">>, - <<"trial_used">> => false - }, maps:without([<<"id">>], Decoded)), % ← исправлено: Decoded, а не Body - case core_subscription:create_subscription(SubData) of - {ok, Sub} -> - log_audit(AdminId, <<"create_subscription">>, <<"subscription">>, Sub#subscription.id, UserId), - send_json(Req2, 201, subscription_to_json(Sub)); - {error, Reason} -> - send_error(Req2, 500, Reason) - end; - false -> - send_error(Req2, 400, <<"Invalid plan value">>) - end; - _ -> - send_error(Req2, 400, <<"Missing 'user_id' or 'plan' field">>) - end - catch - _:_ -> send_error(Req2, 400, <<"Invalid JSON">>) - end; - {error, Code, Message, Req1} -> - send_error(Req1, Code, Message) - end. - -%% ================== PUT /subscriptions/:id ================== -update_subscription(Id, Req) -> - case auth_admin(Req) of - {ok, AdminId, Req1} -> - {ok, Body, Req2} = cowboy_req:read_body(Req1), - try - Updates = jsx:decode(Body, [return_maps]), - case map_size(Updates) > 0 of - true -> - case core_subscription:update_subscription(Id, Updates) of - {ok, Sub} -> - log_audit(AdminId, <<"update_subscription">>, <<"subscription">>, Id, <<"">>), - send_json(Req2, 200, subscription_to_json(Sub)); - {error, not_found} -> - send_error(Req2, 404, <<"Subscription not found">>); - {error, Reason} -> - send_error(Req2, 500, Reason) - end; - false -> - send_error(Req2, 400, <<"Request body is empty">>) - end - catch - _:_ -> send_error(Req2, 400, <<"Invalid JSON">>) - end; - {error, Code, Message, Req1} -> - send_error(Req1, Code, Message) - end. - -%% ================== DELETE /subscriptions/:id ================== -delete_subscription(Id, Req) -> - case auth_admin(Req) of - {ok, AdminId, Req1} -> - case core_subscription:delete_subscription(Id) of - {ok, deleted} -> - log_audit(AdminId, <<"delete_subscription">>, <<"subscription">>, Id, <<"">>), - send_json(Req1, 200, #{status => <<"deleted">>}); - {error, not_found} -> - send_error(Req1, 404, <<"Subscription not found">>) - end; - {error, Code, Message, Req1} -> - send_error(Req1, Code, Message) - end. - -%% ================== Аутентификация и роли ================== -auth_admin(Req) -> - case handler_auth:authenticate(Req) of - {ok, AdminId, Req1} -> - case admin_utils:is_admin(AdminId) of - true -> {ok, AdminId, Req1}; - false -> {error, 403, <<"Admin access required">>, Req1} - end; - {error, Code, Message, Req1} -> - {error, Code, Message, Req1} - end. - -%% ================== Аудит ================== -log_audit(AdminId, Action, EntityType, EntityId, Reason) -> - case core_admin:get_by_id(AdminId) of - {ok, Admin} -> - core_admin_audit:log(AdminId, Admin#admin.email, Admin#admin.role, - Action, EntityType, EntityId, <<"127.0.0.1">>, Reason); - _ -> ok - end. - -%% ================== Сериализация ================== -subscription_to_json(S) -> +subscription_schema() -> #{ - id => S#subscription.id, - user_id => S#subscription.user_id, - plan => atom_to_binary(S#subscription.plan, utf8), - status => atom_to_binary(S#subscription.status, utf8), - trial_used => S#subscription.trial_used, - started_at => datetime_to_iso8601(S#subscription.started_at), - expires_at => datetime_to_iso8601(S#subscription.expires_at), - created_at => datetime_to_iso8601(S#subscription.created_at), - updated_at => datetime_to_iso8601(S#subscription.updated_at) + type => object, + properties => #{ + id => #{type => string}, + user_id => #{type => string}, + plan => #{type => string, enum => [<<"monthly">>, <<"quarterly">>, <<"biannual">>, <<"annual">>]}, + status => #{type => string, enum => [<<"active">>, <<"expired">>, <<"cancelled">>]}, + trial_used => #{type => boolean}, + started_at => #{type => string, format => <<"date-time">>}, + expires_at => #{type => string, format => <<"date-time">>}, + created_at => #{type => string, format => <<"date-time">>}, + updated_at => #{type => string, format => <<"date-time">>} + } }. -datetime_to_iso8601({{Year, Month, Day}, {Hour, Minute, Second}}) -> - iolist_to_binary(io_lib:format("~4..0B-~2..0B-~2..0BT~2..0B:~2..0B:~2..0BZ", - [Year, Month, Day, Hour, Minute, Second])); -datetime_to_iso8601(undefined) -> undefined. +%%% Internal functions -%% ================== Валидация ================== -validate_plan(Plan) when is_binary(Plan) -> - lists:member(Plan, [<<"monthly">>, <<"yearly">>, <<"quarterly">>, <<"biannual">>, <<"annual">>]); -validate_plan(_) -> false. +list_subscriptions(Req) -> + case handler_utils:auth_admin(Req) of + {ok, _AdminId, Req1} -> + Filters = parse_subscription_filters(Req1), + Pagination = handler_utils:parse_pagination_params(Req1), + {ok, AllSubscriptions} = core_subscription:list_all(), + Filtered = apply_filters(AllSubscriptions, Filters), + Sorted = sort_subscriptions(Filtered, Pagination), + Total = length(Sorted), + Page = lists:sublist(Sorted, maps:get(offset, Pagination) + 1, maps:get(limit, Pagination)), + Json = [handler_utils:subscription_to_json(S) || S <- Page], + ExtraHeaders = pagination_headers(Pagination, Total), + handler_utils:send_json(Req1, 200, Json, ExtraHeaders); + {error, Code, Msg, Req1} -> + handler_utils:send_error(Req1, Code, Msg) + end. -%% ================== HTTP-ответы ================== -send_json(Req, Status, Data) -> - Headers = #{ - <<"content-type">> => <<"application/json">>, - <<"access-control-allow-origin">> => <<"*">>, - <<"access-control-expose-headers">> => <<"Content-Range">> - }, - Body = jsx:encode(Data), - cowboy_req:reply(Status, Headers, Body, Req), - {ok, Body, []}. +parse_subscription_filters(Req) -> + Qs = cowboy_req:parse_qs(Req), + #{ + plan => proplists:get_value(<<"plan">>, Qs), + status => proplists:get_value(<<"status">>, Qs) + }. -send_error(Req, Code, Message) -> - Headers = #{ - <<"content-type">> => <<"application/json">>, - <<"access-control-allow-origin">> => <<"*">>, - <<"access-control-expose-headers">> => <<"Content-Range">> - }, - Body = jsx:encode(#{error => Message}), - cowboy_req:reply(Code, Headers, Body, Req), - {ok, Body, []}. \ No newline at end of file +apply_filters(Subs, Filters) -> + Plan = maps:get(plan, Filters, undefined), + Status = maps:get(status, Filters, undefined), + F1 = case Plan of + undefined -> Subs; + _ -> [S || S <- Subs, S#subscription.plan =:= Plan] + end, + case Status of + undefined -> F1; + _ -> [S || S <- F1, S#subscription.status =:= Status] + end. + +sort_subscriptions(Subs, #{sort := Sort, order := Order}) -> + Field = binary_to_existing_atom(Sort, utf8), + lists:sort( + fun(A, B) -> + ValA = sub_field(A, Field), + ValB = sub_field(B, Field), + if Order == <<"asc">> -> ValA =< ValB; + true -> ValA >= ValB + end + end, Subs). + +sub_field(#subscription{created_at = V}, created_at) -> V; +sub_field(#subscription{expires_at = V}, expires_at) -> V; +sub_field(_, _) -> undefined. + +pagination_headers(#{limit := Limit, offset := Offset}, Total) -> + RangeEnd = min(Offset + Limit - 1, Total - 1), + #{ + <<"content-range">> => iolist_to_binary(io_lib:format("items ~B-~B/~B", [Offset, RangeEnd, Total])), + <<"x-total-count">> => integer_to_binary(Total), + <<"access-control-expose-headers">> => <<"Content-Range, X-Total-Count">> + }. \ No newline at end of file diff --git a/src/handlers/admin/admin_handler_subscriptions_by_id.erl b/src/handlers/admin/admin_handler_subscriptions_by_id.erl new file mode 100644 index 0000000..d63bfa8 --- /dev/null +++ b/src/handlers/admin/admin_handler_subscriptions_by_id.erl @@ -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. \ No newline at end of file diff --git a/src/handlers/admin/admin_handler_ticket_by_id.erl b/src/handlers/admin/admin_handler_ticket_by_id.erl index 023d2b5..1dbf677 100644 --- a/src/handlers/admin/admin_handler_ticket_by_id.erl +++ b/src/handlers/admin/admin_handler_ticket_by_id.erl @@ -1,106 +1,206 @@ +%%%------------------------------------------------------------------- +%%% @doc Административный обработчик конкретного тикета. +%%% GET – получить тикет по ID. +%%% PUT – обновить тикет. +%%% DELETE – удалить тикет. +%%% @end +%%%------------------------------------------------------------------- -module(admin_handler_ticket_by_id). -behaviour(cowboy_handler). + -export([init/2]). +-export([trails/0]). -include("records.hrl"). +%%% cowboy_handler callback +-spec init(cowboy_req:req(), any()) -> {ok, cowboy_req:req(), any()}. init(Req, _Opts) -> case cowboy_req:method(Req) of <<"GET">> -> get_ticket(Req); <<"PUT">> -> update_ticket(Req); <<"DELETE">> -> delete_ticket(Req); - _ -> send_error(Req, 405, <<"Method not allowed">>) + _ -> handler_utils:send_error(Req, 405, <<"Method not allowed">>) end. +%%% Swagger metadata +-spec trails() -> [map()]. +trails() -> + BaseParams = [ + #{ + name => <<"id">>, + in => <<"path">>, + description => <<"Ticket ID">>, + required => true, + schema => #{type => string} + } + ], + [ + #{ % GET by id + path => <<"/v1/admin/tickets/:id">>, + method => <<"GET">>, + description => <<"Get ticket by ID (admin)">>, + tags => [<<"Tickets">>], + parameters => BaseParams, + responses => #{ + 200 => #{ + description => <<"Ticket details">>, + content => #{<<"application/json">> => #{schema => ticket_schema()}} + }, + 404 => #{description => <<"Ticket not found">>} + } + }, + #{ % PUT update + path => <<"/v1/admin/tickets/:id">>, + method => <<"PUT">>, + description => <<"Update ticket (admin)">>, + tags => [<<"Tickets">>], + parameters => BaseParams, + requestBody => #{ + required => true, + content => #{<<"application/json">> => #{schema => ticket_update_schema()}} + }, + responses => #{ + 200 => #{description => <<"Updated ticket">>}, + 404 => #{description => <<"Ticket not found">>} + } + }, + #{ % DELETE + path => <<"/v1/admin/tickets/:id">>, + method => <<"DELETE">>, + description => <<"Delete ticket (admin)">>, + tags => [<<"Tickets">>], + parameters => BaseParams, + responses => #{ + 200 => #{description => <<"Ticket deleted">>}, + 404 => #{description => <<"Ticket not found">>} + } + } + ]. + +ticket_schema() -> + #{ + type => object, + properties => #{ + id => #{type => string}, + reporter_id => #{type => string}, + error_hash => #{type => string}, + error_message => #{type => string}, + stacktrace => #{type => string}, + context => #{type => string}, + count => #{type => integer}, + first_seen => #{type => string, format => <<"date-time">>}, + last_seen => #{type => string, format => <<"date-time">>}, + status => #{type => string, enum => [<<"open">>, <<"in_progress">>, <<"resolved">>, <<"closed">>]}, + assigned_to => #{type => string, nullable => true}, + resolution_note => #{type => string, nullable => true} + } + }. + +ticket_update_schema() -> + #{ + type => object, + properties => #{ + status => #{type => string, enum => [<<"open">>, <<"in_progress">>, <<"resolved">>, <<"closed">>]}, + assigned_to => #{type => string}, + resolution_note => #{type => string} + } + }. + +%%% Internal functions + +%% @doc Получить тикет по ID. +-spec get_ticket(cowboy_req:req()) -> {ok, binary(), cowboy_req:req()}. get_ticket(Req) -> - case auth_admin(Req) of - {ok, _AdminId, Req1} -> + case handler_utils:auth_admin(Req) of + {ok, AdminId, Req1} -> TicketId = cowboy_req:binding(id, Req1), - case core_ticket:get_by_id(TicketId) of + case logic_ticket:get_ticket(AdminId, TicketId) of {ok, Ticket} -> - send_json(Req1, 200, ticket_to_json(Ticket)); + handler_utils:send_json(Req1, 200, handler_utils:ticket_to_json(Ticket)); {error, not_found} -> - send_error(Req1, 404, <<"Ticket not found">>) + handler_utils:send_error(Req1, 404, <<"Ticket not found">>); + {error, access_denied} -> + handler_utils:send_error(Req1, 403, <<"Admin access required">>); + {error, _} -> + handler_utils:send_error(Req1, 500, <<"Internal server error">>) end; - {error, Code, Message, Req1} -> - send_error(Req1, Code, Message) + {error, Code, Msg, Req1} -> + handler_utils:send_error(Req1, Code, Msg) end. +%% @doc Обновить тикет. +-spec update_ticket(cowboy_req:req()) -> {ok, binary(), cowboy_req:req()}. update_ticket(Req) -> - case auth_admin(Req) of - {ok, _AdminId, Req1} -> + case handler_utils:auth_admin(Req) of + {ok, AdminId, Req1} -> TicketId = cowboy_req:binding(id, Req1), {ok, Body, Req2} = cowboy_req:read_body(Req1), try jsx:decode(Body, [return_maps]) of - UpdatesMap when is_map(UpdatesMap) -> - case core_ticket:update_ticket(TicketId, UpdatesMap) of + Data when is_map(Data) -> + Result = apply_ticket_changes(AdminId, TicketId, Data), + case Result of {ok, Ticket} -> - send_json(Req2, 200, ticket_to_json(Ticket)); + handler_utils:send_json(Req2, 200, handler_utils:ticket_to_json(Ticket)); {error, not_found} -> - send_error(Req2, 404, <<"Ticket not found">>); - {error, Reason} -> - send_error(Req2, 500, Reason) + handler_utils:send_error(Req2, 404, <<"Ticket not found">>); + {error, access_denied} -> + handler_utils:send_error(Req2, 403, <<"Admin access required">>); + {error, _} -> + handler_utils:send_error(Req2, 500, <<"Internal server error">>) end; _ -> - send_error(Req2, 400, <<"Invalid JSON">>) + handler_utils:send_error(Req2, 400, <<"Invalid JSON">>) catch - _:_ -> send_error(Req2, 400, <<"Invalid JSON">>) + _:_ -> handler_utils:send_error(Req1, 400, <<"Invalid JSON format">>) end; - {error, Code, Message, Req1} -> - send_error(Req1, Code, Message) + {error, Code, Msg, Req1} -> + handler_utils:send_error(Req1, Code, Msg) end. +%% @doc Удалить тикет. +-spec delete_ticket(cowboy_req:req()) -> {ok, binary(), cowboy_req:req()}. delete_ticket(Req) -> - case auth_admin(Req) of - {ok, _AdminId, Req1} -> - TicketId = cowboy_req:binding(id, Req1), - case core_ticket:delete_ticket(TicketId) of - {ok, deleted} -> - send_json(Req1, 200, #{status => <<"deleted">>}); - {error, not_found} -> - send_error(Req1, 404, <<"Ticket not found">>) - end; - {error, Code, Message, Req1} -> - send_error(Req1, Code, Message) - end. - -auth_admin(Req) -> - case handler_auth:authenticate(Req) of + case handler_utils:auth_admin(Req) of {ok, AdminId, Req1} -> - case admin_utils:is_admin(AdminId) of - true -> {ok, AdminId, Req1}; - false -> {error, 403, <<"Admin access required">>, Req1} + TicketId = cowboy_req:binding(id, Req1), + case logic_ticket:delete_ticket(AdminId, TicketId) of + {ok, _} -> + handler_utils:send_json(Req1, 200, #{status => <<"deleted">>}); + {error, not_found} -> + handler_utils:send_error(Req1, 404, <<"Ticket not found">>); + {error, access_denied} -> + handler_utils:send_error(Req1, 403, <<"Admin access required">>); + {error, _} -> + handler_utils:send_error(Req1, 500, <<"Internal server error">>) end; - {error, Code, Message, Req1} -> - {error, Code, Message, Req1} + {error, Code, Msg, Req1} -> + handler_utils:send_error(Req1, Code, Msg) end. -ticket_to_json(T) -> - #{ - id => T#ticket.id, - error_hash => T#ticket.error_hash, - error_message => T#ticket.error_message, - stacktrace => T#ticket.stacktrace, - context => T#ticket.context, - count => T#ticket.count, - first_seen => datetime_to_iso8601(T#ticket.first_seen), - last_seen => datetime_to_iso8601(T#ticket.last_seen), - status => T#ticket.status, - assigned_to => T#ticket.assigned_to, - resolution_note => T#ticket.resolution_note - }. - -datetime_to_iso8601({{Year, Month, Day}, {Hour, Minute, Second}}) -> - iolist_to_binary(io_lib:format("~4..0B-~2..0B-~2..0BT~2..0B:~2..0B:~2..0BZ", - [Year, Month, Day, Hour, Minute, Second])); -datetime_to_iso8601(undefined) -> undefined. - -send_json(Req, Status, Data) -> - Body = jsx:encode(Data), - cowboy_req:reply(Status, #{<<"content-type">> => <<"application/json">>}, Body, Req), - {ok, Body, []}. - -send_error(Req, Status, Message) -> - Body = jsx:encode(#{error => Message}), - cowboy_req:reply(Status, #{<<"content-type">> => <<"application/json">>}, Body, Req), - {ok, Body, []}. \ No newline at end of file +%% @private Применить изменения (аналогично admin_handler_tickets). +apply_ticket_changes(AdminId, TicketId, Data) -> + case {maps:find(<<"status">>, Data), maps:find(<<"resolution_note">>, Data)} of + {{ok, <<"resolved">>}, {ok, Note}} -> + logic_ticket:resolve_ticket(AdminId, TicketId, Note); + {{ok, <<"resolved">>}, error} -> + logic_ticket:update_status(AdminId, TicketId, resolved); + {{ok, <<"closed">>}, _} -> + logic_ticket:close_ticket(AdminId, TicketId); + {{ok, OtherStatus}, _} -> + case logic_ticket:update_status(AdminId, TicketId, OtherStatus) of + {ok, Ticket1} -> + case maps:find(<<"assigned_to">>, Data) of + {ok, AssignTo} -> + logic_ticket:assign_ticket(AdminId, TicketId, AssignTo); + error -> {ok, Ticket1} + end; + Error -> Error + end; + {error, _} -> + case maps:find(<<"assigned_to">>, Data) of + {ok, AssignTo} -> + logic_ticket:assign_ticket(AdminId, TicketId, AssignTo); + error -> {error, no_changes} + end + end. \ No newline at end of file diff --git a/src/handlers/admin/admin_handler_ticket_stats.erl b/src/handlers/admin/admin_handler_ticket_stats.erl index 2871045..c676b53 100644 --- a/src/handlers/admin/admin_handler_ticket_stats.erl +++ b/src/handlers/admin/admin_handler_ticket_stats.erl @@ -1,46 +1,62 @@ +%%%------------------------------------------------------------------- +%%% @doc Административный обработчик для получения статистики по тикетам. +%%% GET – возвращает агрегированную статистику тикетов +%%% (количество по статусам: open, in_progress, resolved, closed). +%%% @end +%%%------------------------------------------------------------------- -module(admin_handler_ticket_stats). -behaviour(cowboy_handler). + -export([init/2]). +-export([trails/0]). --include("records.hrl"). % ← добавлено +-include("records.hrl"). +%%% cowboy_handler callback +-spec init(cowboy_req:req(), any()) -> {ok, cowboy_req:req(), any()}. init(Req, _Opts) -> case cowboy_req:method(Req) of <<"GET">> -> get_stats(Req); - _ -> send_error(Req, 405, <<"Method not allowed">>) + _ -> handler_utils:send_error(Req, 405, <<"Method not allowed">>) end. +%%% Swagger metadata +-spec trails() -> [map()]. +trails() -> + [ + #{ + path => <<"/v1/admin/tickets/stats">>, + method => <<"GET">>, + description => <<"Get ticket statistics (admin)">>, + tags => [<<"Tickets">>], + responses => #{ + 200 => #{ + description => <<"Ticket statistics">>, + content => #{<<"application/json">> => #{schema => #{ + type => object, + properties => #{ + open => #{type => integer, description => <<"Number of open tickets">>}, + in_progress => #{type => integer, description => <<"Number of tickets in progress">>}, + resolved => #{type => integer, description => <<"Number of resolved tickets">>}, + closed => #{type => integer, description => <<"Number of closed tickets">>}, + total => #{type => integer, description => <<"Total number of tickets">>} + } + }}} + }, + 403 => #{description => <<"Admin access required">>} + } + } + ]. + +%%% Internal functions + +%% @doc Получить статистику тикетов. Доступно только администраторам. +-spec get_stats(cowboy_req:req()) -> {ok, binary(), cowboy_req:req()}. get_stats(Req) -> - case auth_admin(Req) of + case handler_utils:auth_admin(Req) of {ok, _AdminId, Req1} -> Stats = core_ticket:stats(), - send_json(Req1, 200, Stats); + handler_utils:send_json(Req1, 200, Stats); {error, Code, Message, Req1} -> - send_error(Req1, Code, Message) - end. - -auth_admin(Req) -> - case handler_auth:authenticate(Req) of - {ok, AdminId, Req1} -> - case admin_utils:is_admin(AdminId) of - true -> {ok, AdminId, Req1}; - false -> {error, 403, <<"Admin access required">>, Req1} - end; - {error, Code, Message, Req1} -> - {error, Code, Message, Req1} - end. - -send_json(Req, Status, Data) -> - Headers = #{ - <<"content-type">> => <<"application/json">>, - <<"access-control-allow-origin">> => <<"*">>, - <<"access-control-expose-headers">> => <<"Content-Range">> - }, - Body = jsx:encode(Data), - cowboy_req:reply(Status, Headers, Body, Req), - {ok, Body, []}. - -send_error(Req, Status, Message) -> - Body = jsx:encode(#{error => Message}), - cowboy_req:reply(Status, #{<<"content-type">> => <<"application/json">>}, Body, Req), - {ok, Body, []}. \ No newline at end of file + handler_utils:send_error(Req1, Code, Message) + end. \ No newline at end of file diff --git a/src/handlers/admin/admin_handler_tickets.erl b/src/handlers/admin/admin_handler_tickets.erl index 32488b7..4e0e451 100644 --- a/src/handlers/admin/admin_handler_tickets.erl +++ b/src/handlers/admin/admin_handler_tickets.erl @@ -1,188 +1,150 @@ +%%%------------------------------------------------------------------- +%%% @doc Административный обработчик списка тикетов. +%%% GET – список с пагинацией, фильтрацией и сортировкой. +%%% @end +%%%------------------------------------------------------------------- -module(admin_handler_tickets). -behaviour(cowboy_handler). + -export([init/2]). +-export([trails/0]). -include("records.hrl"). +%%% cowboy_handler callback +-spec init(cowboy_req:req(), any()) -> {ok, cowboy_req:req(), any()}. init(Req, _Opts) -> - case cowboy_req:binding(id, Req) of - undefined -> handle_collection(Req); - TicketId -> handle_item(TicketId, Req) - end. - -handle_collection(Req) -> case cowboy_req:method(Req) of <<"GET">> -> list_tickets(Req); - <<"POST">> -> create_ticket(Req); - _ -> send_error(Req, 405, <<"Method not allowed">>) + _ -> handler_utils:send_error(Req, 405, <<"Method not allowed">>) end. -handle_item(TicketId, Req) -> - case cowboy_req:method(Req) of - <<"GET">> -> get_ticket(TicketId, Req); - <<"PUT">> -> update_ticket(TicketId, Req); - <<"DELETE">> -> delete_ticket(TicketId, Req); - _ -> send_error(Req, 405, <<"Method not allowed">>) - end. +%%% Swagger metadata +-spec trails() -> [map()]. +trails() -> + [ + #{ % GET list + path => <<"/v1/admin/tickets">>, + method => <<"GET">>, + description => <<"List all tickets (admin)">>, + tags => [<<"Tickets">>], + parameters => [ + #{name => <<"status">>, in => <<"query">>, schema => #{type => string, enum => [<<"open">>, <<"in_progress">>, <<"resolved">>, <<"closed">>]}, description => <<"Filter by status">>}, + #{name => <<"assigned_to">>, in => <<"query">>, schema => #{type => string}, description => <<"Filter by assigned admin ID">>}, + #{name => <<"q">>, in => <<"query">>, schema => #{type => string}, description => <<"Search in error message">>}, + #{name => <<"limit">>, in => <<"query">>, schema => #{type => integer}, description => <<"Page size">>}, + #{name => <<"offset">>, in => <<"query">>, schema => #{type => integer}, description => <<"Offset">>} + ], + responses => #{ + 200 => #{ + description => <<"Array of tickets">>, + content => #{<<"application/json">> => #{schema => #{ + type => array, + items => ticket_schema() + }}} + } + } + } + ]. -%% ── Список тикетов ────────────────────────────────────── -list_tickets(Req) -> - case auth_admin(Req) of - {ok, _AdminId, Req1} -> - Tickets = core_ticket:list_all(), - send_json(Req1, 200, [ticket_to_json(T) || T <- Tickets]); - {error, Code, Message, Req1} -> - send_error(Req1, Code, Message) - end. - -%% ── Создание тикета ────────────────────────────────────── -create_ticket(Req) -> - case auth_admin(Req) of - {ok, AdminId, Req1} -> - {ok, Body, Req2} = cowboy_req:read_body(Req1), - try - Decoded = jsx:decode(Body, [return_maps]), - case Decoded of - #{<<"error_message">> := ErrorMsg} when byte_size(ErrorMsg) > 0 -> - TicketData = maps:merge(#{ - <<"reporter_id">> => AdminId, - <<"status">> => <<"open">> - }, maps:without([<<"id">>], Decoded)), - case core_ticket:create_ticket(TicketData) of - {ok, Ticket} -> - log_audit(AdminId, <<"create_ticket">>, <<"ticket">>, Ticket#ticket.id, <<"">>), - send_json(Req2, 201, ticket_to_json(Ticket)); - {error, Reason} -> - send_error(Req2, 500, Reason) - end; - _ -> - send_error(Req2, 400, <<"Missing or empty 'error_message'">>) - end - catch - _:_ -> send_error(Req2, 400, <<"Invalid JSON">>) - end; - {error, Code, Message, Req1} -> - send_error(Req1, Code, Message) - end. - -%% ── Получение тикета по ID ───────────────────────────── -get_ticket(TicketId, Req) -> - case auth_admin(Req) of - {ok, _AdminId, Req1} -> - case core_ticket:get_by_id(TicketId) of - {ok, Ticket} -> - send_json(Req1, 200, ticket_to_json(Ticket)); - {error, not_found} -> - send_error(Req1, 404, <<"Ticket not found">>) - end; - {error, Code, Message, Req1} -> - send_error(Req1, Code, Message) - end. - -%% ── Обновление тикета ─────────────────────────────────── -update_ticket(TicketId, Req) -> - case auth_admin(Req) of - {ok, AdminId, Req1} -> - {ok, Body, Req2} = cowboy_req:read_body(Req1), - try - Updates = jsx:decode(Body, [return_maps]), - case map_size(Updates) > 0 of - true -> - case core_ticket:update_ticket(TicketId, Updates) of - {ok, Ticket} -> - Reason = maps:get(<<"reason">>, Updates, <<"">>), - log_audit(AdminId, <<"update_ticket">>, <<"ticket">>, TicketId, Reason), - send_json(Req2, 200, ticket_to_json(Ticket)); - {error, not_found} -> - send_error(Req2, 404, <<"Ticket not found">>); - {error, Reason} -> - send_error(Req2, 500, Reason) - end; - false -> - send_error(Req2, 400, <<"Request body is empty">>) - end - catch - _:_ -> send_error(Req2, 400, <<"Invalid JSON">>) - end; - {error, Code, Message, Req1} -> - send_error(Req1, Code, Message) - end. - -%% ── Удаление тикета ───────────────────────────────────── -delete_ticket(TicketId, Req) -> - case auth_admin(Req) of - {ok, AdminId, Req1} -> - case core_ticket:delete_ticket(TicketId) of - {ok, deleted} -> - log_audit(AdminId, <<"delete_ticket">>, <<"ticket">>, TicketId, <<"">>), - send_json(Req1, 200, #{status => <<"deleted">>}); - {error, not_found} -> - send_error(Req1, 404, <<"Ticket not found">>) - end; - {error, Code, Message, Req1} -> - send_error(Req1, Code, Message) - end. - -%% ── Аудит ────────────────────────────────────────────── -log_audit(AdminId, Action, EntityType, EntityId, Reason) -> - case core_admin:get_by_id(AdminId) of - {ok, Admin} -> - core_admin_audit:log(AdminId, Admin#admin.email, Admin#admin.role, - Action, EntityType, EntityId, <<"127.0.0.1">>, Reason); - _ -> ok - end. - -%% ── Аутентификация ────────────────────────────────────── -auth_admin(Req) -> - case handler_auth:authenticate(Req) of - {ok, AdminId, Req1} -> - case admin_utils:is_admin(AdminId) of - true -> {ok, AdminId, Req1}; - false -> {error, 403, <<"Admin access required">>, Req1} - end; - {error, Code, Message, Req1} -> - {error, Code, Message, Req1} - end. - -%% ── Сериализация ──────────────────────────────────────── -ticket_to_json(T) -> +ticket_schema() -> #{ - id => T#ticket.id, - reporter_id => T#ticket.reporter_id, - error_hash => T#ticket.error_hash, - error_message => T#ticket.error_message, - stacktrace => T#ticket.stacktrace, - context => T#ticket.context, - count => T#ticket.count, - first_seen => datetime_to_iso8601(T#ticket.first_seen), - last_seen => datetime_to_iso8601(T#ticket.last_seen), - status => T#ticket.status, - assigned_to => T#ticket.assigned_to, - resolution_note => T#ticket.resolution_note + type => object, + properties => #{ + id => #{type => string}, + reporter_id => #{type => string}, + error_hash => #{type => string}, + error_message => #{type => string}, + stacktrace => #{type => string}, + context => #{type => string}, + count => #{type => integer}, + first_seen => #{type => string, format => <<"date-time">>}, + last_seen => #{type => string, format => <<"date-time">>}, + status => #{type => string, enum => [<<"open">>, <<"in_progress">>, <<"resolved">>, <<"closed">>]}, + assigned_to => #{type => string, nullable => true}, + resolution_note => #{type => string, nullable => true} + } }. -datetime_to_iso8601({{Year, Month, Day}, {Hour, Minute, Second}}) -> - iolist_to_binary(io_lib:format("~4..0B-~2..0B-~2..0BT~2..0B:~2..0B:~2..0BZ", - [Year, Month, Day, Hour, Minute, Second])); -datetime_to_iso8601(undefined) -> undefined. +%%% Internal functions -%% ── HTTP-ответы ───────────────────────────────────────── -send_json(Req, Status, Data) -> - Headers = #{ - <<"content-type">> => <<"application/json">>, - <<"access-control-allow-origin">> => <<"*">>, - <<"access-control-expose-headers">> => <<"Content-Range">> - }, - Body = jsx:encode(Data), - cowboy_req:reply(Status, Headers, Body, Req), - {ok, Body, []}. +%% @doc Получить список тикетов с пагинацией и фильтрацией. +-spec list_tickets(cowboy_req:req()) -> {ok, binary(), cowboy_req:req()}. +list_tickets(Req) -> + case handler_utils:auth_admin(Req) of + {ok, AdminId, Req1} -> + Filters = parse_ticket_filters(Req1), + Pagination = handler_utils:parse_pagination_params(Req1), + TicketsResult = case maps:get(status, Filters, undefined) of + undefined -> logic_ticket:list_tickets(AdminId); + Status -> logic_ticket:list_tickets_by_status(AdminId, Status) + end, + case TicketsResult of + Tickets when is_list(Tickets) -> + Filtered = apply_ticket_filters(Tickets, Filters), + Sorted = sort_tickets(Filtered, Pagination), + Total = length(Sorted), + Page = lists:sublist(Sorted, maps:get(offset, Pagination) + 1, maps:get(limit, Pagination)), + Json = [handler_utils:ticket_to_json(T) || T <- Page], + ExtraHeaders = pagination_headers(Pagination, Total), + handler_utils:send_json(Req1, 200, Json, ExtraHeaders); + {error, access_denied} -> + handler_utils:send_error(Req1, 403, <<"Admin access required">>) + end; + {error, Code, Msg, Req1} -> + handler_utils:send_error(Req1, Code, Msg) + end. -send_error(Req, Code, Message) -> - Headers = #{ - <<"content-type">> => <<"application/json">>, - <<"access-control-allow-origin">> => <<"*">>, - <<"access-control-expose-headers">> => <<"Content-Range">> - }, - Body = jsx:encode(#{error => Message}), - cowboy_req:reply(Code, Headers, Body, Req), - {ok, Body, []}. \ No newline at end of file +%% @private Извлечь фильтры из query string. +-spec parse_ticket_filters(cowboy_req:req()) -> map(). +parse_ticket_filters(Req) -> + Qs = cowboy_req:parse_qs(Req), + #{ + status => proplists:get_value(<<"status">>, Qs), + assigned_to => proplists:get_value(<<"assigned_to">>, Qs), + q => proplists:get_value(<<"q">>, Qs) + }. + +%% @private Дополнительная фильтрация (assigned_to, q). +-spec apply_ticket_filters([#ticket{}], map()) -> [#ticket{}]. +apply_ticket_filters(Tickets, Filters) -> + Assigned = maps:get(assigned_to, Filters, undefined), + Q = maps:get(q, Filters, undefined), + F1 = case Assigned of + undefined -> Tickets; + _ -> [T || T <- Tickets, T#ticket.assigned_to =:= Assigned] + end, + case Q of + undefined -> F1; + _ -> [T || T <- F1, + string:str(binary_to_list(T#ticket.error_message), binary_to_list(Q)) > 0] + end. + +%% @private Отсортировать тикеты согласно параметрам. +-spec sort_tickets([#ticket{}], map()) -> [#ticket{}]. +sort_tickets(Tickets, #{sort := Sort, order := Order}) -> + Field = binary_to_existing_atom(Sort, utf8), + Sorted = lists:sort( + fun(A, B) -> + ValA = ticket_field(A, Field), + ValB = ticket_field(B, Field), + if Order == <<"asc">> -> ValA =< ValB; + true -> ValA >= ValB + end + end, Tickets), + Sorted. + +ticket_field(#ticket{first_seen = V}, first_seen) -> V; +ticket_field(#ticket{last_seen = V}, last_seen) -> V; +ticket_field(#ticket{status = V}, status) -> V; +ticket_field(_, _) -> undefined. + +%% @private Сформировать заголовки пагинации. +-spec pagination_headers(map(), non_neg_integer()) -> map(). +pagination_headers(#{limit := Limit, offset := Offset}, Total) -> + RangeEnd = min(Offset + Limit - 1, Total - 1), + #{ + <<"content-range">> => iolist_to_binary(io_lib:format("items ~B-~B/~B", [Offset, RangeEnd, Total])), + <<"x-total-count">> => integer_to_binary(Total), + <<"access-control-expose-headers">> => <<"Content-Range, X-Total-Count">> + }. \ No newline at end of file diff --git a/src/handlers/admin/admin_handler_user_by_id.erl b/src/handlers/admin/admin_handler_user_by_id.erl index 73f63ff..3379f8d 100644 --- a/src/handlers/admin/admin_handler_user_by_id.erl +++ b/src/handlers/admin/admin_handler_user_by_id.erl @@ -1,152 +1,151 @@ -module(admin_handler_user_by_id). --include("records.hrl"). +-behaviour(cowboy_handler). + -export([init/2]). +-export([trails/0]). + +-include("records.hrl"). init(Req, _Opts) -> case cowboy_req:method(Req) of <<"GET">> -> get_user(Req); <<"PUT">> -> update_user(Req); <<"DELETE">> -> delete_user(Req); - _ -> send_error(Req, 405, <<"Method not allowed">>) + _ -> handler_utils:send_error(Req, 405, <<"Method not allowed">>) end. +trails() -> + BaseParams = [#{name => <<"id">>, in => <<"path">>, required => true, schema => #{type => string}}], + [ + #{ % GET + path => <<"/v1/admin/users/:id">>, + method => <<"GET">>, + description => <<"Get user by ID (admin)">>, + tags => [<<"Users">>], + parameters => BaseParams, + responses => #{ + 200 => #{ + description => <<"User details">>, + content => #{<<"application/json">> => #{schema => user_schema()}} + } + } + }, + #{ % PUT + path => <<"/v1/admin/users/:id">>, + method => <<"PUT">>, + description => <<"Update user (admin)">>, + tags => [<<"Users">>], + parameters => BaseParams, + requestBody => #{ + required => true, + content => #{<<"application/json">> => #{schema => user_update_schema()}} + }, + responses => #{ + 200 => #{description => <<"Updated user">>} + } + }, + #{ % DELETE + path => <<"/v1/admin/users/:id">>, + method => <<"DELETE">>, + description => <<"Soft-delete user (admin)">>, + tags => [<<"Users">>], + parameters => BaseParams, + responses => #{ + 200 => #{description => <<"User status set to deleted">>} + } + } + ]. + +user_schema() -> + #{ + type => object, + properties => #{ + id => #{type => string}, + email => #{type => string}, + role => #{type => string}, + status => #{type => string}, + reason => #{type => string, nullable => true}, + nickname => #{type => string, nullable => true}, + avatar_url => #{type => string, nullable => true}, + timezone => #{type => string, nullable => true}, + language => #{type => string, nullable => true}, + social_links => #{type => array, items => #{type => string}}, + phone => #{type => string, nullable => true}, + preferences => #{type => object, nullable => true}, + last_login => #{type => string, format => <<"date-time">>}, + created_at => #{type => string, format => <<"date-time">>}, + updated_at => #{type => string, format => <<"date-time">>} + } + }. + +user_update_schema() -> + #{ + type => object, + properties => #{ + role => #{type => string, enum => [<<"user">>, <<"bot">>]}, + status => #{type => string, enum => [<<"active">>, <<"frozen">>, <<"deleted">>]}, + reason => #{type => string}, + nickname => #{type => string}, + timezone => #{type => string}, + language => #{type => string}, + phone => #{type => string}, + preferences => #{type => object} + } + }. + get_user(Req) -> - case handler_auth:authenticate(Req) of - {ok, AdminId, Req1} -> - case admin_utils:is_admin(AdminId) of - true -> - UserId = cowboy_req:binding(id, Req1), - case core_user:get_by_id(UserId) of - {ok, User} -> - send_json(Req1, 200, user_to_json(User)); - {error, not_found} -> - send_error(Req1, 404, <<"User not found">>) - end; - false -> - send_error(Req1, 403, <<"Admin access required">>) + case handler_utils:auth_admin(Req) of + {ok, _AdminId, Req1} -> + UserId = cowboy_req:binding(id, Req1), + case logic_user:get_user_admin(UserId) of + {ok, User} -> + handler_utils:send_json(Req1, 200, handler_utils:user_to_json(User)); + {error, not_found} -> + handler_utils:send_error(Req1, 404, <<"User not found">>); + {error, _} -> + handler_utils:send_error(Req1, 500, <<"Internal server error">>) end; - {error, Code, Message, Req1} -> - send_error(Req1, Code, Message) + {error, Code, Msg, Req1} -> + handler_utils:send_error(Req1, Code, Msg) end. update_user(Req) -> - case handler_auth:authenticate(Req) of - {ok, AdminId, Req1} -> - case admin_utils:is_admin(AdminId) of - true -> - UserId = cowboy_req:binding(id, Req1), - {ok, Body, Req2} = cowboy_req:read_body(Req1), - try jsx:decode(Body, [return_maps]) of - Updates when map_size(Updates) > 0 -> - % Проверка на наличие reason при изменении статуса - case maps:find(<<"status">>, Updates) of - {ok, NewStatus} when NewStatus =:= <<"blocked">> orelse NewStatus =:= <<"active">> -> - case maps:find(<<"reason">>, Updates) of - {ok, Reason} when byte_size(Reason) > 0 -> - apply_updates(UserId, Updates, AdminId, Reason, Req2); - _ -> - send_error(Req2, 400, <<"Missing or empty reason">>) - end; - _ -> - apply_updates(UserId, Updates, AdminId, undefined, Req2) - end; - _ -> - send_error(Req2, 400, <<"Request body is empty">>) - catch - _:_ -> send_error(Req2, 400, <<"Invalid JSON">>) + case handler_utils:auth_admin(Req) of + {ok, _AdminId, Req1} -> + UserId = cowboy_req:binding(id, Req1), + {ok, Body, Req2} = cowboy_req:read_body(Req1), + try jsx:decode(Body, [return_maps]) of + UpdatesMap when is_map(UpdatesMap) -> + Updates = maps:to_list(UpdatesMap), + case logic_user:update_user_admin(UserId, Updates) of + {ok, User} -> + handler_utils:send_json(Req2, 200, handler_utils:user_to_json(User)); + {error, not_found} -> + handler_utils:send_error(Req2, 404, <<"User not found">>); + {error, _} -> + handler_utils:send_error(Req2, 500, <<"Internal server error">>) end; - false -> - send_error(Req1, 403, <<"Admin access required">>) + _ -> + handler_utils:send_error(Req2, 400, <<"Invalid JSON">>) + catch + _:_ -> handler_utils:send_error(Req1, 400, <<"Invalid JSON format">>) end; - {error, Code, Message, Req1} -> - send_error(Req1, Code, Message) + {error, Code, Msg, Req1} -> + handler_utils:send_error(Req1, Code, Msg) end. delete_user(Req) -> - case handler_auth:authenticate(Req) of - {ok, AdminId, Req1} -> - case admin_utils:is_admin(AdminId) of - true -> - UserId = cowboy_req:binding(id, Req1), - case core_user:delete(UserId) of - {ok, _} -> - send_json(Req1, 200, #{status => <<"deleted">>}); - {error, not_found} -> - send_error(Req1, 404, <<"User not found">>) - end; - false -> - send_error(Req1, 403, <<"Admin access required">>) + case handler_utils:auth_admin(Req) of + {ok, _AdminId, Req1} -> + UserId = cowboy_req:binding(id, Req1), + case logic_user:delete_user_admin(UserId) of + {ok, _} -> + handler_utils:send_json(Req1, 200, #{status => <<"deleted">>}); + {error, not_found} -> + handler_utils:send_error(Req1, 404, <<"User not found">>); + {error, _} -> + handler_utils:send_error(Req1, 500, <<"Internal server error">>) end; - {error, Code, Message, Req1} -> - send_error(Req1, Code, Message) - end. - -%% ── Вспомогательная функция обновления ──────────────────── -apply_updates(UserId, Updates, AdminId, Reason, Req) -> - Converted = convert_updates(maps:to_list(Updates)), - case core_user:update(UserId, Converted) of - {ok, User} -> - % Логируем, если был указан reason - case Reason of - undefined -> ok; - _ -> - case core_admin:get_by_id(AdminId) of - {ok, Admin} -> - Action = case maps:get(<<"status">>, Updates, undefined) of - <<"blocked">> -> <<"block_user">>; - <<"active">> -> <<"unblock_user">>; - _ -> <<"update_user">> - end, - core_admin_audit:log(AdminId, Admin#admin.email, Admin#admin.role, - Action, <<"user">>, UserId, <<"127.0.0.1">>, Reason); - _ -> ok - end - end, - send_json(Req, 200, user_to_json(User)); - {error, not_found} -> - send_error(Req, 404, <<"User not found">>); - {error, _} -> - send_error(Req, 500, <<"Internal server error">>) - end. - -convert_updates(Updates) -> - lists:map(fun({<<"status">>, Value}) -> {status, binary_to_existing_atom(Value, utf8)}; - ({<<"role">>, Value}) -> {role, binary_to_existing_atom(Value, utf8)}; - ({<<"reason">>, Value}) -> {reason, Value}; - (Other) -> Other - end, Updates). - -user_to_json(User) -> - #{ - id => User#user.id, - email => User#user.email, - role => atom_to_binary(User#user.role, utf8), - status => atom_to_binary(User#user.status, utf8), - reason => User#user.reason, - created_at => datetime_to_iso8601(User#user.created_at), - updated_at => datetime_to_iso8601(User#user.updated_at) - }. - -datetime_to_iso8601({{Y,M,D},{H,Min,S}}) -> - iolist_to_binary(io_lib:format("~4..0B-~2..0B-~2..0BT~2..0B:~2..0B:~2..0BZ", [Y,M,D,H,Min,S])); -datetime_to_iso8601(_) -> null. - -send_json(Req, Status, Data) -> - Headers = #{ - <<"content-type">> => <<"application/json">>, - <<"access-control-allow-origin">> => <<"*">>, - <<"access-control-expose-headers">> => <<"Content-Range">> - }, - Body = jsx:encode(Data), - cowboy_req:reply(Status, Headers, Body, Req), - {ok, Body, []}. - -send_error(Req, Code, Message) -> - Headers = #{ - <<"content-type">> => <<"application/json">>, - <<"access-control-allow-origin">> => <<"*">>, - <<"access-control-expose-headers">> => <<"Content-Range">> - }, - Body = jsx:encode(#{error => Message}), - cowboy_req:reply(Code, Headers, Body, Req), - {ok, Body, []}. \ No newline at end of file + {error, Code, Msg, Req1} -> + handler_utils:send_error(Req1, Code, Msg) + end. \ No newline at end of file diff --git a/src/handlers/admin/admin_handler_users.erl b/src/handlers/admin/admin_handler_users.erl index d23a7ab..fd99c36 100644 --- a/src/handlers/admin/admin_handler_users.erl +++ b/src/handlers/admin/admin_handler_users.erl @@ -1,61 +1,90 @@ -module(admin_handler_users). --include("records.hrl"). +-behaviour(cowboy_handler). + -export([init/2]). +-export([trails/0]). + +-include("records.hrl"). init(Req, _Opts) -> case cowboy_req:method(Req) of <<"GET">> -> list_users(Req); - _ -> send_error(Req, 405, <<"Method not allowed">>) + _ -> handler_utils:send_error(Req, 405, <<"Method not allowed">>) end. -list_users(Req) -> - case handler_auth:authenticate(Req) of - {ok, AdminId, Req1} -> - case admin_utils:is_admin(AdminId) of - true -> - {ok, Users} = core_user:list_users(), - send_json(Req1, 200, [user_to_map(U) || U <- Users]); - false -> - send_error(Req1, 403, <<"Admin access required">>) - end; - {error, Code, Message, Req1} -> - send_error(Req1, Code, Message) - end. +trails() -> + [ + #{ + path => <<"/v1/admin/users">>, + method => <<"GET">>, + description => <<"List all users (admin)">>, + tags => [<<"Users">>], + parameters => [ + #{name => <<"role">>, in => <<"query">>, schema => #{type => string, enum => [<<"user">>, <<"bot">>]}}, + #{name => <<"status">>, in => <<"query">>, schema => #{type => string, enum => [<<"active">>, <<"frozen">>, <<"deleted">>]}}, + #{name => <<"q">>, in => <<"query">>, schema => #{type => string}, description => <<"Search by email or nickname">>}, + #{name => <<"limit">>, in => <<"query">>, schema => #{type => integer}}, + #{name => <<"offset">>, in => <<"query">>, schema => #{type => integer}} + ], + responses => #{ + 200 => #{ + description => <<"Array of users">>, + content => #{<<"application/json">> => #{schema => #{ + type => array, + items => user_schema() + }}} + } + } + } + ]. -user_to_map(User) when is_map(User) -> +user_schema() -> #{ - id => maps:get(id, User), - email => maps:get(email, User), - role => maps:get(role, User, <<"user">>), - status => maps:get(status, User, <<"active">>), - created_at => datetime_to_iso8601(maps:get(created_at, User)), - updated_at => datetime_to_iso8601(maps:get(updated_at, User)) - }; -user_to_map(User) -> - #{ - id => User#user.id, - email => User#user.email, - role => atom_to_binary(User#user.role, utf8), - status => atom_to_binary(User#user.status, utf8), - created_at => datetime_to_iso8601(User#user.created_at), - updated_at => datetime_to_iso8601(User#user.updated_at) + type => object, + properties => #{ + id => #{type => string}, + email => #{type => string, format => <<"email">>}, + role => #{type => string, enum => [<<"user">>, <<"bot">>]}, + status => #{type => string, enum => [<<"active">>, <<"frozen">>, <<"deleted">>]}, + reason => #{type => string, nullable => true}, + nickname => #{type => string, nullable => true}, + avatar_url => #{type => string, nullable => true}, + timezone => #{type => string, nullable => true}, + language => #{type => string, nullable => true}, + social_links => #{type => array, items => #{type => string}, nullable => true}, + phone => #{type => string, nullable => true}, + preferences => #{type => object, nullable => true}, + last_login => #{type => string, format => <<"date-time">>}, + created_at => #{type => string, format => <<"date-time">>}, + updated_at => #{type => string, format => <<"date-time">>} + } }. -datetime_to_iso8601({{Y,M,D},{H,Min,S}}) -> - iolist_to_binary(io_lib:format("~4..0B-~2..0B-~2..0BT~2..0B:~2..0B:~2..0BZ", [Y,M,D,H,Min,S])); -datetime_to_iso8601(_) -> null. +list_users(Req) -> + case handler_utils:auth_admin(Req) of + {ok, _AdminId, Req1} -> + Filters = parse_user_filters(Req1), + Pagination = handler_utils:parse_pagination_params(Req1), + {ok, Total, Users} = logic_user:list_users_admin(Filters, Pagination), + Json = [handler_utils:user_to_json(U) || U <- Users], + ExtraHeaders = pagination_headers(Pagination, Total), + handler_utils:send_json(Req1, 200, Json, ExtraHeaders); + {error, Code, Msg, Req1} -> + handler_utils:send_error(Req1, Code, Msg) + end. -send_json(Req, Status, Data) -> - Headers = #{ - <<"content-type">> => <<"application/json">>, - <<"access-control-allow-origin">> => <<"*">>, - <<"access-control-expose-headers">> => <<"Content-Range">> - }, - Body = jsx:encode(Data), - cowboy_req:reply(Status, Headers, Body, Req), - {ok, Body, []}. +parse_user_filters(Req) -> + Qs = cowboy_req:parse_qs(Req), + #{ + role => proplists:get_value(<<"role">>, Qs), + status => proplists:get_value(<<"status">>, Qs), + q => proplists:get_value(<<"q">>, Qs) + }. -send_error(Req, Status, Message) -> - Body = jsx:encode(#{error => Message}), - cowboy_req:reply(Status, #{<<"content-type">> => <<"application/json">>}, Body, Req), - {ok, Body, []}. \ No newline at end of file +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">> + }. \ No newline at end of file diff --git a/src/handlers/admin/admin_ws_handler.erl b/src/handlers/admin/admin_ws_handler.erl index 9b36d81..4c3b3fa 100644 --- a/src/handlers/admin/admin_ws_handler.erl +++ b/src/handlers/admin/admin_ws_handler.erl @@ -1,5 +1,12 @@ +%%%------------------------------------------------------------------- +%%% @doc Административный WebSocket-обработчик. +%%% Устанавливает WebSocket-соединение после проверки JWT-токена +%%% и подписывает администратора на каналы уведомлений. +%%% @end +%%%------------------------------------------------------------------- -module(admin_ws_handler). -behaviour(cowboy_websocket). + -export([init/2]). -export([websocket_init/1]). -export([websocket_handle/2]). @@ -10,6 +17,11 @@ admin_id :: binary() | undefined }). +%%% cowboy_websocket callback + +%% @doc Инициализирует соединение, проверяет токен из query-строки. +-spec init(cowboy_req:req(), any()) -> + {ok, cowboy_req:req(), #state{}} | {cowboy_websocket, cowboy_req:req(), #state{}}. init(Req, _Opts) -> Qs = cowboy_req:parse_qs(Req), case proplists:get_value(<<"token">>, Qs) of @@ -42,11 +54,15 @@ init(Req, _Opts) -> end end. +%% @doc Вызывается после установки WebSocket-соединения. +-spec websocket_init(#state{}) -> {ok, #state{}}. websocket_init(State) -> io:format("[ADMIN_WS] WebSocket initialized for admin ~s~n", [State#state.admin_id]), pg:join(eventhub_admin_ws, self()), {ok, State}. +%% @doc Обрабатывает входящие текстовые сообщения (subscribe/unsubscribe/ping). +-spec websocket_handle(term(), #state{}) -> {ok, #state{}} | {reply, {text, binary()}, #state{}}. websocket_handle({text, Msg}, State) -> io:format("[ADMIN_WS] Received: ~s~n", [Msg]), try jsx:decode(Msg, [return_maps]) of @@ -63,22 +79,25 @@ websocket_handle({text, Msg}, State) -> _ -> {ok, State} catch - _:_ -> - {ok, State} + _:_ -> {ok, State} end; websocket_handle(_Frame, State) -> {ok, State}. +%% @doc Отправляет административное уведомление через WebSocket. +-spec websocket_info(term(), #state{}) -> {reply, {text, binary()}, #state{}} | {ok, #state{}}. websocket_info({admin_notification, Type, Data}, State) -> Msg = jsx:encode(#{ - type => Type, - data => Data, + type => Type, + data => Data, timestamp => os:system_time(seconds) }), {reply, {text, Msg}, State}; websocket_info(_Info, State) -> {ok, State}. +%% @private Вызывается при закрытии соединения. +-spec terminate(term(), cowboy_req:req(), #state{}) -> ok. terminate(_Reason, _Req, _State) -> pg:leave(eventhub_admin_ws, self()), ok. \ No newline at end of file diff --git a/src/handlers/handler_banned_words.erl b/src/handlers/handler_banned_words.erl deleted file mode 100644 index 71ac8af..0000000 --- a/src/handlers/handler_banned_words.erl +++ /dev/null @@ -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, []}. \ No newline at end of file diff --git a/src/handlers/handler_report_by_id.erl b/src/handlers/handler_report_by_id.erl deleted file mode 100644 index 6e1597e..0000000 --- a/src/handlers/handler_report_by_id.erl +++ /dev/null @@ -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, []}. \ No newline at end of file diff --git a/src/handlers/handler_ticket_stats.erl b/src/handlers/handler_ticket_stats.erl deleted file mode 100644 index 2dcbffb..0000000 --- a/src/handlers/handler_ticket_stats.erl +++ /dev/null @@ -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, []}. \ No newline at end of file diff --git a/src/handlers/handler_utils.erl b/src/handlers/handler_utils.erl new file mode 100644 index 0000000..5e0e53c --- /dev/null +++ b/src/handlers/handler_utils.erl @@ -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 => <>, + 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 => <>, + method => <<"PUT">>, + description => <<"Update record by ID">>, + parameters => [IdParam], + requestBody => #{ + required => true, + content => #{<<"application/json">> => #{schema => UpdateSchema}} + }, + responses => #{ + 200 => #{description => <<"Record updated">>} + } + }, + #{ % DELETE + path => <>, + method => <<"DELETE">>, + description => <<"Delete record by ID">>, + parameters => [IdParam], + responses => #{ + 200 => #{description => <<"Record deleted">>} + } + } + ]. \ No newline at end of file diff --git a/src/infra/infra_utils.erl b/src/infra/infra_utils.erl new file mode 100644 index 0000000..8cf1de4 --- /dev/null +++ b/src/infra/infra_utils.erl @@ -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]). \ No newline at end of file diff --git a/src/logic/logic_admin.erl b/src/logic/logic_admin.erl new file mode 100644 index 0000000..137aa05 --- /dev/null +++ b/src/logic/logic_admin.erl @@ -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. \ No newline at end of file diff --git a/src/logic/logic_report.erl b/src/logic/logic_report.erl new file mode 100644 index 0000000..16d1158 --- /dev/null +++ b/src/logic/logic_report.erl @@ -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. \ No newline at end of file diff --git a/src/logic/logic_review.erl b/src/logic/logic_review.erl index b596147..4d59f8c 100644 --- a/src/logic/logic_review.erl +++ b/src/logic/logic_review.erl @@ -5,6 +5,7 @@ update_review/3, delete_review/2, hide_review/2, hide_review/3, unhide_review/2, unhide_review/3]). -export([can_review/3, update_target_rating/2, can_moderate_review/2]). -export([list_admin_reviews/1, bulk_update_status/1]). +-export([list_admin_reviews/2, get_review_admin/1, update_review_admin/2]). %% Создание отзыва create_review(UserId, TargetType, TargetId, Rating, Comment) -> @@ -200,8 +201,9 @@ can_moderate_review(UserId, ReviewId) -> %%% @end %%%------------------------------------------------------------------- list_admin_reviews(Filters) -> - AllReviews = core_review:list_all(), - apply_filters(AllReviews, Filters). + Reviews = core_review:list_all(), % возвращает список + Filtered = apply_filters(Reviews, Filters), + {ok, Filtered}. %% Вспомогательная функция: фильтрация списка по proplist apply_filters(Reviews, []) -> @@ -271,4 +273,53 @@ update_target_rating(event, EventId) -> update_target_rating(calendar, CalendarId) -> {Avg, Count} = core_review:get_average_rating(calendar, CalendarId), core_calendar:update(CalendarId, [{rating_avg, Avg}, {rating_count, Count}]); -update_target_rating(_, _) -> ok. \ No newline at end of file +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. \ No newline at end of file diff --git a/src/logic/logic_ticket.erl b/src/logic/logic_ticket.erl index 82d4076..d21dfe7 100644 --- a/src/logic/logic_ticket.erl +++ b/src/logic/logic_ticket.erl @@ -10,6 +10,7 @@ resolve_ticket/3, close_ticket/2, get_statistics/1]). +-export([delete_ticket/2]). %% Зарегистрировать ошибку (создать или обновить тикет) report_error(ErrorMessage, Stacktrace, Context) -> @@ -93,6 +94,13 @@ close_ticket(AdminId, TicketId) -> false -> {error, access_denied} end. +%% Удалить тикет (только для админов) +delete_ticket(AdminId, TicketId) -> + case admin_utils:is_admin(AdminId) of + true -> core_ticket:delete_ticket(TicketId); + false -> {error, access_denied} + end. + %% Получить статистику по тикетам get_statistics(AdminId) -> case admin_utils:is_admin(AdminId) of diff --git a/src/logic/logic_user.erl b/src/logic/logic_user.erl new file mode 100644 index 0000000..0a8884b --- /dev/null +++ b/src/logic/logic_user.erl @@ -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. \ No newline at end of file diff --git a/src/swagger/trails.erl b/src/swagger/trails.erl index 3de761c..6696b6a 100644 --- a/src/swagger/trails.erl +++ b/src/swagger/trails.erl @@ -3,9 +3,37 @@ admin() -> Modules = [ + % ================== БАЗОВЫЕ ================== + admin_handler_health, + admin_handler_stats, + admin_handler_login, + % ================== ПОЛЬЗОВАТЕЛИ ================== + admin_handler_users, + admin_handler_user_by_id, + % ================== СОБЫТИЯ ================== admin_handler_events, - admin_handler_event_by_id - %% другие админские обработчики с trails/0 + admin_handler_event_by_id, + % ================== ОТЧЁТЫ ================== + admin_handler_reports, + admin_handler_report_by_id, + % ================== ОТЗЫВЫ ================== + admin_handler_reviews, + admin_handler_reviews_by_id, + % ================== БАН-СЛОВА ================== + admin_handler_banned_words, + % ================== ТИКЕТЫ ================== + admin_handler_ticket_stats, + admin_handler_ticket_by_id, + admin_handler_tickets, + % ================== ПОДПИСКИ ================== + admin_handler_subscriptions, + admin_handler_subscriptions_by_id, + % ================== МОДЕРАЦИЯ (общий маршрут) ================== + admin_handler_moderation, + % ================== Управление ролями (только для superadmin) ================== + admin_handler_me, + admin_handler_admins, + admin_handler_audit ], lists:flatmap(fun trails_from_module/1, Modules). diff --git a/test/api/api_admin_tests.erl b/test/api/api_admin_tests.erl index 99fcd13..71f415f 100644 --- a/test/api/api_admin_tests.erl +++ b/test/api/api_admin_tests.erl @@ -131,7 +131,7 @@ test() -> <<"error_message">> => <<"Test error">>, <<"stacktrace">> => <<"trace">> }), - {ok, {{_, 201, _}, _, TicketResp}} = httpc:request(post, {AdminURL ++ "/v1/admin/tickets", [{"Authorization", "Bearer " ++ binary_to_list(AdminToken)}], "application/json", TicketBody}, [], []), + {ok, {{_, 201, _}, _, TicketResp}} = httpc:request(post, {UserURL ++ "/v1/tickets", [{"Authorization", "Bearer " ++ binary_to_list(UserToken)}], "application/json", TicketBody}, [], []), #{<<"id">> := TicketId} = jsx:decode(list_to_binary(TicketResp), [return_maps]), ct:pal(" OK (TicketId: ~p)~n", [TicketId]), ct:pal("OK~n"), @@ -164,11 +164,8 @@ test() -> %% TEST 18: Create subscription ct:pal(" TEST 18: Create subscription... "), - SubBody = jsx:encode(#{ - <<"user_id">> => UserId, - <<"plan">> => <<"monthly">> - }), - {ok, {{_, 201, _}, _, SubResp}} = httpc:request(post, {AdminURL ++ "/v1/admin/subscriptions", [{"Authorization", "Bearer " ++ binary_to_list(AdminToken)}], "application/json", SubBody}, [], []), + SubBody = jsx:encode(#{action => <<"activate">>, plan => <<"monthly">>, payment_info => #{card => <<"4242">>}}), + {ok, {{_, 201, _}, _, SubResp}} = httpc:request(post, {UserURL ++ "/v1/subscription", [{"Authorization", "Bearer " ++ binary_to_list(UserToken)}], "application/json", SubBody}, [], []), #{<<"id">> := SubId} = jsx:decode(list_to_binary(SubResp), [return_maps]), ct:pal("OK~n"),