-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.