diff --git a/src/core/core_calendar.erl b/src/core/core_calendar.erl index 382dd69..96086c3 100644 --- a/src/core/core_calendar.erl +++ b/src/core/core_calendar.erl @@ -4,6 +4,9 @@ -export([count_calendars/0, list_all/0]). -export([freeze/2, unfreeze/2]). -export([count_calendars_by_date/2]). +-export([count_calendars_by_type/0, count_calendars_by_status/0, + get_top_calendars_by_reviews/1, get_top_calendars_by_positive_reviews/1, + get_top_calendars_by_negative_reviews/1, get_top_calendars_by_rating/1]). %% ───────────────────────────────────────────────────────────────── %% Значения по умолчанию для необязательных полей @@ -174,6 +177,92 @@ count_calendars_by_date(From, To) -> date_part({{Y,M,D}, _}) -> {Y,M,D}. +%%%------------------------------------------------------------------- +%%% @doc Подсчёт календарей по типам (personal, commercial). +%%% @end +%%%------------------------------------------------------------------- +-spec count_calendars_by_type() -> [{atom(), non_neg_integer()}]. +count_calendars_by_type() -> + Calendars = mnesia:dirty_match_object(#calendar{_ = '_'}), + lists:foldl(fun(#calendar{type = Type}, Acc) -> + case lists:keyfind(Type, 1, Acc) of + false -> [{Type, 1} | Acc]; + {Type, C} -> lists:keyreplace(Type, 1, Acc, {Type, C+1}) + end + end, [], Calendars). + +%%%------------------------------------------------------------------- +%%% @doc Подсчёт календарей по статусам (active, frozen, deleted). +%%% @end +%%%------------------------------------------------------------------- +-spec count_calendars_by_status() -> [{atom(), non_neg_integer()}]. +count_calendars_by_status() -> + Calendars = mnesia:dirty_match_object(#calendar{_ = '_'}), + lists:foldl(fun(#calendar{status = Status}, Acc) -> + case lists:keyfind(Status, 1, Acc) of + false -> [{Status, 1} | Acc]; + {Status, C} -> lists:keyreplace(Status, 1, Acc, {Status, C+1}) + end + end, [], Calendars). + +%%%------------------------------------------------------------------- +%%% @doc Топ-N календарей по количеству отзывов. +%%% @end +%%%------------------------------------------------------------------- +-spec get_top_calendars_by_reviews(N :: non_neg_integer()) -> [#calendar{}]. +get_top_calendars_by_reviews(N) -> + Calendars = mnesia:dirty_match_object(#calendar{_ = '_'}), + Sorted = lists:reverse(lists:sort( + fun(A, B) -> A#calendar.rating_count =< B#calendar.rating_count end, + Calendars)), + lists:sublist(Sorted, N). + +%%%------------------------------------------------------------------- +%%% @doc Топ-N календарей по количеству позитивных отзывов (rating >= 4). +%%% @end +%%%------------------------------------------------------------------- +-spec get_top_calendars_by_positive_reviews(N :: non_neg_integer()) -> [#calendar{}]. +get_top_calendars_by_positive_reviews(N) -> + top_by_review_filter(N, fun(R) -> R#review.rating >= 4 end). + +%%%------------------------------------------------------------------- +%%% @doc Топ-N календарей по количеству негативных отзывов (rating <= 2). +%%% @end +%%%------------------------------------------------------------------- +-spec get_top_calendars_by_negative_reviews(N :: non_neg_integer()) -> [#calendar{}]. +get_top_calendars_by_negative_reviews(N) -> + top_by_review_filter(N, fun(R) -> R#review.rating =< 2 end). + +%%%------------------------------------------------------------------- +%%% @doc Топ-N календарей по среднему рейтингу. +%%% @end +%%%------------------------------------------------------------------- +-spec get_top_calendars_by_rating(N :: non_neg_integer()) -> [#calendar{}]. +get_top_calendars_by_rating(N) -> + Calendars = mnesia:dirty_match_object(#calendar{_ = '_'}), + Sorted = lists:reverse(lists:sort( + fun(A, B) -> A#calendar.rating_avg =< B#calendar.rating_avg end, + Calendars)), + lists:sublist(Sorted, N). + +%% @private Вспомогательная функция для фильтрации отзывов и подсчёта по календарям. +top_by_review_filter(N, FilterFun) -> + Reviews = mnesia:dirty_match_object(#review{target_type = calendar, _ = '_'}), + Filtered = lists:filter(FilterFun, Reviews), + % Считаем количество отзывов для каждого календаря + Counts = lists:foldl(fun(#review{target_id = CalId}, Acc) -> + case lists:keyfind(CalId, 1, Acc) of + false -> [{CalId, 1} | Acc]; + {CalId, C} -> lists:keyreplace(CalId, 1, Acc, {CalId, C+1}) + end + end, [], Filtered), + % Сортируем по убыванию количества + SortedIds = [Id || {Id, _} <- lists:reverse(lists:sort(fun({_,C1},{_,C2}) -> C1 =< C2 end, Counts))], + TopIds = lists:sublist(SortedIds, N), + % Получаем объекты календарей + [Cal || Id <- TopIds, + {ok, Cal} <- [core_calendar:get_by_id(Id)]]. + %%%=================================================================== %%% ВНУТРЕННИЕ ФУНКЦИИ %%%=================================================================== diff --git a/src/core/core_event.erl b/src/core/core_event.erl index 258ed70..c44d8e7 100644 --- a/src/core/core_event.erl +++ b/src/core/core_event.erl @@ -5,6 +5,7 @@ -export([count_events/0, count_events_by_date/2]). -export([freeze/2, unfreeze/2]). -export([list_all/0]). +-export([count_events_by_type/0, count_events_by_status/0, get_top_events_by_rating/1]). %% ───────────────────────────────────────────────────────────────── %% Значения по умолчанию для необязательных полей @@ -251,6 +252,46 @@ count_events_by_date(From, To) -> end, [], Filtered), lists:sort(Counts). +%%%------------------------------------------------------------------- +%%% @doc Подсчёт событий по типам (single, recurring). +%%% @end +%%%------------------------------------------------------------------- +-spec count_events_by_type() -> [{atom(), non_neg_integer()}]. +count_events_by_type() -> + Events = mnesia:dirty_match_object(#event{_ = '_'}), + lists:foldl(fun(#event{event_type = Type}, Acc) -> + case lists:keyfind(Type, 1, Acc) of + false -> [{Type, 1} | Acc]; + {Type, C} -> lists:keyreplace(Type, 1, Acc, {Type, C+1}) + end + end, [], Events). + +%%%------------------------------------------------------------------- +%%% @doc Подсчёт событий по статусам (active, cancelled, completed). +%%% @end +%%%------------------------------------------------------------------- +-spec count_events_by_status() -> [{atom(), non_neg_integer()}]. +count_events_by_status() -> + Events = mnesia:dirty_match_object(#event{_ = '_'}), + lists:foldl(fun(#event{status = Status}, Acc) -> + case lists:keyfind(Status, 1, Acc) of + false -> [{Status, 1} | Acc]; + {Status, C} -> lists:keyreplace(Status, 1, Acc, {Status, C+1}) + end + end, [], Events). + +%%%------------------------------------------------------------------- +%%% @doc Возвращает топ-N событий по среднему рейтингу (по убыванию). +%%% @end +%%%------------------------------------------------------------------- +-spec get_top_events_by_rating(N :: non_neg_integer()) -> [#event{}]. +get_top_events_by_rating(N) -> + Events = mnesia:dirty_match_object(#event{_ = '_'}), + Sorted = lists:reverse(lists:sort( + fun(A, B) -> A#event.rating_avg =< B#event.rating_avg end, + Events)), + lists:sublist(Sorted, N). + %%%------------------------------------------------------------------- %%% @doc Выделить из datetime только дату `{Y,M,D}`. %%% @end diff --git a/src/core/core_report.erl b/src/core/core_report.erl index 48933ee..be7aea1 100644 --- a/src/core/core_report.erl +++ b/src/core/core_report.erl @@ -3,7 +3,7 @@ -export([create/4, get_by_id/1, list_all/0, list_by_reporter/1, update_status/3, count_reports/0]). -export([list_by_target/2, get_count_by_target/2]). -export([count_reports_by_status/1, count_reports_resolved_by_admin/2, avg_resolution_time/1]). --export([count_reports_by_date/2]). +-export([count_reports_by_date/2, get_top_targets_by_reports/1, count_reports_by_status/0, count_reports_by_target_type/0]). %% ───────────────────────────────────────────────────────────────── %% Значения по умолчанию для необязательных полей @@ -178,4 +178,55 @@ count_reports_by_date(From, To) -> end, [], Filtered), lists:sort(Counts). +%%%------------------------------------------------------------------- +%%% @doc Подсчёт жалоб по типам цели (event, calendar, review). +%%% @end +%%%------------------------------------------------------------------- +-spec count_reports_by_target_type() -> [{atom(), non_neg_integer()}]. +count_reports_by_target_type() -> + Reports = mnesia:dirty_match_object(#report{_ = '_'}), + lists:foldl(fun(#report{target_type = Type}, Acc) -> + case lists:keyfind(Type, 1, Acc) of + false -> [{Type, 1} | Acc]; + {Type, C} -> lists:keyreplace(Type, 1, Acc, {Type, C+1}) + end + end, [], Reports). + +%%%------------------------------------------------------------------- +%%% @doc Подсчёт жалоб по статусам. +%%% @end +%%%------------------------------------------------------------------- +-spec count_reports_by_status() -> [{atom(), non_neg_integer()}]. +count_reports_by_status() -> + Reports = mnesia:dirty_match_object(#report{_ = '_'}), + lists:foldl(fun(#report{status = Status}, Acc) -> + case lists:keyfind(Status, 1, Acc) of + false -> [{Status, 1} | Acc]; + {Status, C} -> lists:keyreplace(Status, 1, Acc, {Status, C+1}) + end + end, [], Reports). + +%%%------------------------------------------------------------------- +%%% @doc Топ-N целей по количеству жалоб. +%%% Возвращает список [{TargetType, TargetId, Count}], отсортированный по убыванию Count. +%%% @end +%%%------------------------------------------------------------------- +-spec get_top_targets_by_reports(N :: non_neg_integer()) -> + [{TargetType :: atom(), TargetId :: binary(), Count :: non_neg_integer()}]. +get_top_targets_by_reports(N) -> + Reports = mnesia:dirty_match_object(#report{_ = '_'}), + % Группируем по {TargetType, TargetId} + Dict = lists:foldl(fun(#report{target_type = Type, target_id = Id}, Acc) -> + Key = {Type, Id}, + case lists:keyfind(Key, 1, Acc) of + false -> [{Key, 1} | Acc]; + {Key, C} -> lists:keyreplace(Key, 1, Acc, {Key, C+1}) + end + end, [], Reports), + % Сортируем по убыванию Count + Sorted = lists:reverse(lists:sort(fun({_, C1}, {_, C2}) -> C1 =< C2 end, Dict)), + % Преобразуем в тройки и берём первые N + Top = lists:sublist([{Type, Id, Count} || {{Type, Id}, Count} <- Sorted], N), + Top. + date_part({{Y,M,D}, _}) -> {Y,M,D}. \ No newline at end of file diff --git a/src/core/core_review.erl b/src/core/core_review.erl index 929c871..ec423d9 100644 --- a/src/core/core_review.erl +++ b/src/core/core_review.erl @@ -5,6 +5,9 @@ -export([get_average_rating/2, has_user_reviewed/3]). -export([count_reviews/0, list_all/0]). -export([count_reviews_by_date/2]). +-export([count_reviews_by_target_type/0, count_reviews_by_status/0, + get_top_targets_by_reviews/1, get_top_targets_by_positive_reviews/1, + get_top_targets_by_negative_reviews/1]). %% ───────────────────────────────────────────────────────────────── %% Значения по умолчанию для необязательных полей @@ -204,6 +207,75 @@ count_reviews_by_date(From, To) -> date_part({{Y,M,D}, _}) -> {Y,M,D}. +%%%------------------------------------------------------------------- +%%% @doc Подсчёт отзывов по типам цели (event, calendar). +%%% @end +%%%------------------------------------------------------------------- +-spec count_reviews_by_target_type() -> [{atom(), non_neg_integer()}]. +count_reviews_by_target_type() -> + Reviews = mnesia:dirty_match_object(#review{_ = '_'}), + lists:foldl(fun(#review{target_type = Type}, Acc) -> + case lists:keyfind(Type, 1, Acc) of + false -> [{Type, 1} | Acc]; + {Type, C} -> lists:keyreplace(Type, 1, Acc, {Type, C+1}) + end + end, [], Reviews). + +%%%------------------------------------------------------------------- +%%% @doc Подсчёт отзывов по статусам (visible, hidden, deleted). +%%% @end +%%%------------------------------------------------------------------- +-spec count_reviews_by_status() -> [{atom(), non_neg_integer()}]. +count_reviews_by_status() -> + Reviews = mnesia:dirty_match_object(#review{_ = '_'}), + lists:foldl(fun(#review{status = Status}, Acc) -> + case lists:keyfind(Status, 1, Acc) of + false -> [{Status, 1} | Acc]; + {Status, C} -> lists:keyreplace(Status, 1, Acc, {Status, C+1}) + end + end, [], Reviews). + +%%%------------------------------------------------------------------- +%%% @doc Топ-N целей по общему количеству отзывов. +%%% @end +%%%------------------------------------------------------------------- +-spec get_top_targets_by_reviews(N :: non_neg_integer()) -> + [{TargetType :: atom(), TargetId :: binary(), Count :: non_neg_integer()}]. +get_top_targets_by_reviews(N) -> + aggregate_targets_by(fun(_Review) -> true end, N). + +%%%------------------------------------------------------------------- +%%% @doc Топ-N целей по количеству позитивных отзывов (rating >= 4). +%%% @end +%%%------------------------------------------------------------------- +-spec get_top_targets_by_positive_reviews(N :: non_neg_integer()) -> + [{TargetType :: atom(), TargetId :: binary(), Count :: non_neg_integer()}]. +get_top_targets_by_positive_reviews(N) -> + aggregate_targets_by(fun(#review{rating = R}) -> R >= 4 end, N). + +%%%------------------------------------------------------------------- +%%% @doc Топ-N целей по количеству негативных отзывов (rating <= 2). +%%% @end +%%%------------------------------------------------------------------- +-spec get_top_targets_by_negative_reviews(N :: non_neg_integer()) -> + [{TargetType :: atom(), TargetId :: binary(), Count :: non_neg_integer()}]. +get_top_targets_by_negative_reviews(N) -> + aggregate_targets_by(fun(#review{rating = R}) -> R =< 2 end, N). + +%% @private Вспомогательная функция агрегации целей с фильтром. +aggregate_targets_by(Pred, N) -> + Reviews = mnesia:dirty_match_object(#review{_ = '_'}), + Filtered = lists:filter(Pred, Reviews), + Dict = lists:foldl(fun(#review{target_type = Type, target_id = Id}, Acc) -> + Key = {Type, Id}, + case lists:keyfind(Key, 1, Acc) of + false -> [{Key, 1} | Acc]; + {Key, C} -> lists:keyreplace(Key, 1, Acc, {Key, C+1}) + end + end, [], Filtered), + Sorted = lists:reverse(lists:sort(fun({_, C1}, {_, C2}) -> C1 =< C2 end, Dict)), + lists:sublist([{Type, Id, Cnt} || {{Type, Id}, Cnt} <- Sorted], N). + %%%=================================================================== %%% ВНУТРЕННИЕ ФУНКЦИИ %%%=================================================================== diff --git a/src/core/core_subscription.erl b/src/core/core_subscription.erl index c44ae20..60383d9 100644 --- a/src/core/core_subscription.erl +++ b/src/core/core_subscription.erl @@ -3,8 +3,10 @@ -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([list_subscriptions/0, create_subscription/1, update_subscription/2, delete_subscription/1]). --export([count_subscription/0]). +-export([count_subscriptions/0]). -export([count_subscriptions_by_date/2]). +-export([count_subscriptions_by_plan/0, count_subscriptions_by_status/0, + count_trial_subscriptions/0, get_ending_paid_subscriptions/1]). -define(TRIAL_DAYS, 30). @@ -291,8 +293,8 @@ parse_iso_datetime(Other) -> Other. %%% @doc Количество подписок. %%% @end %%%------------------------------------------------------------------- --spec count_subscription() -> non_neg_integer(). -count_subscription() -> +-spec count_subscriptions() -> non_neg_integer(). +count_subscriptions() -> mnesia:table_info(subscription, size). %%%------------------------------------------------------------------- @@ -314,4 +316,56 @@ count_subscriptions_by_date(From, To) -> end, [], Filtered), lists:sort(Counts). -date_part({{Y,M,D}, _}) -> {Y,M,D}. \ No newline at end of file +date_part({{Y,M,D}, _}) -> {Y,M,D}. + +%%%------------------------------------------------------------------- +%%% @doc Подсчёт подписок по планам. +%%% @end +%%%------------------------------------------------------------------- +-spec count_subscriptions_by_plan() -> [{atom(), non_neg_integer()}]. +count_subscriptions_by_plan() -> + Subs = mnesia:dirty_match_object(#subscription{_ = '_'}), + lists:foldl(fun(#subscription{plan = Plan}, Acc) -> + case lists:keyfind(Plan, 1, Acc) of + false -> [{Plan, 1} | Acc]; + {Plan, C} -> lists:keyreplace(Plan, 1, Acc, {Plan, C+1}) + end + end, [], Subs). + +%%%------------------------------------------------------------------- +%%% @doc Подсчёт подписок по статусам. +%%% @end +%%%------------------------------------------------------------------- +-spec count_subscriptions_by_status() -> [{atom(), non_neg_integer()}]. +count_subscriptions_by_status() -> + Subs = mnesia:dirty_match_object(#subscription{_ = '_'}), + lists:foldl(fun(#subscription{status = Status}, Acc) -> + case lists:keyfind(Status, 1, Acc) of + false -> [{Status, 1} | Acc]; + {Status, C} -> lists:keyreplace(Status, 1, Acc, {Status, C+1}) + end + end, [], Subs). + +%%%------------------------------------------------------------------- +%%% @doc Количество пробных подписок (trial_used = false). +%%% @end +%%%------------------------------------------------------------------- +-spec count_trial_subscriptions() -> non_neg_integer(). +count_trial_subscriptions() -> + Match = #subscription{trial_used = false, _ = '_'}, + length(mnesia:dirty_match_object(Match)). + +%%%------------------------------------------------------------------- +%%% @doc Платные подписки (trial_used = true), истекающие в течение Days дней. +%%% @end +%%%------------------------------------------------------------------- +-spec get_ending_paid_subscriptions(Days :: non_neg_integer()) -> [#subscription{}]. +get_ending_paid_subscriptions(Days) -> + NowSec = calendar:datetime_to_gregorian_seconds(calendar:universal_time()), + ThresholdSec = NowSec + Days * 86400, + Match = #subscription{status = active, trial_used = true, _ = '_'}, + ActivePaid = mnesia:dirty_match_object(Match), + lists:filter(fun(#subscription{expires_at = Exp}) -> + ExpSec = calendar:datetime_to_gregorian_seconds(Exp), + ExpSec >= NowSec andalso ExpSec =< ThresholdSec + end, ActivePaid). \ No newline at end of file diff --git a/src/core/core_ticket.erl b/src/core/core_ticket.erl index 170d8d5..e2a56f9 100644 --- a/src/core/core_ticket.erl +++ b/src/core/core_ticket.erl @@ -5,7 +5,7 @@ -export([count_tickets/0]). -export([create_ticket/1]). -export([list_by_user/1]). --export([count_tickets_by_status/1, avg_resolution_time/0, count_tickets_by_admin/2]). +-export([count_tickets_by_status/1, avg_resolution_time/0, count_tickets_by_admin/2, stats/0]). -export([update_ticket/2]). -export([delete_ticket/1]). -export([count_tickets_by_date/2]). @@ -236,6 +236,23 @@ avg_resolution_time() -> Total / length(Closed) / 3600.0 end. +%% @doc Статистика по тикетам (используется в admin_handler_ticket_stats) +stats() -> + Tickets = list_all(), + #{ + total => length(Tickets), + open => count_by_status(open, Tickets), + in_progress => count_by_status(in_progress, Tickets), + resolved => count_by_status(resolved, Tickets), + closed => count_by_status(closed, Tickets) + }. + +%% ── функции подсчёта с нормализацией статуса ───────────── + +%% @private Подсчитывает тикеты с заданным статусом (атом или бинарный) +count_by_status(Status, Tickets) -> + length([T || T <- Tickets, normalize_status(T#ticket.status) =:= Status]). + %%%------------------------------------------------------------------- %%% @doc Количество тикетов, назначенных на администратора. %%% @end @@ -274,6 +291,14 @@ date_part({{Y,M,D}, _}) -> {Y,M,D}. %%% ВНУТРЕННИЕ ФУНКЦИИ %%%=================================================================== +%% @private Преобразует бинарный статус в атом, если нужно. +%% Атомы возвращает без изменений. +normalize_status(Status) when is_atom(Status) -> Status; +normalize_status(Status) when is_binary(Status) -> + try binary_to_existing_atom(Status, utf8) + catch error:badarg -> Status + end. + apply_updates(Ticket, Updates) -> Updated = lists:foldl(fun({Field, Value}, T) -> set_field(Field, Value, T) end, Ticket, Updates), diff --git a/src/core/core_user.erl b/src/core/core_user.erl index 6c445ed..1f830b6 100644 --- a/src/core/core_user.erl +++ b/src/core/core_user.erl @@ -6,6 +6,7 @@ -export([list_users/0]). -export([block/2, unblock/2]). -export([count_users/0, count_users_by_date/2, count_pending_users/0, count_pending_users_by_date/2, list_all/0]). +-export([count_users_by_role/0, count_users_by_status/0]). -export([create_bot/2, delete_bot/1]). %% ───────────────────────────────────────────────────────────────── @@ -306,6 +307,38 @@ count_pending_users_by_date(From, To) -> end, [], Filtered), lists:sort(Counts). +%%%------------------------------------------------------------------- +%%% @doc Подсчёт пользователей по ролям. +%%% Возвращает список [{Role :: atom(), Count :: non_neg_integer()}]. +%%% @end +%%%------------------------------------------------------------------- +-spec count_users_by_role() -> [{atom(), non_neg_integer()}]. +count_users_by_role() -> + Users = mnesia:dirty_match_object(#user{_ = '_'}), + Dict = lists:foldl(fun(#user{role = Role}, Acc) -> + case lists:keyfind(Role, 1, Acc) of + false -> [{Role, 1} | Acc]; + {Role, C} -> lists:keyreplace(Role, 1, Acc, {Role, C+1}) + end + end, [], Users), + Dict. + +%%%------------------------------------------------------------------- +%%% @doc Подсчёт пользователей по статусам. +%%% Возвращает список [{Status :: atom(), Count :: non_neg_integer()}]. +%%% @end +%%%------------------------------------------------------------------- +-spec count_users_by_status() -> [{atom(), non_neg_integer()}]. +count_users_by_status() -> + Users = mnesia:dirty_match_object(#user{_ = '_'}), + Dict = lists:foldl(fun(#user{status = Status}, Acc) -> + case lists:keyfind(Status, 1, Acc) of + false -> [{Status, 1} | Acc]; + {Status, C} -> lists:keyreplace(Status, 1, Acc, {Status, C+1}) + end + end, [], Users), + Dict. + %%%------------------------------------------------------------------- %%% @doc Выделить из datetime только дату `{Y,M,D}`. %%% @end diff --git a/src/eventhub_app.erl b/src/eventhub_app.erl index c16db52..247c442 100644 --- a/src/eventhub_app.erl +++ b/src/eventhub_app.erl @@ -113,19 +113,24 @@ start_admin_http() -> {"/v1/admin/refresh", admin_handler_refresh, []}, % ================== ПОЛЬЗОВАТЕЛИ ================== {"/v1/admin/users", admin_handler_users, []}, + {"/v1/admin/users/stats", admin_handler_user_stats, []}, {"/v1/admin/users/:id", admin_handler_user_by_id, []}, {"/v1/admin/users/:id/verification-token", admin_handler_user_verification_token, []}, % ================== КАЛЕНДАРИ ================== {"/v1/admin/calendars", admin_handler_calendars, []}, + {"/v1/admin/calendars/stats", admin_handler_calendar_stats, []}, {"/v1/admin/calendars/:id", admin_handler_calendar_by_id, []}, % ================== СОБЫТИЯ ================== {"/v1/admin/events", admin_handler_events, []}, + {"/v1/admin/events/stats", admin_handler_event_stats, []}, {"/v1/admin/events/:id", admin_handler_event_by_id, []}, % ================== ОТЧЁТЫ ================== {"/v1/admin/reports", admin_handler_reports, []}, + {"/v1/admin/reports/stats", admin_handler_report_stats, []}, {"/v1/admin/reports/:id", admin_handler_report_by_id, []}, % ================== ОТЗЫВЫ ================== {"/v1/admin/reviews", admin_handler_reviews, []}, + {"/v1/admin/reviews/stats", admin_handler_reviews_stats, []}, {"/v1/admin/reviews/:id", admin_handler_reviews_by_id, []}, % ================== БАН-СЛОВА ================== {"/v1/admin/banned-words/batch", admin_handler_banned_words, []}, @@ -137,6 +142,7 @@ start_admin_http() -> {"/v1/admin/tickets", admin_handler_tickets, []}, % ================== ПОДПИСКИ ================== {"/v1/admin/subscriptions", admin_handler_subscriptions, []}, + {"/v1/admin/subscriptions/stats", admin_handler_subscription_stats, []}, {"/v1/admin/subscriptions/:id", admin_handler_subscriptions_by_id, []}, % ================== Управление ролями (только для superadmin) ================== {"/v1/admin/me", admin_handler_me, []}, diff --git a/src/handlers/admin/admin_handler_calendar_stats.erl b/src/handlers/admin/admin_handler_calendar_stats.erl new file mode 100644 index 0000000..6c9a0c2 --- /dev/null +++ b/src/handlers/admin/admin_handler_calendar_stats.erl @@ -0,0 +1,107 @@ +%%%------------------------------------------------------------------- +%%% @doc Административный обработчик статистики по календарям. +%%% GET /v1/admin/calendars/stats +%%% @end +%%%------------------------------------------------------------------- +-module(admin_handler_calendar_stats). +-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_calendar_stats(Req); + _ -> handler_utils:send_error(Req, 405, <<"Method not allowed">>) + end. + +-spec trails() -> [map()]. +trails() -> + [ #{ + path => <<"/v1/admin/calendars/stats">>, + method => <<"GET">>, + description => <<"Calendar statistics">>, + tags => [<<"Statistics">>], + responses => #{ + 200 => #{ + description => <<"Calendar statistics object">>, + content => #{<<"application/json">> => #{schema => calendar_stats_schema()}} + } + } + } ]. + +calendar_stats_schema() -> + #{ + type => object, + properties => #{ + <<"total_calendars">> => #{type => integer}, + <<"calendars_by_type">> => #{ + type => object, + additionalProperties => #{type => integer} + }, + <<"calendars_by_status">> => #{ + type => object, + additionalProperties => #{type => integer} + }, + <<"top_calendars_by_reviews">> => #{ + type => array, + items => calendar_rank_schema() + }, + <<"top_calendars_by_positive_reviews">> => #{ + type => array, + items => calendar_rank_schema() + }, + <<"top_calendars_by_negative_reviews">> => #{ + type => array, + items => calendar_rank_schema() + }, + <<"top_calendars_by_rating">> => #{ + type => array, + items => calendar_rank_schema() + } + } + }. + +calendar_rank_schema() -> + #{ + type => object, + properties => #{ + <<"id">> => #{type => string}, + <<"title">> => #{type => string}, + <<"rating_avg">> => #{type => number, format => float}, + <<"review_count">> => #{type => integer} + } + }. + +%%% Internal functions + +get_calendar_stats(Req) -> + case handler_utils:auth_admin(Req) of + {ok, _AdminId, Req1} -> + Total = core_calendar:count_calendars(), + ByType = core_calendar:count_calendars_by_type(), + ByStatus = core_calendar:count_calendars_by_status(), + TopReviews = core_calendar:get_top_calendars_by_reviews(10), + TopPositive = core_calendar:get_top_calendars_by_positive_reviews(10), + TopNegative = core_calendar:get_top_calendars_by_negative_reviews(10), + TopRating = core_calendar:get_top_calendars_by_rating(10), + Stats = #{ + <<"total_calendars">> => Total, + <<"calendars_by_type">> => maps:from_list([{atom_to_binary(T, utf8), C} || {T, C} <- ByType]), + <<"calendars_by_status">> => maps:from_list([{atom_to_binary(S, utf8), C} || {S, C} <- ByStatus]), + <<"top_calendars_by_reviews">> => format_calendar_list(TopReviews), + <<"top_calendars_by_positive_reviews">> => format_calendar_list(TopPositive), + <<"top_calendars_by_negative_reviews">> => format_calendar_list(TopNegative), + <<"top_calendars_by_rating">> => format_calendar_list(TopRating) + }, + handler_utils:send_json(Req1, 200, Stats); + {error, Code, Msg, Req1} -> + handler_utils:send_error(Req1, Code, Msg) + end. + +format_calendar_list(List) -> + lists:map(fun(#calendar{id = Id, title = Title, rating_avg = RAvg}) -> + #{<<"id">> => Id, <<"title">> => Title, <<"rating_avg">> => RAvg} + end, List). \ No newline at end of file diff --git a/src/handlers/admin/admin_handler_event_stats.erl b/src/handlers/admin/admin_handler_event_stats.erl new file mode 100644 index 0000000..fd57410 --- /dev/null +++ b/src/handlers/admin/admin_handler_event_stats.erl @@ -0,0 +1,100 @@ +%%%------------------------------------------------------------------- +%%% @doc Административный обработчик статистики по событиям. +%%% GET /v1/admin/events/stats – возвращает общее количество, +%%% распределение по типам, статусам и топ-10 по рейтингу. +%%% @end +%%%------------------------------------------------------------------- +-module(admin_handler_event_stats). +-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_event_stats(Req); + _ -> handler_utils:send_error(Req, 405, <<"Method not allowed">>) + end. + +%%% Swagger metadata +-spec trails() -> [map()]. +trails() -> + [ + #{ + path => <<"/v1/admin/events/stats">>, + method => <<"GET">>, + description => <<"Get detailed event statistics (total, by type, by status, top 10 by rating)">>, + tags => [<<"Statistics">>], + responses => #{ + 200 => #{ + description => <<"Event statistics object">>, + content => #{<<"application/json">> => #{schema => event_stats_schema()}} + }, + 403 => #{description => <<"Admin access required">>} + } + } + ]. + +event_stats_schema() -> + #{ + type => object, + properties => #{ + <<"total_events">> => #{type => integer, description => <<"Total number of events (master, active)">>}, + <<"events_by_type">> => #{ + type => object, + description => <<"Number of events grouped by type (single, recurring)">>, + additionalProperties => #{type => integer} + }, + <<"events_by_status">> => #{ + type => object, + description => <<"Number of events grouped by status (active, cancelled, completed)">>, + additionalProperties => #{type => integer} + }, + <<"top_events_by_rating">> => #{ + type => array, + description => <<"Top 10 events by average rating">>, + items => #{ + type => object, + properties => #{ + <<"id">> => #{type => string}, + <<"title">> => #{type => string}, + <<"rating_avg">> => #{type => number, format => float}, + <<"rating_count">> => #{type => integer} + } + } + } + } + }. + +%%% Internal functions + +%% @doc GET /v1/admin/events/stats – статистика по событиям. +-spec get_event_stats(cowboy_req:req()) -> {ok, binary(), cowboy_req:req()}. +get_event_stats(Req) -> + case handler_utils:auth_admin(Req) of + {ok, _AdminId, Req1} -> + Total = core_event:count_events(), + ByType = core_event:count_events_by_type(), + ByStatus = core_event:count_events_by_status(), + Top10 = core_event:get_top_events_by_rating(10), + Stats = #{ + <<"total_events">> => Total, + <<"events_by_type">> => maps:from_list([{atom_to_binary(Type, utf8), Count} || {Type, Count} <- ByType]), + <<"events_by_status">> => maps:from_list([{atom_to_binary(Status, utf8), Count} || {Status, Count} <- ByStatus]), + <<"top_events_by_rating">> => lists:map(fun event_to_map/1, Top10) + }, + handler_utils:send_json(Req1, 200, Stats); + {error, Code, Msg, Req1} -> + handler_utils:send_error(Req1, Code, Msg) + end. + +event_to_map(Event) -> + #{ + <<"id">> => Event#event.id, + <<"title">> => Event#event.title, + <<"rating_avg">> => Event#event.rating_avg, + <<"rating_count">> => Event#event.rating_count + }. \ No newline at end of file diff --git a/src/handlers/admin/admin_handler_report_stats.erl b/src/handlers/admin/admin_handler_report_stats.erl new file mode 100644 index 0000000..0f7616c --- /dev/null +++ b/src/handlers/admin/admin_handler_report_stats.erl @@ -0,0 +1,97 @@ +%%%------------------------------------------------------------------- +%%% @doc Административный обработчик статистики по жалобам. +%%% GET /v1/admin/reports/stats – возвращает общее количество, +%%% распределение по типам цели, статусам и топ-10 целей по жалобам. +%%% @end +%%%------------------------------------------------------------------- +-module(admin_handler_report_stats). +-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_report_stats(Req); + _ -> handler_utils:send_error(Req, 405, <<"Method not allowed">>) + end. + +%%% Swagger metadata +-spec trails() -> [map()]. +trails() -> + [ + #{ + path => <<"/v1/admin/reports/stats">>, + method => <<"GET">>, + description => <<"Get detailed report statistics (total, by target type, by status, top 10 targets by reports)">>, + tags => [<<"Statistics">>], + responses => #{ + 200 => #{ + description => <<"Report statistics object">>, + content => #{<<"application/json">> => #{schema => report_stats_schema()}} + }, + 403 => #{description => <<"Admin access required">>} + } + } + ]. + +report_stats_schema() -> + #{ + type => object, + properties => #{ + <<"total_reports">> => #{type => integer, description => <<"Total number of reports">>}, + <<"reports_by_target_type">> => #{ + type => object, + description => <<"Number of reports grouped by target type (event, calendar, review)">>, + additionalProperties => #{type => integer} + }, + <<"reports_by_status">> => #{ + type => object, + description => <<"Number of reports grouped by status (pending, reviewed, dismissed)">>, + additionalProperties => #{type => integer} + }, + <<"top_targets_by_reports">> => #{ + type => array, + description => <<"Top 10 targets by number of reports">>, + items => #{ + type => object, + properties => #{ + <<"target_type">> => #{type => string}, + <<"target_id">> => #{type => string}, + <<"report_count">> => #{type => integer} + } + } + } + } + }. + +%%% Internal functions + +%% @doc GET /v1/admin/reports/stats – статистика по жалобам. +-spec get_report_stats(cowboy_req:req()) -> {ok, binary(), cowboy_req:req()}. +get_report_stats(Req) -> + case handler_utils:auth_admin(Req) of + {ok, _AdminId, Req1} -> + Total = core_report:count_reports(), + ByTargetType = core_report:count_reports_by_target_type(), + ByStatus = core_report:count_reports_by_status(), + Top10 = core_report:get_top_targets_by_reports(10), + Stats = #{ + <<"total_reports">> => Total, + <<"reports_by_target_type">> => maps:from_list([{atom_to_binary(Type, utf8), Count} || {Type, Count} <- ByTargetType]), + <<"reports_by_status">> => maps:from_list([{atom_to_binary(Status, utf8), Count} || {Status, Count} <- ByStatus]), + <<"top_targets_by_reports">> => lists:map(fun({TargetType, TargetId, Count}) -> + #{ + <<"target_type">> => atom_to_binary(TargetType, utf8), + <<"target_id">> => TargetId, + <<"report_count">> => Count + } + end, Top10) + }, + handler_utils:send_json(Req1, 200, Stats); + {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_reviews_stats.erl b/src/handlers/admin/admin_handler_reviews_stats.erl new file mode 100644 index 0000000..4bc983e --- /dev/null +++ b/src/handlers/admin/admin_handler_reviews_stats.erl @@ -0,0 +1,116 @@ +%%%------------------------------------------------------------------- +%%% @doc Административный обработчик статистики по отзывам. +%%% GET /v1/admin/reviews/stats – возвращает общее количество, +%%% распределение по типам цели, статусам, топ-10 целей по отзывам +%%% (всего, позитивных, негативных). +%%% @end +%%%------------------------------------------------------------------- +-module(admin_handler_reviews_stats). +-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_review_stats(Req); + _ -> handler_utils:send_error(Req, 405, <<"Method not allowed">>) + end. + +%%% Swagger metadata +-spec trails() -> [map()]. +trails() -> + [ + #{ + path => <<"/v1/admin/reviews/stats">>, + method => <<"GET">>, + description => <<"Get detailed review statistics (total, by target type, by status, top targets by reviews/positive/negative)">>, + tags => [<<"Statistics">>], + responses => #{ + 200 => #{ + description => <<"Review statistics object">>, + content => #{<<"application/json">> => #{schema => review_stats_schema()}} + }, + 403 => #{description => <<"Admin access required">>} + } + } + ]. + +review_stats_schema() -> + #{ + type => object, + properties => #{ + <<"total_reviews">> => #{type => integer, description => <<"Total number of reviews">>}, + <<"reviews_by_target_type">> => #{ + type => object, + description => <<"Number of reviews grouped by target type (event, calendar)">>, + additionalProperties => #{type => integer} + }, + <<"reviews_by_status">> => #{ + type => object, + description => <<"Number of reviews grouped by status (visible, hidden, deleted)">>, + additionalProperties => #{type => integer} + }, + <<"top_targets_by_reviews">> => #{ + type => array, + description => <<"Top 10 targets by total number of reviews">>, + items => target_review_count_schema() + }, + <<"top_targets_by_positive_reviews">> => #{ + type => array, + description => <<"Top 10 targets by positive reviews (rating >= 4)">>, + items => target_review_count_schema() + }, + <<"top_targets_by_negative_reviews">> => #{ + type => array, + description => <<"Top 10 targets by negative reviews (rating <= 2)">>, + items => target_review_count_schema() + } + } + }. + +target_review_count_schema() -> + #{ + type => object, + properties => #{ + <<"target_type">> => #{type => string}, + <<"target_id">> => #{type => string}, + <<"review_count">> => #{type => integer} + } + }. + +%%% Internal functions + +%% @doc GET /v1/admin/reviews/stats – статистика по отзывам. +-spec get_review_stats(cowboy_req:req()) -> {ok, binary(), cowboy_req:req()}. +get_review_stats(Req) -> + case handler_utils:auth_admin(Req) of + {ok, _AdminId, Req1} -> + Total = core_review:count_reviews(), + ByTargetType = core_review:count_reviews_by_target_type(), + ByStatus = core_review:count_reviews_by_status(), + TopAll = core_review:get_top_targets_by_reviews(10), + TopPositive = core_review:get_top_targets_by_positive_reviews(10), + TopNegative = core_review:get_top_targets_by_negative_reviews(10), + Stats = #{ + <<"total_reviews">> => Total, + <<"reviews_by_target_type">> => maps:from_list([{atom_to_binary(Type, utf8), Count} || {Type, Count} <- ByTargetType]), + <<"reviews_by_status">> => maps:from_list([{atom_to_binary(Status, utf8), Count} || {Status, Count} <- ByStatus]), + <<"top_targets_by_reviews">> => lists:map(fun target_to_map/1, TopAll), + <<"top_targets_by_positive_reviews">> => lists:map(fun target_to_map/1, TopPositive), + <<"top_targets_by_negative_reviews">> => lists:map(fun target_to_map/1, TopNegative) + }, + handler_utils:send_json(Req1, 200, Stats); + {error, Code, Msg, Req1} -> + handler_utils:send_error(Req1, Code, Msg) + end. + +target_to_map({TargetType, TargetId, Count}) -> + #{ + <<"target_type">> => atom_to_binary(TargetType, utf8), + <<"target_id">> => TargetId, + <<"review_count">> => Count + }. \ 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 15d423f..280f7ba 100644 --- a/src/handlers/admin/admin_handler_stats.erl +++ b/src/handlers/admin/admin_handler_stats.erl @@ -47,15 +47,18 @@ stats_schema() -> type => object, properties => #{ <<"users_total">> => #{type => integer, description => <<"Total number of users">>}, + <<"pending_users_total">> => #{type => integer, description => <<"Number of users awaiting email verification">>}, <<"events_total">> => #{type => integer, description => <<"Total number of events">>}, - <<"reviews_total">> => #{type => integer, description => <<"Total number of reviews">>}, <<"calendars_total">> => #{type => integer, description => <<"Total number of calendars">>}, - <<"reports_total">> => #{type => integer, description => <<"Total number of reports">>}, + <<"reviews_total">> => #{type => integer, description => <<"Total number of reviews">>}, + <<"reports_total">> => #{type => integer, description => <<"Total number of reports (all statuses)">>}, + <<"reports_pending">> => #{type => integer, description => <<"Number of pending reports">>}, + <<"reports_reviewed">> => #{type => integer, description => <<"Number of reviewed reports">>}, + <<"reports_dismissed">> => #{type => integer, description => <<"Number of dismissed reports">>}, <<"tickets_total">> => #{type => integer, description => <<"Total number of tickets">>}, <<"tickets_open">> => #{type => integer, description => <<"Number of open tickets">>}, <<"subscriptions_total">> => #{type => integer, description => <<"Total number of subscriptions">>}, - <<"active_subscriptions">> => #{type => integer, description => <<"Number of active subscriptions">>}, - <<"pending_users_total">> => #{type => integer, description => <<"Number of users awaiting email verification">>}, + <<"trial_subscriptions">> => #{type => integer, description => <<"Number of trial subscriptions">>}, <<"avg_ticket_resolution_h">> => #{type => number, format => float, description => <<"Average ticket resolution time in hours">>}, <<"registrations_by_day">> => #{ type => array, diff --git a/src/handlers/admin/admin_handler_subscription_stats.erl b/src/handlers/admin/admin_handler_subscription_stats.erl new file mode 100644 index 0000000..11d716c --- /dev/null +++ b/src/handlers/admin/admin_handler_subscription_stats.erl @@ -0,0 +1,102 @@ +%%%------------------------------------------------------------------- +%%% @doc Административный обработчик статистики по подпискам. +%%% GET /v1/admin/subscriptions/stats – возвращает общее количество, +%%% распределение по планам, статусам, пробные, заканчивающиеся платные. +%%% @end +%%%------------------------------------------------------------------- +-module(admin_handler_subscription_stats). +-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_subscription_stats(Req); + _ -> handler_utils:send_error(Req, 405, <<"Method not allowed">>) + end. + +%%% Swagger metadata +-spec trails() -> [map()]. +trails() -> + [ + #{ + path => <<"/v1/admin/subscriptions/stats">>, + method => <<"GET">>, + description => <<"Get detailed subscription statistics (total, by plan, by status, trial count, ending paid subscriptions)">>, + tags => [<<"Statistics">>], + responses => #{ + 200 => #{ + description => <<"Subscription statistics object">>, + content => #{<<"application/json">> => #{schema => subscription_stats_schema()}} + }, + 403 => #{description => <<"Admin access required">>} + } + } + ]. + +subscription_stats_schema() -> + #{ + type => object, + properties => #{ + <<"total_subscriptions">> => #{type => integer, description => <<"Total number of subscriptions">>}, + <<"subscriptions_by_plan">> => #{ + type => object, + description => <<"Number of subscriptions grouped by plan">>, + additionalProperties => #{type => integer} + }, + <<"subscriptions_by_status">> => #{ + type => object, + description => <<"Number of subscriptions grouped by status">>, + additionalProperties => #{type => integer} + }, + <<"trial_subscriptions">> => #{type => integer, description => <<"Number of subscriptions in trial period (trial_used = false)">>}, + <<"ending_paid_subscriptions">> => #{ + type => array, + description => <<"Paid subscriptions expiring within 30 days">>, + items => #{ + type => object, + properties => #{ + <<"id">> => #{type => string}, + <<"user_id">> => #{type => string}, + <<"plan">> => #{type => string}, + <<"expires_at">> => #{type => string, format => <<"date-time">>} + } + } + } + } + }. + +%%% Internal functions + +%% @doc GET /v1/admin/subscriptions/stats – статистика по подпискам. +-spec get_subscription_stats(cowboy_req:req()) -> {ok, binary(), cowboy_req:req()}. +get_subscription_stats(Req) -> + case handler_utils:auth_admin(Req) of + {ok, _AdminId, Req1} -> + Total = core_subscription:count_subscriptions(), + ByPlan = core_subscription:count_subscriptions_by_plan(), + ByStatus = core_subscription:count_subscriptions_by_status(), + Trial = core_subscription:count_trial_subscriptions(), + EndingPaid = core_subscription:get_ending_paid_subscriptions(30), + Stats = #{ + <<"total_subscriptions">> => Total, + <<"subscriptions_by_plan">> => maps:from_list([{atom_to_binary(Plan, utf8), Count} || {Plan, Count} <- ByPlan]), + <<"subscriptions_by_status">> => maps:from_list([{atom_to_binary(Status, utf8), Count} || {Status, Count} <- ByStatus]), + <<"trial_subscriptions">> => Trial, + <<"ending_paid_subscriptions">> => lists:map(fun(Sub) -> + #{ + <<"id">> => Sub#subscription.id, + <<"user_id">> => Sub#subscription.user_id, + <<"plan">> => atom_to_binary(Sub#subscription.plan, utf8), + <<"expires_at">> => handler_utils:datetime_to_iso8601(Sub#subscription.expires_at) + } + end, EndingPaid) + }, + handler_utils:send_json(Req1, 200, Stats); + {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_stats.erl b/src/handlers/admin/admin_handler_ticket_stats.erl index c676b53..3e09c1d 100644 --- a/src/handlers/admin/admin_handler_ticket_stats.erl +++ b/src/handlers/admin/admin_handler_ticket_stats.erl @@ -1,12 +1,10 @@ %%%------------------------------------------------------------------- -%%% @doc Административный обработчик для получения статистики по тикетам. -%%% GET – возвращает агрегированную статистику тикетов -%%% (количество по статусам: open, in_progress, resolved, closed). +%%% @doc Административный обработчик статистики по тикетам. +%%% GET – возвращает агрегированные данные по тикетам. %%% @end %%%------------------------------------------------------------------- -module(admin_handler_ticket_stats). -behaviour(cowboy_handler). - -export([init/2]). -export([trails/0]). @@ -16,8 +14,8 @@ -spec init(cowboy_req:req(), any()) -> {ok, cowboy_req:req(), any()}. init(Req, _Opts) -> case cowboy_req:method(Req) of - <<"GET">> -> get_stats(Req); - _ -> handler_utils:send_error(Req, 405, <<"Method not allowed">>) + <<"GET">> -> get_ticket_stats(Req); + _ -> handler_utils:send_error(Req, 405, <<"Method not allowed">>) end. %%% Swagger metadata @@ -25,38 +23,41 @@ init(Req, _Opts) -> trails() -> [ #{ - path => <<"/v1/admin/tickets/stats">>, - method => <<"GET">>, - description => <<"Get ticket statistics (admin)">>, - tags => [<<"Tickets">>], - responses => #{ + path => <<"/v1/admin/tickets/stats">>, + method => <<"GET">>, + description => <<"Get ticket statistics">>, + 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">>} + content => #{<<"application/json">> => #{schema => ticket_stats_schema()}} + } } } ]. +ticket_stats_schema() -> + #{ + type => object, + properties => #{ + open => #{type => integer, description => <<"Number of open tickets">>}, + in_progress => #{type => integer, description => <<"Number of in-progress tickets">>}, + resolved => #{type => integer, description => <<"Number of resolved tickets">>}, + closed => #{type => integer, description => <<"Number of closed tickets">>}, + total_tickets => #{type => integer, description => <<"Total number of tickets">>}, + total_errors => #{type => integer, description => <<"Total error occurrences">>} + } + }. + %%% Internal functions -%% @doc Получить статистику тикетов. Доступно только администраторам. --spec get_stats(cowboy_req:req()) -> {ok, binary(), cowboy_req:req()}. -get_stats(Req) -> +%% @doc GET /v1/admin/tickets/stats – получить статистику по тикетам. +-spec get_ticket_stats(cowboy_req:req()) -> {ok, binary(), cowboy_req:req()}. +get_ticket_stats(Req) -> case handler_utils:auth_admin(Req) of - {ok, _AdminId, Req1} -> - Stats = core_ticket:stats(), + {ok, AdminId, Req1} -> + Stats = logic_ticket:get_statistics(AdminId), handler_utils:send_json(Req1, 200, Stats); - {error, Code, Message, Req1} -> - handler_utils:send_error(Req1, Code, Message) + {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_user_stats.erl b/src/handlers/admin/admin_handler_user_stats.erl new file mode 100644 index 0000000..2e5853a --- /dev/null +++ b/src/handlers/admin/admin_handler_user_stats.erl @@ -0,0 +1,80 @@ +%%%------------------------------------------------------------------- +%%% @doc Административный обработчик статистики по пользователям. +%%% GET /v1/admin/users/stats – возвращает общее количество, +%%% распределение по ролям, статусам и число неподтверждённых. +%%% @end +%%%------------------------------------------------------------------- +-module(admin_handler_user_stats). +-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_user_stats(Req); + _ -> handler_utils:send_error(Req, 405, <<"Method not allowed">>) + end. + +%%% Swagger metadata +-spec trails() -> [map()]. +trails() -> + [ + #{ + path => <<"/v1/admin/users/stats">>, + method => <<"GET">>, + description => <<"Get detailed user statistics (total, by role, by status, pending)">>, + tags => [<<"Statistics">>], + responses => #{ + 200 => #{ + description => <<"User statistics object">>, + content => #{<<"application/json">> => #{schema => user_stats_schema()}} + }, + 403 => #{description => <<"Admin access required">>} + } + } + ]. + +user_stats_schema() -> + #{ + type => object, + properties => #{ + <<"total_users">> => #{type => integer, description => <<"Total number of users">>}, + <<"users_by_role">> => #{ + type => object, + description => <<"Number of users grouped by role">>, + additionalProperties => #{type => integer} + }, + <<"users_by_status">> => #{ + type => object, + description => <<"Number of users grouped by status">>, + additionalProperties => #{type => integer} + }, + <<"pending_users">> => #{type => integer, description => <<"Number of unverified users">>} + } + }. + +%%% Internal functions + +%% @doc GET /v1/admin/users/stats – статистика по пользователям. +-spec get_user_stats(cowboy_req:req()) -> {ok, binary(), cowboy_req:req()}. +get_user_stats(Req) -> + case handler_utils:auth_admin(Req) of + {ok, _AdminId, Req1} -> + Total = core_user:count_users(), + ByRole = core_user:count_users_by_role(), + ByStatus = core_user:count_users_by_status(), + Pending = core_user:count_pending_users(), + Stats = #{ + <<"total_users">> => Total, + <<"users_by_role">> => maps:from_list([{atom_to_binary(Role, utf8), Count} || {Role, Count} <- ByRole]), + <<"users_by_status">> => maps:from_list([{atom_to_binary(Status, utf8), Count} || {Status, Count} <- ByStatus]), + <<"pending_users">> => Pending + }, + handler_utils:send_json(Req1, 200, Stats); + {error, Code, Msg, Req1} -> + handler_utils:send_error(Req1, Code, Msg) + end. \ No newline at end of file diff --git a/src/logic/logic_stats.erl b/src/logic/logic_stats.erl index 6623b73..26bc047 100644 --- a/src/logic/logic_stats.erl +++ b/src/logic/logic_stats.erl @@ -1,10 +1,3 @@ -%%%------------------------------------------------------------------- -%%% @doc Модуль сбора статистики для административной панели. -%%% -%%% Предоставляет функции `get_stats/2` и `get_stats/4`, возвращающие -%%% агрегированные показатели в зависимости от роли администратора. -%%% @end -%%%------------------------------------------------------------------- -module(logic_stats). -export([get_stats/2, get_stats/4]). -include("records.hrl"). @@ -53,9 +46,14 @@ build_superadmin_stats(From, To) -> events_total => core_event:count_events(), calendars_total => core_calendar:count_calendars(), reviews_total => core_review:count_reviews(), - reports_total => core_report:count_reports_by_status(pending), + reports_total => core_report:count_reports(), % все жалобы + reports_pending => core_report:count_reports_by_status(pending), + reports_reviewed => core_report:count_reports_by_status(reviewed), + reports_dismissed => core_report:count_reports_by_status(dismissed), tickets_open => core_ticket:count_tickets_by_status(open), tickets_total => length(core_ticket:list_all()), + subscriptions_total => core_subscription:count_subscriptions(), + trial_subscriptions => core_subscription:count_trial_subscriptions(), avg_ticket_resolution_h => trunc_hours(core_ticket:avg_resolution_time()), registrations_by_day => date_list_to_json(core_user:count_users_by_date(From, To)), events_by_day => date_list_to_json(core_event:count_events_by_date(From, To)), @@ -80,9 +78,14 @@ build_admin_stats(From, To) -> events_total => core_event:count_events(), calendars_total => core_calendar:count_calendars(), reviews_total => core_review:count_reviews(), - reports_total => core_report:count_reports_by_status(pending), + reports_total => core_report:count_reports(), + reports_pending => core_report:count_reports_by_status(pending), + reports_reviewed => core_report:count_reports_by_status(reviewed), + reports_dismissed => core_report:count_reports_by_status(dismissed), tickets_open => core_ticket:count_tickets_by_status(open), tickets_total => length(core_ticket:list_all()), + subscriptions_total => core_subscription:count_subscriptions(), + trial_subscriptions => core_subscription:count_trial_subscriptions(), avg_ticket_resolution_h => trunc_hours(core_ticket:avg_resolution_time()), registrations_by_day => date_list_to_json(core_user:count_users_by_date(From, To)), events_by_day => date_list_to_json(core_event:count_events_by_date(From, To)), diff --git a/src/swagger/eventhub_trails.erl b/src/swagger/eventhub_trails.erl index db015ef..18d2f4c 100644 --- a/src/swagger/eventhub_trails.erl +++ b/src/swagger/eventhub_trails.erl @@ -12,22 +12,27 @@ admin() -> admin_handler_health, admin_handler_stats, admin_handler_login, - admin_handler_refresh, % ← добавлен + admin_handler_refresh, % ================== ПОЛЬЗОВАТЕЛИ ================== admin_handler_users, admin_handler_user_by_id, - admin_handler_user_verification_token, % ← добавлен + admin_handler_user_verification_token, + admin_handler_user_stats, % ================== КАЛЕНДАРИ ================== - admin_handler_calendars, % ← добавлен - admin_handler_calendar_by_id, % ← добавлен + admin_handler_calendars, + admin_handler_calendar_stats, + admin_handler_calendar_by_id, % ================== СОБЫТИЯ ================== admin_handler_events, + admin_handler_event_stats, admin_handler_event_by_id, % ================== ОТЧЁТЫ ================== admin_handler_reports, + admin_handler_report_stats, admin_handler_report_by_id, % ================== ОТЗЫВЫ ================== admin_handler_reviews, + admin_handler_reviews_stats, admin_handler_reviews_by_id, % ================== БАН-СЛОВА ================== admin_handler_banned_words, @@ -37,13 +42,14 @@ admin() -> admin_handler_tickets, % ================== ПОДПИСКИ ================== admin_handler_subscriptions, + admin_handler_subscription_stats, admin_handler_subscriptions_by_id, % ================== МОДЕРАЦИЯ (общий маршрут) ================== admin_handler_moderation, % ================== Управление ролями (только для superadmin) ================== admin_handler_me, admin_handler_admins, - admin_handler_admins_by_id, % ← добавлен + admin_handler_admins_by_id, admin_handler_audit ], lists:flatmap(fun trails_from_module/1, Modules). @@ -59,7 +65,7 @@ user() -> handler_register, handler_login, handler_refresh, - handler_verify, % ← добавлен + handler_verify, handler_booking_by_id, handler_bookings, handler_calendar_by_id, diff --git a/test/api/admins/admin_stats_tests.erl b/test/api/admins/admin_stats_tests.erl index 44e3aa5..02da6e3 100644 --- a/test/api/admins/admin_stats_tests.erl +++ b/test/api/admins/admin_stats_tests.erl @@ -3,13 +3,24 @@ %%% %%% Покрывает эндпоинты: %%% GET /v1/admin/stats +%%% GET /v1/admin/users/stats +%%% GET /v1/admin/events/stats +%%% GET /v1/admin/reports/stats +%%% GET /v1/admin/reviews/stats +%%% GET /v1/admin/subscriptions/stats +%%% GET /v1/admin/calendars/stats %%% %%% Проверяет: -%%% - получение статистики для всех четырёх ролей администраторов -%%% - для superadmin и admin – наличие ключевых метрик, включая -%%% pending_users_total, *_by_day +%%% - получение общей статистики для всех ролей администраторов +%%% - для superadmin и admin – наличие ключевых метрик, включая pending_users_total, *_by_day %%% - для moderator и support – ответ непустой %%% - работу с фильтром по датам (from, to) +%%% - статистику по пользователям (users/stats) +%%% - статистику по событиям (events/stats) +%%% - статистику по жалобам (reports/stats) +%%% - статистику по отзывам (reviews/stats) +%%% - статистику по подпискам (subscriptions/stats) +%%% - статистику по календарям (calendars/stats) %%% @end %%%------------------------------------------------------------------- -module(admin_stats_tests). @@ -27,6 +38,31 @@ test() -> ModerToken = api_test_runner:get_moderator_token(), SupportToken = api_test_runner:get_support_token(), + % Создаём тестовые данные для ненулевой статистики + UserToken = api_test_runner:get_user_token(), + CalId = api_test_runner:create_calendar(UserToken, #{title => <<"StatsCal">>}), + EventId = api_test_runner:create_event(UserToken, CalId, #{ + title => <<"Stats Event">>, + start_time => api_test_runner:future_date(), + duration => 60 + }), + % Бронируем и подтверждаем, чтобы можно было оставить отзыв + #{<<"id">> := BookingId} = api_test_runner:client_post( + <<"/v1/events/", EventId/binary, "/bookings">>, UserToken, #{}), + api_test_runner:client_put(<<"/v1/bookings/", BookingId/binary>>, UserToken, + #{action => <<"confirm">>}), + % Оставляем отзыв + api_test_runner:client_post(<<"/v1/reviews">>, UserToken, + #{target_type => <<"event">>, target_id => EventId, rating => 5, comment => <<"Great!">>}), + % Жалоба + api_test_runner:client_post(<<"/v1/reports">>, UserToken, + #{<<"target_type">> => <<"event">>, <<"target_id">> => EventId, <<"reason">> => <<"Test">>}), + % Подписка + SubUserToken = api_test_runner:get_user_token(), + api_test_runner:client_post(<<"/v1/subscription">>, SubUserToken, + #{<<"action">> => <<"start_trial">>}), + + % Общая статистика test_stats_for_role("Superadmin", SuperToken, strict), test_stats_for_role("Admin", AdminToken, strict), test_stats_for_role("Moderator", ModerToken, loose), @@ -34,6 +70,14 @@ test() -> test_stats_with_dates(SuperToken), + % Детальная статистика + test_user_stats(SuperToken), + test_event_stats(SuperToken), + test_report_stats(SuperToken), + test_review_stats(SuperToken), + test_subscription_stats(SuperToken), + test_calendar_stats(SuperToken), + ct:pal("=== All admin stats tests passed ==="), ok. @@ -41,9 +85,6 @@ test() -> %%% Тестовые функции %%%=================================================================== -%% @doc Проверяет получение статистики для конкретной роли. -%% strict – ожидаем ключи users_total/events_total/pending_users_total и *_by_day -%% loose – просто убеждаемся, что ответ непустой -spec test_stats_for_role(string(), binary(), strict | loose) -> ok. test_stats_for_role(RoleName, Token, Strictness) -> ct:pal(" TEST: Get stats for role ~s", [RoleName]), @@ -51,18 +92,32 @@ test_stats_for_role(RoleName, Token, Strictness) -> ?assert(is_map(Stats)), case Strictness of strict -> - HasUsers = maps:is_key(<<"users_total">>, Stats) orelse maps:is_key(<<"users">>, Stats), - HasEvents = maps:is_key(<<"events_total">>, Stats) orelse maps:is_key(<<"events">>, Stats), - ?assert(HasUsers orelse HasEvents), - % Проверяем наличие новых ключей + % Тотальные значения + ?assert(maps:is_key(<<"users_total">>, Stats)), ?assert(maps:is_key(<<"pending_users_total">>, Stats)), + ?assert(maps:is_key(<<"events_total">>, Stats)), + ?assert(maps:is_key(<<"calendars_total">>, Stats)), + ?assert(maps:is_key(<<"reviews_total">>, Stats)), + ?assert(maps:is_key(<<"reports_total">>, Stats)), + ?assert(maps:is_key(<<"reports_pending">>, Stats)), + ?assert(maps:is_key(<<"reports_reviewed">>, Stats)), + ?assert(maps:is_key(<<"reports_dismissed">>, Stats)), + ?assert(maps:is_key(<<"tickets_total">>, Stats)), + ?assert(maps:is_key(<<"tickets_open">>, Stats)), + ?assert(maps:is_key(<<"subscriptions_total">>, Stats)), + ?assert(maps:is_key(<<"trial_subscriptions">>, Stats)), + % Массивы по дням + ?assert(maps:is_key(<<"registrations_by_day">>, Stats)), + ?assert(maps:is_key(<<"events_by_day">>, Stats)), ?assert(maps:is_key(<<"reviews_by_day">>, Stats)), ?assert(maps:is_key(<<"calendars_by_day">>, Stats)), ?assert(maps:is_key(<<"reports_by_day">>, Stats)), ?assert(maps:is_key(<<"tickets_by_day">>, Stats)), ?assert(maps:is_key(<<"subscriptions_by_day">>, Stats)), ?assert(maps:is_key(<<"pending_users_by_day">>, Stats)), - % Проверяем, что *_by_day являются списками + % Проверка, что они действительно списки + ?assert(is_list(maps:get(<<"registrations_by_day">>, Stats))), + ?assert(is_list(maps:get(<<"events_by_day">>, Stats))), ?assert(is_list(maps:get(<<"reviews_by_day">>, Stats))), ?assert(is_list(maps:get(<<"calendars_by_day">>, Stats))), ?assert(is_list(maps:get(<<"reports_by_day">>, Stats))), @@ -74,7 +129,6 @@ test_stats_for_role(RoleName, Token, Strictness) -> end, ct:pal(" OK: ~p keys", [length(maps:keys(Stats))]). -%% @doc GET /v1/admin/stats?from=...&to=... – проверяет фильтрацию по датам. -spec test_stats_with_dates(binary()) -> ok. test_stats_with_dates(Token) -> ct:pal(" TEST: Get stats with date range"), @@ -84,4 +138,85 @@ test_stats_with_dates(Token) -> Stats = api_test_runner:admin_get(Path, Token), ?assert(is_map(Stats)), ?assert(maps:is_key(<<"users_total">>, Stats) orelse maps:is_key(<<"users">>, Stats)), - ct:pal(" OK"). \ No newline at end of file + ct:pal(" OK"). + +%% @doc Тестирует эндпоинт GET /v1/admin/users/stats. +-spec test_user_stats(binary()) -> ok. +test_user_stats(Token) -> + ct:pal(" TEST: User statistics"), + Stats = api_test_runner:admin_get(<<"/v1/admin/users/stats">>, Token), + ?assert(is_map(Stats)), + ?assert(maps:is_key(<<"total_users">>, Stats)), + ?assert(maps:is_key(<<"users_by_role">>, Stats)), + ?assert(maps:is_key(<<"users_by_status">>, Stats)), + ?assert(maps:is_key(<<"pending_users">>, Stats)), + ct:pal(" OK: total=~p, pending=~p", [maps:get(<<"total_users">>, Stats), maps:get(<<"pending_users">>, Stats)]). + +%% @doc Тестирует эндпоинт GET /v1/admin/events/stats. +-spec test_event_stats(binary()) -> ok. +test_event_stats(Token) -> + ct:pal(" TEST: Event statistics"), + Stats = api_test_runner:admin_get(<<"/v1/admin/events/stats">>, Token), + ?assert(is_map(Stats)), + ?assert(maps:is_key(<<"total_events">>, Stats)), + ?assert(maps:is_key(<<"events_by_type">>, Stats)), + ?assert(maps:is_key(<<"events_by_status">>, Stats)), + ?assert(maps:is_key(<<"top_events_by_rating">>, Stats)), + ?assert(is_list(maps:get(<<"top_events_by_rating">>, Stats))), + ct:pal(" OK: total=~p", [maps:get(<<"total_events">>, Stats)]). + +%% @doc Тестирует эндпоинт GET /v1/admin/reports/stats. +-spec test_report_stats(binary()) -> ok. +test_report_stats(Token) -> + ct:pal(" TEST: Report statistics"), + Stats = api_test_runner:admin_get(<<"/v1/admin/reports/stats">>, Token), + ?assert(is_map(Stats)), + ?assert(maps:is_key(<<"total_reports">>, Stats)), + ?assert(maps:is_key(<<"reports_by_target_type">>, Stats)), + ?assert(maps:is_key(<<"reports_by_status">>, Stats)), + ?assert(maps:is_key(<<"top_targets_by_reports">>, Stats)), + ?assert(is_list(maps:get(<<"top_targets_by_reports">>, Stats))), + ct:pal(" OK: total=~p", [maps:get(<<"total_reports">>, Stats)]). + +%% @doc Тестирует эндпоинт GET /v1/admin/reviews/stats. +-spec test_review_stats(binary()) -> ok. +test_review_stats(Token) -> + ct:pal(" TEST: Review statistics"), + Stats = api_test_runner:admin_get(<<"/v1/admin/reviews/stats">>, Token), + ?assert(is_map(Stats)), + ?assert(maps:is_key(<<"total_reviews">>, Stats)), + ?assert(maps:is_key(<<"reviews_by_target_type">>, Stats)), + ?assert(maps:is_key(<<"reviews_by_status">>, Stats)), + ?assert(maps:is_key(<<"top_targets_by_reviews">>, Stats)), + ?assert(maps:is_key(<<"top_targets_by_positive_reviews">>, Stats)), + ?assert(maps:is_key(<<"top_targets_by_negative_reviews">>, Stats)), + ct:pal(" OK: total=~p", [maps:get(<<"total_reviews">>, Stats)]). + +%% @doc Тестирует эндпоинт GET /v1/admin/subscriptions/stats. +-spec test_subscription_stats(binary()) -> ok. +test_subscription_stats(Token) -> + ct:pal(" TEST: Subscription statistics"), + Stats = api_test_runner:admin_get(<<"/v1/admin/subscriptions/stats">>, Token), + ?assert(is_map(Stats)), + ?assert(maps:is_key(<<"total_subscriptions">>, Stats)), + ?assert(maps:is_key(<<"subscriptions_by_plan">>, Stats)), + ?assert(maps:is_key(<<"subscriptions_by_status">>, Stats)), + ?assert(maps:is_key(<<"trial_subscriptions">>, Stats)), + ?assert(maps:is_key(<<"ending_paid_subscriptions">>, Stats)), + ?assert(is_list(maps:get(<<"ending_paid_subscriptions">>, Stats))), + ct:pal(" OK: total=~p, trial=~p", [maps:get(<<"total_subscriptions">>, Stats), maps:get(<<"trial_subscriptions">>, Stats)]). + +%% @doc Тестирует эндпоинт GET /v1/admin/calendars/stats. +-spec test_calendar_stats(binary()) -> ok. +test_calendar_stats(Token) -> + ct:pal(" TEST: Calendar statistics"), + Stats = api_test_runner:admin_get(<<"/v1/admin/calendars/stats">>, Token), + ?assert(is_map(Stats)), + ?assert(maps:is_key(<<"total_calendars">>, Stats)), + ?assert(maps:is_key(<<"calendars_by_type">>, Stats)), + ?assert(maps:is_key(<<"calendars_by_status">>, Stats)), + ?assert(maps:is_key(<<"top_calendars_by_reviews">>, Stats)), + ?assert(maps:is_key(<<"top_calendars_by_positive_reviews">>, Stats)), + ?assert(maps:is_key(<<"top_calendars_by_negative_reviews">>, Stats)), + ?assert(maps:is_key(<<"top_calendars_by_rating">>, Stats)), + ct:pal(" OK: total=~p", [maps:get(<<"total_calendars">>, Stats)]). \ No newline at end of file diff --git a/test/api/users/user_verification_tests.erl b/test/api/users/user_verification_tests.erl index aeaba88..261165b 100644 --- a/test/api/users/user_verification_tests.erl +++ b/test/api/users/user_verification_tests.erl @@ -14,9 +14,6 @@ -module(user_verification_tests). -include_lib("eunit/include/eunit.hrl"). -export([test/0]). --export([verification_test/0]). - -verification_test() -> test(). -spec test() -> ok. test() ->