Files
EventHubBack/src/core/core_user.erl
T
aleksey dd754e28cc
CI / test (push) Failing after 25m40s
CI / deploy-ift (push) Has been skipped
CI / e2e-ift (push) Has been skipped
CI / deploy-stage (push) Has been skipped
CI / e2e-stage (push) Has been skipped
feat: upsert stats counters, daily buckets and ETS tops for admin metrics. Refs EventHub/EventHubBack#42 #43 #44 #45 #46
2026-07-17 23:10:00 +03:00

442 lines
19 KiB
Erlang
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
-module(core_user).
-include("records.hrl").
-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([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]).
%% ─────────────────────────────────────────────────────────────────
%% Значения по умолчанию для необязательных полей
%% ─────────────────────────────────────────────────────────────────
-define(DEFAULT_REASON, <<>>).
-define(DEFAULT_AVATAR_URL, <<"default">>).
-define(DEFAULT_TIMEZONE, <<>>).
-define(DEFAULT_LANGUAGE, <<>>).
-define(DEFAULT_SOCIAL_LINKS, []).
-define(DEFAULT_PHONE, <<>>).
-define(DEFAULT_PREFERENCES, #{}).
-define(DEFAULT_LAST_LOGIN, {{1970,1,1},{0,0,0}}).
%%%-------------------------------------------------------------------
%%% @doc Создание обычного пользователя.
%%% Если email уже существует, возвращает ошибку `{error, email_exists}`.
%%% Все необязательные поля инициализируются значениями по умолчанию
%%% (пустые строки, пустой список и т.д.), чтобы избежать `undefined` в базе.
%%% @end
%%%-------------------------------------------------------------------
-spec create(Email :: binary(), Password :: binary()) ->
{ok, #user{}} | {error, email_exists | term()}.
create(Email, Password) ->
case email_exists(Email) of
true ->
{error, email_exists};
false ->
Id = infra_utils:generate_id(16),
{ok, PasswordHash} = logic_auth:hash_password(Password),
Now = calendar:universal_time(),
User = #user{
id = Id,
email = Email,
password_hash = PasswordHash,
role = user,
status = pending,
reason = ?DEFAULT_REASON,
nickname = extract_nickname(Email),
avatar_url = ?DEFAULT_AVATAR_URL,
timezone = ?DEFAULT_TIMEZONE,
language = ?DEFAULT_LANGUAGE,
social_links = ?DEFAULT_SOCIAL_LINKS,
phone = ?DEFAULT_PHONE,
preferences = ?DEFAULT_PREFERENCES,
last_login = ?DEFAULT_LAST_LOGIN,
created_at = Now,
updated_at = Now
},
F = fun() -> mnesia:write(User), {ok, User} end,
case mnesia:transaction(F) of
{atomic, Result} -> Result;
{aborted, Reason} -> {error, Reason}
end
end.
%%%-------------------------------------------------------------------
%%% @doc Получить пользователя по идентификатору.
%%% @end
%%%-------------------------------------------------------------------
-spec get_by_id(Id :: binary()) -> {ok, #user{}} | {error, not_found}.
get_by_id(Id) ->
case mnesia:dirty_read(user, Id) of
[] -> {error, not_found};
[User] -> {ok, User}
end.
%%%-------------------------------------------------------------------
%%% @doc Получить пользователя по email.
%%% @end
%%%-------------------------------------------------------------------
-spec get_by_email(Email :: binary()) -> {ok, #user{}} | {error, not_found}.
get_by_email(Email) ->
Match = #user{email = Email, _ = '_'},
case mnesia:dirty_match_object(Match) of
[] -> {error, not_found};
[User] -> {ok, User}
end.
%%%-------------------------------------------------------------------
%%% @doc Проверить, существует ли email в базе.
%%% @end
%%%-------------------------------------------------------------------
-spec email_exists(Email :: binary()) -> boolean().
email_exists(Email) ->
case get_by_email(Email) of
{ok, _} -> true;
{error, _} -> false
end.
%%%-------------------------------------------------------------------
%%% @doc Обновить поля пользователя.
%%% `Updates` список пар `{atom(), term()}`.
%%% Поля, не указанные в обновлении, сохраняют прежние значения.
%%% @end
%%%-------------------------------------------------------------------
-spec update(Id :: binary(), Updates :: [{atom(), term()}]) ->
{ok, #user{}} | {error, not_found | term()}.
update(Id, Updates) ->
F = fun() ->
case mnesia:read(user, Id) of
[] -> {error, not_found};
[User] ->
UpdatedUser = apply_updates(User, Updates),
mnesia:write(UpdatedUser),
{ok, UpdatedUser}
end
end,
case mnesia:transaction(F) of
{atomic, Result} -> Result;
{aborted, Reason} -> {error, Reason}
end.
%%%-------------------------------------------------------------------
%%% @doc Обновить время последнего входа пользователя.
%%% Устанавливает `last_login` в текущее UTC-время.
%%% @end
%%%-------------------------------------------------------------------
-spec update_last_login(Id :: binary()) -> {ok, #user{}} | {error, not_found}.
update_last_login(Id) ->
case get_by_id(Id) of
{ok, User} ->
Updated = User#user{last_login = calendar:universal_time()},
mnesia:dirty_write(Updated),
{ok, Updated};
Error -> Error
end.
%%%-------------------------------------------------------------------
%%% @doc Изменить статус и причину пользователя.
%%% @end
%%%-------------------------------------------------------------------
-spec update_status(Id :: binary(), Status :: atom(), Reason :: binary()) ->
{ok, #user{}} | {error, not_found}.
update_status(Id, Status, Reason) ->
case get_by_id(Id) of
{ok, User} ->
Updated = User#user{status = Status, reason = Reason,
updated_at = calendar:universal_time()},
mnesia:dirty_write(Updated),
{ok, Updated};
Error -> Error
end.
%%%-------------------------------------------------------------------
%%% @doc Мягкое удаление пользователя (установка статуса `deleted`).
%%% @end
%%%-------------------------------------------------------------------
-spec delete(Id :: binary()) -> {ok, #user{}} | {error, not_found | term()}.
delete(Id) ->
update(Id, [{status, deleted}]).
%%%-------------------------------------------------------------------
%%% @doc Получить список активных пользователей (без удалённых).
%%% Результат – список карт с ключами `id`, `email`, `role`, `status`, `reason`,
%%% `created_at`, `updated_at`.
%%% @end
%%%-------------------------------------------------------------------
-spec list_users() -> {ok, [map()]}.
list_users() ->
Users = mnesia:dirty_match_object(#user{_ = '_'}),
ActiveUsers = [U || U <- Users, U#user.status =/= deleted],
{ok, [user_to_map(U) || U <- ActiveUsers]}.
%%%-------------------------------------------------------------------
%%% @doc Преобразование записи пользователя в map.
%%% Используется для API‑ответов.
%%% @end
%%%-------------------------------------------------------------------
-spec user_to_map(#user{} | map()) -> map().
user_to_map(User) when is_map(User) ->
#{
id => maps:get(id, User),
email => maps:get(email, User),
role => maps:get(role, User, <<"user">>),
status => maps:get(status, User, <<"active">>),
reason => maps:get(reason, User, ?DEFAULT_REASON),
created_at => maps:get(created_at, User),
updated_at => 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),
reason => User#user.reason,
created_at => User#user.created_at,
updated_at => User#user.updated_at
}.
%%%-------------------------------------------------------------------
%%% @doc Заблокировать пользователя с указанием причины.
%%% @end
%%%-------------------------------------------------------------------
-spec block(Id :: binary(), Reason :: binary()) ->
{ok, #user{}} | {error, not_found}.
block(Id, Reason) ->
case get_by_id(Id) of
{ok, User} ->
Updated = User#user{status = blocked, reason = Reason,
updated_at = calendar:universal_time()},
mnesia:dirty_write(Updated),
{ok, Updated};
Error -> Error
end.
%%%-------------------------------------------------------------------
%%% @doc Разблокировать пользователя с указанием причины.
%%% @end
%%%-------------------------------------------------------------------
-spec unblock(Id :: binary(), Reason :: binary()) ->
{ok, #user{}} | {error, not_found}.
unblock(Id, Reason) ->
case get_by_id(Id) of
{ok, User} ->
Updated = User#user{status = active, reason = Reason,
updated_at = calendar:universal_time()},
mnesia:dirty_write(Updated),
{ok, Updated};
Error -> Error
end.
%%%-------------------------------------------------------------------
%%% @doc Извлекает локальную часть email (до символа `@`).
%%% Если `@` отсутствует, возвращает email целиком.
%%% @end
%%%-------------------------------------------------------------------
-spec extract_nickname(Email :: binary()) -> binary().
extract_nickname(Email) ->
case binary:split(Email, <<"@">>) of
[LocalPart, _Domain] -> LocalPart;
_ -> Email
end.
%%%-------------------------------------------------------------------
%%% @doc Количество всех записей в таблице `user`.
%%% @end
%%%-------------------------------------------------------------------
-spec count_users() -> non_neg_integer().
count_users() ->
mnesia:table_info(user, size).
%%%-------------------------------------------------------------------
%%% @doc Получить всех пользователей (включая удалённых).
%%% @end
%%%-------------------------------------------------------------------
-spec list_all() -> [#user{}].
list_all() ->
mnesia:dirty_match_object(#user{_ = '_'}).
%%%-------------------------------------------------------------------
%%% @doc Подсчёт пользователей, созданных в заданном временном диапазоне.
%%% Возвращает список кортежей `{{Year,Month,Day}, Count}`, отсортированный по дате.
%%% @end
%%%-------------------------------------------------------------------
-spec count_users_by_date(From :: calendar:datetime(), To :: calendar:datetime()) ->
[{{pos_integer(), pos_integer(), pos_integer()}, non_neg_integer()}].
count_users_by_date(From, To) ->
core_stats:with_daily(users_created, From, To, fun() ->
All = mnesia:dirty_match_object(#user{_ = '_'}),
Filtered = lists:filter(fun(U) -> U#user.created_at >= From andalso
U#user.created_at =< To end, All),
Counts = lists:foldl(fun(U, Acc) ->
Day = date_part(U#user.created_at),
case lists:keyfind(Day, 1, Acc) of
false -> [{Day, 1} | Acc];
{Day, C} -> lists:keyreplace(Day, 1, Acc, {Day, C+1})
end
end, [], Filtered),
lists:sort(Counts)
end).
%%%-------------------------------------------------------------------
%%% @doc Количество неподтверждённых пользователей (status = pending).
%%% @end
%%%-------------------------------------------------------------------
-spec count_pending_users() -> non_neg_integer().
count_pending_users() ->
core_stats:with_counter({user_by_status, pending}, fun() ->
Match = #user{status = pending, _ = '_'},
length(mnesia:dirty_match_object(Match))
end).
%%%-------------------------------------------------------------------
%%% @doc Подсчёт неподтверждённых пользователей (status=pending) по дням.
%%% @end
%%%-------------------------------------------------------------------
-spec count_pending_users_by_date(From :: calendar:datetime(), To :: calendar:datetime()) ->
[{{pos_integer(), pos_integer(), pos_integer()}, non_neg_integer()}].
count_pending_users_by_date(From, To) ->
core_stats:with_daily(pending_users_created, From, To, fun() ->
All = mnesia:dirty_match_object(#user{_ = '_'}),
Pending = lists:filter(fun(U) -> U#user.status =:= pending end, All),
Filtered = lists:filter(fun(U) -> U#user.created_at >= From andalso
U#user.created_at =< To end, Pending),
Counts = lists:foldl(fun(U, Acc) ->
Day = date_part(U#user.created_at),
case lists:keyfind(Day, 1, Acc) of
false -> [{Day, 1} | Acc];
{Day, C} -> lists:keyreplace(Day, 1, Acc, {Day, C+1})
end
end, [], Filtered),
lists:sort(Counts)
end).
%%%-------------------------------------------------------------------
%%% @doc Подсчёт пользователей по ролям.
%%% Возвращает список [{Role :: atom(), Count :: non_neg_integer()}].
%%% @end
%%%-------------------------------------------------------------------
-spec count_users_by_role() -> [{atom(), non_neg_integer()}].
count_users_by_role() ->
core_stats:with_dim(user, role, fun() ->
Users = mnesia:dirty_match_object(#user{_ = '_'}),
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)
end).
%%%-------------------------------------------------------------------
%%% @doc Подсчёт пользователей по статусам.
%%% Возвращает список [{Status :: atom(), Count :: non_neg_integer()}].
%%% @end
%%%-------------------------------------------------------------------
-spec count_users_by_status() -> [{atom(), non_neg_integer()}].
count_users_by_status() ->
core_stats:with_dim(user, status, fun() ->
Users = mnesia:dirty_match_object(#user{_ = '_'}),
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)
end).
%%%-------------------------------------------------------------------
%%% @doc Выделить из datetime только дату `{Y,M,D}`.
%%% @end
%%%-------------------------------------------------------------------
-spec date_part(calendar:datetime()) -> {pos_integer(), pos_integer(), pos_integer()}.
date_part({{Y,M,D}, _}) -> {Y,M,D}.
%%%-------------------------------------------------------------------
%%% @doc Применить список обновлений к записи пользователя.
%%% @end
%%%-------------------------------------------------------------------
-spec apply_updates(#user{}, [{atom(), term()}]) -> #user{}.
apply_updates(User, Updates) ->
Updated = lists:foldl(fun({Field, Value}, U) ->
set_field(Field, Value, U)
end, User, Updates),
Updated#user{updated_at = calendar:universal_time()}.
%%%-------------------------------------------------------------------
%%% @doc Установить одно поле записи. Неизвестные поля игнорируются.
%%% @end
%%%-------------------------------------------------------------------
-spec set_field(atom(), term(), #user{}) -> #user{}.
set_field(email, Value, U) -> U#user{email = Value};
set_field(password_hash, Value, U) -> U#user{password_hash = Value};
set_field(role, Value, U) when Value =:= user; Value =:= bot ->
U#user{role = Value};
set_field(status, Value, U) when Value =:= active; Value =:= frozen; Value =:= deleted; Value =:= pending ->
U#user{status = Value};
set_field(reason, Value, U) -> U#user{reason = Value};
set_field(nickname, Value, U) -> U#user{nickname = Value};
set_field(avatar_url, Value, U) -> U#user{avatar_url = Value};
set_field(timezone, Value, U) -> U#user{timezone = Value};
set_field(language, Value, U) -> U#user{language = Value};
set_field(social_links, Value, U) -> U#user{social_links = Value};
set_field(phone, Value, U) -> U#user{phone = Value};
set_field(preferences, Value, U) -> U#user{preferences = Value};
set_field(_, _, U) -> U.
%%%-------------------------------------------------------------------
%%% @doc Создание бота (аналогично пользователю, но с ролью `bot`).
%%% @end
%%%-------------------------------------------------------------------
-spec create_bot(Email :: binary(), Password :: binary()) ->
{ok, #user{}} | {error, email_exists | invalid_email}.
create_bot(Email, Password) ->
case email_exists(Email) of
true ->
{error, email_exists};
false ->
case mnesia:dirty_index_read(user, Email, email) of
[] ->
{ok, PasswordHash} = logic_auth:hash_password(Password),
Id = infra_utils:generate_id(16),
Now = calendar:universal_time(),
User = #user{
id = Id,
email = Email,
password_hash = PasswordHash,
role = bot,
status = active,
reason = ?DEFAULT_REASON,
nickname = extract_nickname(Email),
avatar_url = ?DEFAULT_AVATAR_URL,
timezone = ?DEFAULT_TIMEZONE,
language = ?DEFAULT_LANGUAGE,
social_links = ?DEFAULT_SOCIAL_LINKS,
phone = ?DEFAULT_PHONE,
preferences = ?DEFAULT_PREFERENCES,
last_login = ?DEFAULT_LAST_LOGIN,
created_at = Now,
updated_at = Now
},
ok = mnesia:dirty_write(User),
{ok, User};
_ ->
{error, duplicate_email}
end
end.
%%%-------------------------------------------------------------------
%%% @doc Удаление бота (физическое удаление записи, не мягкое).
%%% @end
%%%-------------------------------------------------------------------
-spec delete_bot(Id :: binary()) -> ok | {error, not_found}.
delete_bot(Id) ->
case mnesia:dirty_read({user, Id}) of
[#user{role = bot}] ->
mnesia:dirty_delete({user, Id}),
ok;
[] ->
{error, not_found}
end.