diff --git a/src/core/core_admin.erl b/src/core/core_admin.erl
index 8fc0c6e..2680f05 100644
--- a/src/core/core_admin.erl
+++ b/src/core/core_admin.erl
@@ -1,9 +1,28 @@
-module(core_admin).
-include("records.hrl").
--export([create/3, get_by_email/1, get_by_id/1, list_all/0,
- update_role/2, block/1, unblock/1, update_last_login/1]).
+-export([create/3, get_by_email/1, get_by_id/1, list_all/0, update_role/2, block/1, unblock/1,
+ update_last_login/1]).
-export([update/2, delete/1]).
+%% ─────────────────────────────────────────────────────────────────
+%% Значения по умолчанию для необязательных полей
+%% ─────────────────────────────────────────────────────────────────
+-define(DEFAULT_AVATAR_URL, <<"default">>).
+-define(DEFAULT_TIMEZONE, <<>>).
+-define(DEFAULT_LANGUAGE, <<>>).
+-define(DEFAULT_PHONE, <<>>).
+-define(DEFAULT_PREFERENCES, #{}).
+-define(DEFAULT_LAST_LOGIN, {{1970,1,1},{0,0,0}}).
+
+%%%-------------------------------------------------------------------
+%%% @doc Создание администратора.
+%%% Если email уже существует, возвращает ошибку `{error, email_exists}`.
+%%% Все необязательные поля инициализируются значениями по умолчанию,
+%%% nickname устанавливается в локальную часть email (до @).
+%%% @end
+%%%-------------------------------------------------------------------
+-spec create(Email :: binary(), Password :: binary(), Role :: atom()) ->
+ {ok, #admin{}} | {error, email_exists | term()}.
create(Email, Password, Role) ->
case get_by_email(Email) of
{ok, _} ->
@@ -13,23 +32,38 @@ create(Email, Password, Role) ->
{ok, Hash} = argon2:hash(Password),
Now = calendar:universal_time(),
Admin = #admin{
- id = Id,
- email = Email,
+ id = Id,
+ email = Email,
password_hash = Hash,
- role = Role,
- status = active,
- created_at = Now,
- updated_at = Now
+ role = Role,
+ status = active,
+ nickname = extract_nickname(Email),
+ avatar_url = ?DEFAULT_AVATAR_URL,
+ timezone = ?DEFAULT_TIMEZONE,
+ language = ?DEFAULT_LANGUAGE,
+ phone = ?DEFAULT_PHONE,
+ preferences = ?DEFAULT_PREFERENCES,
+ last_login = ?DEFAULT_LAST_LOGIN,
+ created_at = Now,
+ updated_at = Now
},
mnesia:dirty_write(Admin),
{ok, Admin}
end.
-%% Обновление администратора (любые поля)
+%%%-------------------------------------------------------------------
+%%% @doc Обновление администратора (любые поля).
+%%% `Updates` – список пар `[{atom(), term()}]`.
+%%% Поля, не указанные в обновлении, сохраняют прежние значения.
+%%% @end
+%%%-------------------------------------------------------------------
+-spec update(AdminId :: binary(), Updates :: [{atom(), term()}]) ->
+ {ok, #admin{}} | {error, not_found | term()}.
update(AdminId, Updates) ->
F = fun() ->
case mnesia:read(admin, AdminId) of
- [] -> {error, not_found};
+ [] ->
+ {error, not_found};
[Admin] ->
UpdatedAdmin = apply_updates(Admin, Updates),
mnesia:write(UpdatedAdmin),
@@ -41,22 +75,43 @@ update(AdminId, Updates) ->
{aborted, Reason} -> {error, Reason}
end.
+%%%-------------------------------------------------------------------
+%%% @doc Получить администратора по email.
+%%% @end
+%%%-------------------------------------------------------------------
+-spec get_by_email(Email :: binary()) -> {ok, #admin{}} | {error, not_found}.
get_by_email(Email) ->
Match = #admin{email = Email, _ = '_'},
case mnesia:dirty_match_object(Match) of
[Admin] -> {ok, Admin};
- [] -> {error, not_found}
+ [] -> {error, not_found}
end.
+%%%-------------------------------------------------------------------
+%%% @doc Получить администратора по идентификатору.
+%%% @end
+%%%-------------------------------------------------------------------
+-spec get_by_id(Id :: binary()) -> {ok, #admin{}} | {error, not_found}.
get_by_id(Id) ->
case mnesia:dirty_read({admin, Id}) of
[Admin] -> {ok, Admin};
- [] -> {error, not_found}
+ [] -> {error, not_found}
end.
+%%%-------------------------------------------------------------------
+%%% @doc Получить список всех администраторов.
+%%% @end
+%%%-------------------------------------------------------------------
+-spec list_all() -> [#admin{}].
list_all() ->
mnesia:dirty_match_object(#admin{_ = '_'}).
+%%%-------------------------------------------------------------------
+%%% @doc Изменить роль администратора.
+%%% @end
+%%%-------------------------------------------------------------------
+-spec update_role(Id :: binary(), NewRole :: atom()) ->
+ {ok, #admin{}} | {error, not_found}.
update_role(Id, NewRole) when is_atom(NewRole) ->
case get_by_id(Id) of
{ok, Admin} ->
@@ -66,6 +121,12 @@ update_role(Id, NewRole) when is_atom(NewRole) ->
Error -> Error
end.
+%%%-------------------------------------------------------------------
+%%% @doc Обновить время последнего входа администратора.
+%%% Устанавливает `last_login` в текущее UTC-время.
+%%% @end
+%%%-------------------------------------------------------------------
+-spec update_last_login(Id :: binary()) -> {ok, #admin{}} | {error, not_found}.
update_last_login(Id) ->
case get_by_id(Id) of
{ok, Admin} ->
@@ -75,12 +136,28 @@ update_last_login(Id) ->
Error -> Error
end.
+%%%-------------------------------------------------------------------
+%%% @doc Заблокировать администратора.
+%%% @end
+%%%-------------------------------------------------------------------
+-spec block(Id :: binary()) -> {ok, #admin{}} | {error, not_found}.
block(Id) ->
update_status(Id, blocked).
+%%%-------------------------------------------------------------------
+%%% @doc Разблокировать администратора.
+%%% @end
+%%%-------------------------------------------------------------------
+-spec unblock(Id :: binary()) -> {ok, #admin{}} | {error, not_found}.
unblock(Id) ->
update_status(Id, active).
+%%%-------------------------------------------------------------------
+%%% @doc Изменить статус администратора (внутренняя функция).
+%%% @end
+%%%-------------------------------------------------------------------
+-spec update_status(Id :: binary(), Status :: atom()) ->
+ {ok, #admin{}} | {error, not_found}.
update_status(Id, Status) ->
case get_by_id(Id) of
{ok, Admin} ->
@@ -90,7 +167,10 @@ update_status(Id, Status) ->
Error -> Error
end.
-%% Физическое удаление администратора из базы
+%%%-------------------------------------------------------------------
+%%% @doc Физическое удаление администратора из базы.
+%%% @end
+%%%-------------------------------------------------------------------
-spec delete(binary()) -> {ok, deleted} | {error, not_found}.
delete(AdminId) ->
case get_by_id(AdminId) of
@@ -104,19 +184,38 @@ delete(AdminId) ->
%%% ВНУТРЕННИЕ ФУНКЦИИ
%%%===================================================================
-apply_updates(Admin, []) -> Admin;
+%%%-------------------------------------------------------------------
+%%% @doc Применить список обновлений к записи администратора.
+%%% @end
+%%%-------------------------------------------------------------------
+-spec apply_updates(#admin{}, [{atom(), term()}]) -> #admin{}.
+apply_updates(Admin, []) ->
+ Admin;
apply_updates(Admin, [{Field, Value} | Rest]) ->
NewAdmin = case Field of
- email -> Admin#admin{email = Value};
+ email -> Admin#admin{email = Value};
password_hash -> Admin#admin{password_hash = Value};
- role -> Admin#admin{role = Value};
- status -> Admin#admin{status = Value};
- nickname -> Admin#admin{nickname = Value};
- avatar_url -> Admin#admin{avatar_url = Value};
- timezone -> Admin#admin{timezone = Value};
- language -> Admin#admin{language = Value};
- phone -> Admin#admin{phone = Value};
- preferences -> Admin#admin{preferences = Value};
- _ -> Admin
+ role -> Admin#admin{role = Value};
+ status -> Admin#admin{status = Value};
+ nickname -> Admin#admin{nickname = Value};
+ avatar_url -> Admin#admin{avatar_url = Value};
+ timezone -> Admin#admin{timezone = Value};
+ language -> Admin#admin{language = Value};
+ phone -> Admin#admin{phone = Value};
+ preferences -> Admin#admin{preferences = Value};
+ last_login -> Admin#admin{last_login = Value};
+ _ -> Admin
end,
- apply_updates(NewAdmin#admin{updated_at = calendar:universal_time()}, Rest).
\ No newline at end of file
+ apply_updates(NewAdmin#admin{updated_at = calendar:universal_time()}, Rest).
+
+%%%-------------------------------------------------------------------
+%%% @doc Извлекает локальную часть email (до символа `@`).
+%%% Если `@` отсутствует, возвращает email целиком.
+%%% @end
+%%%-------------------------------------------------------------------
+-spec extract_nickname(Email :: binary()) -> binary().
+extract_nickname(Email) ->
+ case binary:split(Email, <<"@">>) of
+ [LocalPart, _Domain] -> LocalPart;
+ _ -> Email
+ end.
\ No newline at end of file
diff --git a/src/core/core_admin_audit.erl b/src/core/core_admin_audit.erl
index e0cf32c..5d1a4fa 100644
--- a/src/core/core_admin_audit.erl
+++ b/src/core/core_admin_audit.erl
@@ -1,30 +1,76 @@
-module(core_admin_audit).
-include("records.hrl").
--export([log/7, log/8, list/0, list/1]). % ← добавили log/8
+-export([log/7, log/8, list/0, list/1]).
-export([count_actions_by_admin/2]).
+-define(DEFAULT_REASON, <<>>). % причина по умолчанию, если не указана
+
+%%%-------------------------------------------------------------------
+%%% @doc Записать событие аудита без указания причины.
+%%% В поле `reason` будет записана пустая строка.
+%%% @end
+%%%-------------------------------------------------------------------
+-spec log(AdminId :: binary(),
+ Email :: binary(),
+ Role :: atom(),
+ Action :: binary(),
+ EntityType :: binary(),
+ EntityId :: term(),
+ Ip :: binary()) -> {ok, #admin_audit{}}.
log(AdminId, Email, Role, Action, EntityType, EntityId, Ip) ->
- log(AdminId, Email, Role, Action, EntityType, EntityId, Ip, undefined).
+ log(AdminId, Email, Role, Action, EntityType, EntityId, Ip, ?DEFAULT_REASON).
+
+%%%-------------------------------------------------------------------
+%%% @doc Записать событие аудита с указанием причины.
+%%% @end
+%%%-------------------------------------------------------------------
+-spec log(AdminId :: binary(),
+ Email :: binary(),
+ Role :: atom(),
+ Action :: binary(),
+ EntityType :: binary(),
+ EntityId :: term(),
+ Ip :: binary(),
+ Reason :: binary()) -> {ok, #admin_audit{}}.
log(AdminId, Email, Role, Action, EntityType, EntityId, Ip, Reason) ->
Id = infra_utils:generate_id(9),
Entry = #admin_audit{
- id = Id,
- admin_id = AdminId,
- email = Email,
- role = Role,
- action = Action,
+ id = Id,
+ admin_id = AdminId,
+ email = Email,
+ role = Role,
+ action = Action,
entity_type = EntityType,
- entity_id = EntityId,
- timestamp = calendar:universal_time(),
- ip = Ip,
- reason = Reason
+ entity_id = EntityId,
+ timestamp = calendar:universal_time(),
+ ip = Ip,
+ reason = Reason
},
mnesia:dirty_write(Entry),
{ok, Entry}.
+%%%-------------------------------------------------------------------
+%%% @doc Получить все записи аудита.
+%%% @end
+%%%-------------------------------------------------------------------
+-spec list() -> [#admin_audit{}].
list() ->
mnesia:dirty_match_object(#admin_audit{_ = '_'}).
+%%%-------------------------------------------------------------------
+%%% @doc Получить записи аудита с фильтрацией.
+%%%
+%%% Поддерживаемые ключи фильтрации (proplist):
+%%%
+%%% - `admin_id` – binary()
+%%% - `action` – binary()
+%%% - `date_from` – calendar:datetime()
+%%% - `date_to` – calendar:datetime()
+%%%
+%%% Все фильтры опциональны, применяются по `AND`.
+%%% @end
+%%%-------------------------------------------------------------------
+-spec list(Filters :: proplists:proplist()) -> [#admin_audit{}].
list(Filters) ->
All = list(),
lists:filter(fun(E) ->
@@ -49,6 +95,11 @@ list(Filters) ->
end
end, All).
+%%%-------------------------------------------------------------------
+%%% @doc Подсчитать количество действий определённого типа для администратора.
+%%% @end
+%%%-------------------------------------------------------------------
+-spec count_actions_by_admin(AdminId :: binary(), Action :: binary()) -> non_neg_integer().
count_actions_by_admin(AdminId, Action) ->
Match = #admin_audit{admin_id = AdminId, action = Action, _ = '_'},
length(mnesia:dirty_match_object(Match)).
\ No newline at end of file
diff --git a/src/core/core_admin_session.erl b/src/core/core_admin_session.erl
index 199b62f..6e48093 100644
--- a/src/core/core_admin_session.erl
+++ b/src/core/core_admin_session.erl
@@ -1,34 +1,58 @@
+%%%-------------------------------------------------------------------
+%%% @doc Управление сессиями администраторов (refresh-токены).
+%%%
+%%% Запись `admin_session` хранит refresh-токен, идентификатор администратора,
+%%% время истечения и тип токена. Все поля обязательны и инициализируются
+%%% при создании, поэтому `undefined` в базе не возникает.
+%%% @end
+%%%-------------------------------------------------------------------
-module(core_admin_session).
-include("records.hrl").
-export([create/2, validate/1, delete/1]).
+%%%-------------------------------------------------------------------
+%%% @doc Создать новую сессию.
+%%% Токен действителен 30 дней с момента создания.
+%%% @end
+%%%-------------------------------------------------------------------
-spec create(AdminId :: binary(), RefreshToken :: binary()) -> ok.
create(AdminId, RefreshToken) ->
Session = #admin_session{
- token = RefreshToken,
- admin_id = AdminId,
+ token = RefreshToken,
+ admin_id = AdminId,
expires_at = calendar:gregorian_seconds_to_datetime(
- calendar:datetime_to_gregorian_seconds(calendar:universal_time()) + 30 * 24 * 3600),
- type = refresh
+ calendar:datetime_to_gregorian_seconds(
+ calendar:universal_time()) + 30 * 24 * 3600),
+ type = refresh
},
mnesia:dirty_write(Session),
ok.
--spec validate(Token :: binary()) -> {ok, AdminId :: binary()} | {error, not_found | expired}.
+%%%-------------------------------------------------------------------
+%%% @doc Проверить refresh-токен.
+%%% Если токен действителен, возвращает `{ok, AdminId}`.
+%%% Если истёк — удаляет запись и возвращает `{error, expired}`.
+%%% Если не найден — `{error, not_found}`.
+%%% @end
+%%%-------------------------------------------------------------------
+-spec validate(Token :: binary()) ->
+ {ok, AdminId :: binary()} | {error, not_found | expired}.
validate(Token) ->
case mnesia:dirty_read({admin_session, Token}) of
[Session] ->
case Session#admin_session.expires_at > calendar:universal_time() of
- true ->
- {ok, Session#admin_session.admin_id};
+ true -> {ok, Session#admin_session.admin_id};
false ->
mnesia:dirty_delete({admin_session, Token}),
{error, expired}
end;
- [] ->
- {error, not_found}
+ [] -> {error, not_found}
end.
+%%%-------------------------------------------------------------------
+%%% @doc Удалить сессию (отзыв refresh-токена).
+%%% @end
+%%%-------------------------------------------------------------------
-spec delete(Token :: binary()) -> ok.
delete(Token) ->
mnesia:dirty_delete({admin_session, Token}),
diff --git a/src/core/core_banned_words.erl b/src/core/core_banned_words.erl
index 30a51c8..e84175e 100644
--- a/src/core/core_banned_words.erl
+++ b/src/core/core_banned_words.erl
@@ -1,74 +1,89 @@
-module(core_banned_words).
--export([list_banned_words/0,
- add_banned_word/2,
- remove_banned_word/1,
- update_banned_word/2]).
-
-include("records.hrl").
+-export([list_banned_words/0, add_banned_word/2, remove_banned_word/1]).
+-export([is_word_banned/1, update_banned_word/2]).
+%%%-------------------------------------------------------------------
+%%% @doc Получить список всех запрещённых слов.
+%%% @end
+%%%-------------------------------------------------------------------
+-spec list_banned_words() -> [#banned_word{}].
list_banned_words() ->
mnesia:dirty_match_object(#banned_word{_ = '_'}).
+%%%-------------------------------------------------------------------
+%%% @doc Добавить слово в список запрещённых.
+%%% Если слово уже существует, возвращает `{error, already_exists}`.
+%%% Поле `added_by` заполняется идентификатором администратора.
+%%% @end
+%%%-------------------------------------------------------------------
+-spec add_banned_word(Word :: binary(), AddedBy :: binary()) ->
+ {ok, #banned_word{}} | {error, already_exists}.
add_banned_word(Word, AddedBy) ->
- Id = infra_utils:generate_id(9),
- Now = calendar:universal_time(),
- BW = #banned_word{id = Id, word = Word, added_by = AddedBy, added_at = Now},
- case mnesia:transaction(fun() ->
- case mnesia:match_object(#banned_word{word = Word, _ = '_'}) of
- [] -> mnesia:write(BW), {ok, BW};
- _ -> mnesia:abort(already_exists)
- end
- end) of
- {atomic, {ok, BWRec}} -> {ok, BWRec};
- {aborted, already_exists} -> {error, already_exists};
- {aborted, Reason} -> {error, Reason}
+ case is_word_banned(Word) of
+ true ->
+ {error, already_exists};
+ false ->
+ Id = infra_utils:generate_id(9),
+ Now = calendar:universal_time(),
+ BannedWord = #banned_word{
+ id = Id,
+ word = Word,
+ added_by = AddedBy,
+ added_at = Now
+ },
+ mnesia:dirty_write(BannedWord),
+ {ok, BannedWord}
end.
-%% Поиск запрещённого слова по его тексту
--spec get_by_word(binary()) -> {ok, #banned_word{}} | {error, not_found}.
-get_by_word(Word) ->
- case mnesia:dirty_match_object(#banned_word{word = Word, _ = '_'}) of
- [Record] -> {ok, Record};
- [] -> {error, not_found}
- end.
-
-%% Проверка существования слова (по тексту)
-word_exists(Word) ->
- case get_by_word(Word) of
- {ok, _} -> true;
- _ -> false
- end.
-
-%% Обновление запрещённого слова
-update_banned_word(OldWord, NewWord) ->
- case get_by_word(OldWord) of
- {ok, Record} ->
- case NewWord =:= OldWord orelse not word_exists(NewWord) of
- true ->
- NewRecord = Record#banned_word{word = NewWord},
- F = fun() ->
- %% Если первичный ключ — id, просто обновляем запись
- mnesia:write(NewRecord),
- ok
- end,
- case mnesia:transaction(F) of
- {atomic, ok} -> {ok, NewRecord};
- {aborted, Reason} -> {error, Reason}
- end;
- false ->
- {error, already_exists}
- end;
- {error, _} = Error -> Error
- end.
-
-%% Удаление слова (используем get_by_word для надёжности)
+%%%-------------------------------------------------------------------
+%%% @doc Удалить слово из списка запрещённых.
+%%% @end
+%%%-------------------------------------------------------------------
+-spec remove_banned_word(Word :: binary()) -> {ok, deleted} | {error, not_found}.
remove_banned_word(Word) ->
- case get_by_word(Word) of
- {ok, Record} ->
- F = fun() -> mnesia:delete({banned_word, Record#banned_word.id}), ok end,
- case mnesia:transaction(F) of
- {atomic, ok} -> {ok, Record};
- {aborted, Reason} -> {error, Reason}
+ case is_word_banned(Word) of
+ true ->
+ Match = #banned_word{word = Word, _ = '_'},
+ [Record] = mnesia:dirty_match_object(Match),
+ mnesia:dirty_delete({banned_word, Record#banned_word.id}),
+ {ok, deleted};
+ false ->
+ {error, not_found}
+ end.
+
+%%%-------------------------------------------------------------------
+%%% @doc Проверить, является ли слово запрещённым.
+%%% @end
+%%%-------------------------------------------------------------------
+-spec is_word_banned(Word :: binary()) -> boolean().
+is_word_banned(Word) ->
+ Match = #banned_word{word = Word, _ = '_'},
+ case mnesia:dirty_match_object(Match) of
+ [] -> false;
+ _ -> true
+ end.
+
+%%%-------------------------------------------------------------------
+%%% @doc Обновить запрещённое слово (переименовать).
+%%% Если `NewWord` уже существует, возвращает `{error, already_exists}`.
+%%% @end
+%%%-------------------------------------------------------------------
+-spec update_banned_word(OldWord :: binary(), NewWord :: binary()) ->
+ {ok, #banned_word{}} | {error, not_found | already_exists}.
+update_banned_word(OldWord, NewWord) ->
+ case is_word_banned(OldWord) of
+ true ->
+ case is_word_banned(NewWord) andalso OldWord =/= NewWord of
+ true ->
+ {error, already_exists};
+ false ->
+ Match = #banned_word{word = OldWord, _ = '_'},
+ [Record] = mnesia:dirty_match_object(Match),
+ Updated = Record#banned_word{word = NewWord},
+ mnesia:dirty_write(Updated),
+ {ok, Updated}
end;
- {error, _} = Error -> Error
+ false ->
+ {error, not_found}
end.
\ No newline at end of file
diff --git a/src/core/core_booking.erl b/src/core/core_booking.erl
index d0ac9b5..0d314e0 100644
--- a/src/core/core_booking.erl
+++ b/src/core/core_booking.erl
@@ -1,41 +1,127 @@
-module(core_booking).
-include("records.hrl").
+-export([create/3, get_by_id/1, list_by_event/1, list_by_user/1, update/2, delete/1]).
+-export([count_bookings/0, get_by_event_and_user/2]).
+-export([list_all/0]). % ← добавлено
--export([create/2, get_by_id/1, get_by_event_and_user/2, list_by_event/1, list_by_user/1]).
--export([update_status/2, delete/1]).
--export([count_bookings/0]).
+%% ─────────────────────────────────────────────────────────────────
+%% Значения по умолчанию для необязательных полей
+%% ─────────────────────────────────────────────────────────────────
+-define(DEFAULT_NOTES, <<>>).
+-define(DEFAULT_REMINDER_SENT, false).
+-define(DEFAULT_CONFIRMED_AT, {{1970,1,1},{0,0,0}}).
-%% Создание бронирования
-create(EventId, UserId) ->
+%%%-------------------------------------------------------------------
+%%% @doc Создание бронирования.
+%%% @end
+%%%-------------------------------------------------------------------
+-spec create(EventId :: binary(), UserId :: binary(), Status :: atom()) ->
+ {ok, #booking{}} | {error, term()}.
+create(EventId, UserId, Status) ->
Id = infra_utils:generate_id(16),
+ Now = calendar:universal_time(),
Booking = #booking{
- id = Id,
- event_id = EventId,
- user_id = UserId,
- status = pending,
- confirmed_at = undefined,
- created_at = calendar:universal_time(),
- updated_at = calendar:universal_time()
+ id = Id,
+ event_id = EventId,
+ user_id = UserId,
+ status = Status,
+ notes = ?DEFAULT_NOTES,
+ reminder_sent = ?DEFAULT_REMINDER_SENT,
+ confirmed_at = ?DEFAULT_CONFIRMED_AT,
+ created_at = Now,
+ updated_at = Now
},
-
- F = fun() ->
- mnesia:write(Booking),
- {ok, Booking}
- end,
-
+ F = fun() -> mnesia:write(Booking), {ok, Booking} end,
case mnesia:transaction(F) of
{atomic, Result} -> Result;
{aborted, Reason} -> {error, Reason}
end.
-%% Получение бронирования по ID
+%%%-------------------------------------------------------------------
+%%% @doc Получить бронирование по идентификатору.
+%%% @end
+%%%-------------------------------------------------------------------
+-spec get_by_id(Id :: binary()) -> {ok, #booking{}} | {error, not_found}.
get_by_id(Id) ->
case mnesia:dirty_read(booking, Id) of
[] -> {error, not_found};
[Booking] -> {ok, Booking}
end.
-%% Получение бронирования по событию и пользователю
+%%%-------------------------------------------------------------------
+%%% @doc Список бронирований для события.
+%%% @end
+%%%-------------------------------------------------------------------
+-spec list_by_event(EventId :: binary()) -> {ok, [#booking{}]}.
+list_by_event(EventId) ->
+ Match = #booking{event_id = EventId, _ = '_'},
+ Bookings = mnesia:dirty_match_object(Match),
+ {ok, Bookings}.
+
+%%%-------------------------------------------------------------------
+%%% @doc Список бронирований пользователя.
+%%% @end
+%%%-------------------------------------------------------------------
+-spec list_by_user(UserId :: binary()) -> {ok, [#booking{}]}.
+list_by_user(UserId) ->
+ Match = #booking{user_id = UserId, _ = '_'},
+ Bookings = mnesia:dirty_match_object(Match),
+ {ok, Bookings}.
+
+%%%-------------------------------------------------------------------
+%%% @doc Получить все бронирования (административный режим).
+%%% @end
+%%%-------------------------------------------------------------------
+-spec list_all() -> [#booking{}].
+list_all() ->
+ mnesia:dirty_match_object(#booking{_ = '_'}).
+
+%%%-------------------------------------------------------------------
+%%% @doc Обновить поля бронирования.
+%%% @end
+%%%-------------------------------------------------------------------
+-spec update(Id :: binary(), Updates :: [{atom(), term()}]) ->
+ {ok, #booking{}} | {error, not_found | term()}.
+update(Id, Updates) ->
+ F = fun() ->
+ case mnesia:read(booking, Id) of
+ [] -> {error, not_found};
+ [Booking] ->
+ UpdatedBooking = apply_updates(Booking, Updates),
+ mnesia:write(UpdatedBooking),
+ {ok, UpdatedBooking}
+ end
+ end,
+ case mnesia:transaction(F) of
+ {atomic, Result} -> Result;
+ {aborted, Reason} -> {error, Reason}
+ end.
+
+%%%-------------------------------------------------------------------
+%%% @doc Удаление бронирования (физическое).
+%%% @end
+%%%-------------------------------------------------------------------
+-spec delete(Id :: binary()) -> ok | {error, not_found}.
+delete(Id) ->
+ case mnesia:dirty_read(booking, Id) of
+ [] -> {error, not_found};
+ [_] -> mnesia:dirty_delete(booking, Id), ok
+ end.
+
+%%%-------------------------------------------------------------------
+%%% @doc Количество бронирований.
+%%% @end
+%%%-------------------------------------------------------------------
+-spec count_bookings() -> non_neg_integer().
+count_bookings() ->
+ mnesia:table_info(booking, size).
+
+%%%-------------------------------------------------------------------
+%%% @doc Получить бронирование по событию и пользователю.
+%%% @end
+%%%-------------------------------------------------------------------
+-spec get_by_event_and_user(EventId :: binary(), UserId :: binary()) ->
+ {ok, #booking{}} | {error, not_found}.
get_by_event_and_user(EventId, UserId) ->
Match = #booking{event_id = EventId, user_id = UserId, _ = '_'},
case mnesia:dirty_match_object(Match) of
@@ -43,58 +129,19 @@ get_by_event_and_user(EventId, UserId) ->
[Booking] -> {ok, Booking}
end.
-%% Список бронирований события
-list_by_event(EventId) ->
- Match = #booking{event_id = EventId, _ = '_'},
- Bookings = mnesia:dirty_match_object(Match),
- {ok, Bookings}.
+%%%===================================================================
+%%% ВНУТРЕННИЕ ФУНКЦИИ
+%%%===================================================================
-%% Список бронирований пользователя
-list_by_user(UserId) ->
- Match = #booking{user_id = UserId, _ = '_'},
- Bookings = mnesia:dirty_match_object(Match),
- {ok, Bookings}.
+apply_updates(Booking, Updates) ->
+ Updated = lists:foldl(fun({Field, Value}, B) -> set_field(Field, Value, B) end,
+ Booking, Updates),
+ Updated#booking{updated_at = calendar:universal_time()}.
-%% Обновление статуса бронирования
-update_status(Id, Status) when Status =:= pending; Status =:= confirmed; Status =:= cancelled ->
- F = fun() ->
- case mnesia:read(booking, Id) of
- [] ->
- {error, not_found};
- [Booking] ->
- Updated = Booking#booking{
- status = Status,
- confirmed_at = case Status of
- confirmed -> calendar:universal_time();
- _ -> Booking#booking.confirmed_at
- end,
- updated_at = calendar:universal_time()
- },
- mnesia:write(Updated),
- {ok, Updated}
- end
- end,
-
- case mnesia:transaction(F) of
- {atomic, Result} -> Result;
- {aborted, Reason} -> {error, Reason}
- end.
-
-%% Удаление бронирования (hard delete)
-delete(Id) ->
- F = fun() ->
- case mnesia:read(booking, Id) of
- [] ->
- {error, not_found};
- [Booking] ->
- mnesia:delete_object(Booking),
- {ok, deleted}
- end
- end,
-
- case mnesia:transaction(F) of
- {atomic, Result} -> Result;
- {aborted, Reason} -> {error, Reason}
- end.
-
-count_bookings() -> mnesia:table_info(booking, size).
\ No newline at end of file
+set_field(status, Value, B) -> B#booking{status = Value};
+set_field(notes, Value, B) -> B#booking{notes = Value};
+set_field(reminder_sent, Value, B) -> B#booking{reminder_sent = Value};
+set_field(confirmed_at, Value, B) -> B#booking{confirmed_at = Value};
+set_field(event_id, Value, B) -> B#booking{event_id = Value};
+set_field(user_id, Value, B) -> B#booking{user_id = Value};
+set_field(_, _, B) -> B.
\ No newline at end of file
diff --git a/src/core/core_calendar.erl b/src/core/core_calendar.erl
index 5dc60c8..55b7cfa 100644
--- a/src/core/core_calendar.erl
+++ b/src/core/core_calendar.erl
@@ -2,47 +2,60 @@
-include("records.hrl").
-export([create/4, create/5, get_by_id/1, list_by_owner/1, update/2, delete/1]).
-export([count_calendars/0, list_all/0]).
--export([freeze/2, unfreeze/2]). % ← новые функции
+-export([freeze/2, unfreeze/2]).
-%% Создание календаря
+%% ─────────────────────────────────────────────────────────────────
+%% Значения по умолчанию для необязательных полей
+%% ─────────────────────────────────────────────────────────────────
+-define(DEFAULT_SHORT_NAME, <<>>).
+-define(DEFAULT_CATEGORY, <<>>).
+-define(DEFAULT_COLOR, <<>>).
+-define(DEFAULT_IMAGE_URL, <<>>).
+-define(DEFAULT_SETTINGS, #{}).
+-define(DEFAULT_TAGS, []).
+-define(DEFAULT_REASON, <<>>).
+
+%%%-------------------------------------------------------------------
+%%% @doc Создание календаря с типом `personal` и подтверждением по умолчанию.
+%%% Все необязательные поля инициализируются значениями по умолчанию.
+%%% @end
+%%%-------------------------------------------------------------------
+-spec create(OwnerId :: binary(), Title :: binary(), Description :: binary(),
+ Confirmation :: auto | manual | {timeout, integer()}) ->
+ {ok, #calendar{}} | {error, term()}.
create(OwnerId, Title, Description, Confirmation) ->
- Id = infra_utils:generate_id(16),
- Calendar = #calendar{
- id = Id,
- owner_id = OwnerId,
- title = Title,
- description = Description,
- tags = [],
- type = personal,
- confirmation = Confirmation,
- rating_avg = 0.0,
- rating_count = 0,
- status = active,
- created_at = calendar:universal_time(),
- updated_at = calendar:universal_time()
- },
- F = fun() -> mnesia:write(Calendar), {ok, Calendar} end,
- case mnesia:transaction(F) of
- {atomic, Result} -> Result;
- {aborted, Reason} -> {error, Reason}
- end.
+ create(OwnerId, Title, Description, Confirmation, personal).
-%% Создание календаря с типом и политикой
+%%%-------------------------------------------------------------------
+%%% @doc Создание календаря с указанием типа и политики подтверждения.
+%%% @end
+%%%-------------------------------------------------------------------
+-spec create(OwnerId :: binary(), Title :: binary(), Description :: binary(),
+ Confirmation :: auto | manual | {timeout, integer()},
+ Type :: personal | commercial) ->
+ {ok, #calendar{}} | {error, term()}.
create(OwnerId, Title, Description, Confirmation, Type) ->
Id = infra_utils:generate_id(16),
+ Now = calendar:universal_time(),
Calendar = #calendar{
- id = Id,
- owner_id = OwnerId,
- title = Title,
- description = Description,
- tags = [],
- type = Type,
- confirmation = Confirmation,
- rating_avg = 0.0,
- rating_count = 0,
- status = active,
- created_at = calendar:universal_time(),
- updated_at = calendar:universal_time()
+ id = Id,
+ owner_id = OwnerId,
+ title = Title,
+ description = Description,
+ short_name = ?DEFAULT_SHORT_NAME,
+ category = ?DEFAULT_CATEGORY,
+ color = ?DEFAULT_COLOR,
+ image_url = ?DEFAULT_IMAGE_URL,
+ settings = ?DEFAULT_SETTINGS,
+ tags = ?DEFAULT_TAGS,
+ type = Type,
+ confirmation = Confirmation,
+ rating_avg = 0.0,
+ rating_count = 0,
+ status = active,
+ reason = ?DEFAULT_REASON,
+ created_at = Now,
+ updated_at = Now
},
F = fun() -> mnesia:write(Calendar), {ok, Calendar} end,
case mnesia:transaction(F) of
@@ -50,20 +63,35 @@ create(OwnerId, Title, Description, Confirmation, Type) ->
{aborted, Reason} -> {error, Reason}
end.
-%% Получение календаря по ID
+%%%-------------------------------------------------------------------
+%%% @doc Получить календарь по идентификатору.
+%%% @end
+%%%-------------------------------------------------------------------
+-spec get_by_id(Id :: binary()) -> {ok, #calendar{}} | {error, not_found}.
get_by_id(Id) ->
case mnesia:dirty_read(calendar, Id) of
[] -> {error, not_found};
[Calendar] -> {ok, Calendar}
end.
-%% Список календарей владельца
+%%%-------------------------------------------------------------------
+%%% @doc Список активных календарей владельца.
+%%% @end
+%%%-------------------------------------------------------------------
+-spec list_by_owner(OwnerId :: binary()) -> {ok, [#calendar{}]}.
list_by_owner(OwnerId) ->
Match = #calendar{owner_id = OwnerId, status = active, _ = '_'},
Calendars = mnesia:dirty_match_object(Match),
{ok, Calendars}.
-%% Обновление календаря
+%%%-------------------------------------------------------------------
+%%% @doc Обновить поля календаря.
+%%% `Updates` – список пар `[{atom(), term()}]`.
+%%% Поля, не указанные в обновлении, сохраняют прежние значения.
+%%% @end
+%%%-------------------------------------------------------------------
+-spec update(Id :: binary(), Updates :: [{atom(), term()}]) ->
+ {ok, #calendar{}} | {error, not_found | term()}.
update(Id, Updates) ->
F = fun() ->
case mnesia:read(calendar, Id) of
@@ -79,45 +107,83 @@ update(Id, Updates) ->
{aborted, Reason} -> {error, Reason}
end.
-%% Удаление календаря (soft delete)
+%%%-------------------------------------------------------------------
+%%% @doc Мягкое удаление календаря (установка статуса `deleted`).
+%%% @end
+%%%-------------------------------------------------------------------
+-spec delete(Id :: binary()) -> {ok, #calendar{}} | {error, not_found | term()}.
delete(Id) ->
update(Id, [{status, deleted}]).
+%%%-------------------------------------------------------------------
+%%% @doc Количество записей в таблице `calendar`.
+%%% @end
+%%%-------------------------------------------------------------------
+-spec count_calendars() -> non_neg_integer().
count_calendars() ->
mnesia:table_info(calendar, size).
+%%%-------------------------------------------------------------------
+%%% @doc Получить список всех календарей (включая удалённые).
+%%% @end
+%%%-------------------------------------------------------------------
-spec list_all() -> [#calendar{}].
list_all() ->
{atomic, Calendars} = mnesia:transaction(fun() ->
mnesia:foldl(fun(C, Acc) -> [C | Acc] end, [], calendar)
- end),
+ end),
Calendars.
-%% ── НОВЫЕ ФУНКЦИИ ──────────────────────────────────────────
+%%%-------------------------------------------------------------------
+%%% @doc Заморозить календарь с указанием причины.
+%%% @end
+%%%-------------------------------------------------------------------
+-spec freeze(Id :: binary(), Reason :: binary()) ->
+ {ok, #calendar{}} | {error, not_found | term()}.
freeze(Id, Reason) ->
update(Id, [{status, frozen}, {reason, Reason}]).
+%%%-------------------------------------------------------------------
+%%% @doc Разморозить календарь с указанием причины.
+%%% @end
+%%%-------------------------------------------------------------------
+-spec unfreeze(Id :: binary(), Reason :: binary()) ->
+ {ok, #calendar{}} | {error, not_found | term()}.
unfreeze(Id, Reason) ->
update(Id, [{status, active}, {reason, Reason}]).
+%%%===================================================================
+%%% ВНУТРЕННИЕ ФУНКЦИИ
+%%%===================================================================
+
+%%%-------------------------------------------------------------------
+%%% @doc Применить список обновлений к записи календаря.
+%%% @end
+%%%-------------------------------------------------------------------
+-spec apply_updates(#calendar{}, [{atom(), term()}]) -> #calendar{}.
apply_updates(Calendar, Updates) ->
- Updated = lists:foldl(fun({Field, Value}, C) ->
- set_field(Field, Value, C)
- end, Calendar, Updates),
+ Updated = lists:foldl(fun({Field, Value}, C) -> set_field(Field, Value, C) end,
+ Calendar, Updates),
Updated#calendar{updated_at = calendar:universal_time()}.
-set_field(title, Value, C) -> C#calendar{title = Value};
-set_field(description, Value, C) -> C#calendar{description = Value};
-set_field(short_name, Value, C) -> C#calendar{short_name = Value};
-set_field(category, Value, C) -> C#calendar{category = Value};
-set_field(color, Value, C) -> C#calendar{color = Value};
-set_field(image_url, Value, C) -> C#calendar{image_url = Value};
-set_field(settings, Value, C) -> C#calendar{settings = Value};
-set_field(tags, Value, C) -> C#calendar{tags = Value};
-set_field(type, Value, C) -> C#calendar{type = Value};
-set_field(confirmation, Value, C) -> C#calendar{confirmation = Value};
-set_field(status, Value, C) -> C#calendar{status = Value};
-set_field(rating_avg, Value, C) -> C#calendar{rating_avg = Value};
-set_field(rating_count, Value, C) -> C#calendar{rating_count = Value};
-set_field(reason, Value, C) -> C#calendar{reason = Value};
+%%%-------------------------------------------------------------------
+%%% @doc Установить одно поле записи календаря.
+%%% Неизвестные поля игнорируются.
+%%% @end
+%%%-------------------------------------------------------------------
+-spec set_field(atom(), term(), #calendar{}) -> #calendar{}.
+set_field(title, Value, C) -> C#calendar{title = Value};
+set_field(description, Value, C) -> C#calendar{description = Value};
+set_field(short_name, Value, C) -> C#calendar{short_name = Value};
+set_field(category, Value, C) -> C#calendar{category = Value};
+set_field(color, Value, C) -> C#calendar{color = Value};
+set_field(image_url, Value, C) -> C#calendar{image_url = Value};
+set_field(settings, Value, C) -> C#calendar{settings = Value};
+set_field(tags, Value, C) -> C#calendar{tags = Value};
+set_field(type, Value, C) -> C#calendar{type = Value};
+set_field(confirmation, Value, C) -> C#calendar{confirmation = Value};
+set_field(status, Value, C) -> C#calendar{status = Value};
+set_field(rating_avg, Value, C) -> C#calendar{rating_avg = Value};
+set_field(rating_count, Value, C) -> C#calendar{rating_count = Value};
+set_field(reason, Value, C) -> C#calendar{reason = Value};
set_field(_, _, C) -> C.
\ No newline at end of file
diff --git a/src/core/core_event.erl b/src/core/core_event.erl
index f0a3132..258ed70 100644
--- a/src/core/core_event.erl
+++ b/src/core/core_event.erl
@@ -1,186 +1,247 @@
-module(core_event).
-include("records.hrl").
-
--export([create/4, create_recurring/5, get_by_id/1, list_by_calendar/1,
- update/2, delete/1, materialize_occurrence/3]).
+-export([create/4, create_recurring/5, get_by_id/1, list_by_calendar/1, update/2, delete/1,
+ materialize_occurrence/3]).
-export([count_events/0, count_events_by_date/2]).
-export([freeze/2, unfreeze/2]).
-export([list_all/0]).
-%% Создание одиночного события
+%% ─────────────────────────────────────────────────────────────────
+%% Значения по умолчанию для необязательных полей
+%% ─────────────────────────────────────────────────────────────────
+-define(DEFAULT_RECURRENCE_RULE, <<>>).
+-define(DEFAULT_MASTER_ID, <<>>).
+-define(DEFAULT_SPECIALIST_ID, <<>>).
+-define(DEFAULT_LOCATION, #location{address = <<>>, lat = 0.0, lon = 0.0}).
+-define(DEFAULT_TAGS, []).
+-define(DEFAULT_CAPACITY, 0).
+-define(DEFAULT_ONLINE_LINK, <<>>).
+-define(DEFAULT_REASON, <<>>).
+-define(DEFAULT_ATTACHMENTS, []).
+-define(DEFAULT_EDIT_HISTORY, []).
+
+%%%-------------------------------------------------------------------
+%%% @doc Создание одиночного события.
+%%% Все необязательные поля инициализируются значениями по умолчанию.
+%%% @end
+%%%-------------------------------------------------------------------
+-spec create(CalendarId :: binary(), Title :: binary(),
+ StartTime :: calendar:datetime(), Duration :: non_neg_integer()) ->
+ {ok, #event{}} | {error, term()}.
create(CalendarId, Title, StartTime, Duration) ->
Id = infra_utils:generate_id(16),
+ Now = calendar:universal_time(),
Event = #event{
- id = Id,
- calendar_id = CalendarId,
- title = Title,
- description = <<"">>,
- event_type = single,
- start_time = StartTime,
- duration = Duration,
- recurrence_rule = undefined,
- master_id = undefined,
- is_instance = false,
- specialist_id = undefined,
- location = undefined,
- tags = [],
- capacity = undefined,
- online_link = undefined,
- status = active,
- rating_avg = 0.0,
- rating_count = 0,
- created_at = calendar:universal_time(),
- updated_at = calendar:universal_time()
+ id = Id,
+ calendar_id = CalendarId,
+ title = Title,
+ description = <<"">>,
+ event_type = single,
+ start_time = StartTime,
+ duration = Duration,
+ recurrence_rule = ?DEFAULT_RECURRENCE_RULE,
+ master_id = ?DEFAULT_MASTER_ID,
+ is_instance = false,
+ specialist_id = ?DEFAULT_SPECIALIST_ID,
+ location = ?DEFAULT_LOCATION,
+ tags = ?DEFAULT_TAGS,
+ capacity = ?DEFAULT_CAPACITY,
+ online_link = ?DEFAULT_ONLINE_LINK,
+ status = active,
+ reason = ?DEFAULT_REASON,
+ rating_avg = 0.0,
+ rating_count = 0,
+ attachments = ?DEFAULT_ATTACHMENTS,
+ edit_history = ?DEFAULT_EDIT_HISTORY,
+ created_at = Now,
+ updated_at = Now
},
-
- F = fun() ->
- mnesia:write(Event),
- {ok, Event}
- end,
-
+ F = fun() -> mnesia:write(Event), {ok, Event} end,
case mnesia:transaction(F) of
{atomic, Result} -> Result;
{aborted, Reason} -> {error, Reason}
end.
-%% Создание повторяющегося события (мастер-запись)
+%%%-------------------------------------------------------------------
+%%% @doc Создание повторяющегося события (мастер-запись).
+%%% @end
+%%%-------------------------------------------------------------------
+-spec create_recurring(CalendarId :: binary(), Title :: binary(),
+ StartTime :: calendar:datetime(), Duration :: non_neg_integer(),
+ RRule :: map()) ->
+ {ok, #event{}} | {error, term()}.
create_recurring(CalendarId, Title, StartTime, Duration, RRule) ->
Id = infra_utils:generate_id(16),
+ Now = calendar:universal_time(),
Event = #event{
- id = Id,
- calendar_id = CalendarId,
- title = Title,
- description = <<"">>,
- event_type = recurring,
- start_time = StartTime,
- duration = Duration,
- recurrence_rule = jsx:encode(RRule),
- master_id = undefined,
- is_instance = false,
- specialist_id = undefined,
- location = undefined,
- tags = [],
- capacity = undefined,
- online_link = undefined,
- status = active,
- rating_avg = 0.0,
- rating_count = 0,
- created_at = calendar:universal_time(),
- updated_at = calendar:universal_time()
+ id = Id,
+ calendar_id = CalendarId,
+ title = Title,
+ description = <<"">>,
+ event_type = recurring,
+ start_time = StartTime,
+ duration = Duration,
+ recurrence_rule = jsx:encode(RRule),
+ master_id = ?DEFAULT_MASTER_ID,
+ is_instance = false,
+ specialist_id = ?DEFAULT_SPECIALIST_ID,
+ location = ?DEFAULT_LOCATION,
+ tags = ?DEFAULT_TAGS,
+ capacity = ?DEFAULT_CAPACITY,
+ online_link = ?DEFAULT_ONLINE_LINK,
+ status = active,
+ reason = ?DEFAULT_REASON,
+ rating_avg = 0.0,
+ rating_count = 0,
+ attachments = ?DEFAULT_ATTACHMENTS,
+ edit_history = ?DEFAULT_EDIT_HISTORY,
+ created_at = Now,
+ updated_at = Now
},
-
- F = fun() ->
- mnesia:write(Event),
- {ok, Event}
- end,
-
+ F = fun() -> mnesia:write(Event), {ok, Event} end,
case mnesia:transaction(F) of
{atomic, Result} -> Result;
{aborted, Reason} -> {error, Reason}
end.
-%% Материализация вхождения повторяющегося события
+%%%-------------------------------------------------------------------
+%%% @doc Материализация вхождения повторяющегося события.
+%%% @end
+%%%-------------------------------------------------------------------
+-spec materialize_occurrence(MasterId :: binary(),
+ OccurrenceStart :: calendar:datetime(),
+ SpecialistId :: binary()) ->
+ {ok, #event{}} | {error, term()}.
materialize_occurrence(MasterId, OccurrenceStart, SpecialistId) ->
case mnesia:dirty_read(event, MasterId) of
[] ->
{error, master_not_found};
[Master] when Master#event.event_type =:= recurring ->
- % Проверяем, не существует ли уже материализованное вхождение
Existing = mnesia:dirty_match_object(
- #event{master_id = MasterId, start_time = OccurrenceStart, is_instance = true, _ = '_'}
+ #event{master_id = MasterId, start_time = OccurrenceStart,
+ is_instance = true, _ = '_'}
),
-
case Existing of
[] ->
- % Создаём новый экземпляр
InstanceId = infra_utils:generate_id(16),
+ Now = calendar:universal_time(),
Instance = #event{
- id = InstanceId,
- calendar_id = Master#event.calendar_id,
- title = Master#event.title,
- description = Master#event.description,
- event_type = single,
- start_time = OccurrenceStart,
- duration = Master#event.duration,
- recurrence_rule = undefined,
- master_id = MasterId,
- is_instance = true,
- specialist_id = SpecialistId,
- location = Master#event.location,
- tags = Master#event.tags,
- capacity = Master#event.capacity,
- online_link = Master#event.online_link,
- status = active,
- rating_avg = 0.0,
- rating_count = 0,
- created_at = calendar:universal_time(),
- updated_at = calendar:universal_time()
+ id = InstanceId,
+ calendar_id = Master#event.calendar_id,
+ title = Master#event.title,
+ description = Master#event.description,
+ event_type = single,
+ start_time = OccurrenceStart,
+ duration = Master#event.duration,
+ recurrence_rule = ?DEFAULT_RECURRENCE_RULE,
+ master_id = MasterId,
+ is_instance = true,
+ specialist_id = SpecialistId,
+ location = Master#event.location,
+ tags = Master#event.tags,
+ capacity = Master#event.capacity,
+ online_link = Master#event.online_link,
+ status = active,
+ reason = ?DEFAULT_REASON,
+ rating_avg = 0.0,
+ rating_count = 0,
+ attachments = ?DEFAULT_ATTACHMENTS,
+ edit_history = ?DEFAULT_EDIT_HISTORY,
+ created_at = Now,
+ updated_at = Now
},
-
- F = fun() ->
- mnesia:write(Instance),
- {ok, Instance}
- end,
-
+ F = fun() -> mnesia:write(Instance), {ok, Instance} end,
case mnesia:transaction(F) of
{atomic, Result} -> Result;
{aborted, Reason} -> {error, Reason}
end;
[Instance] ->
- % Уже существует, возвращаем его
{ok, Instance}
end;
[_] ->
{error, not_recurring}
end.
-%% Получение события по ID
+%%%-------------------------------------------------------------------
+%%% @doc Получить событие по идентификатору.
+%%% @end
+%%%-------------------------------------------------------------------
+-spec get_by_id(Id :: binary()) -> {ok, #event{}} | {error, not_found}.
get_by_id(Id) ->
case mnesia:dirty_read(event, Id) of
[] -> {error, not_found};
[Event] -> {ok, Event}
end.
-%% Список событий календаря (только мастер-записи и одиночные)
+%%%-------------------------------------------------------------------
+%%% @doc Список активных событий календаря.
+%%% @end
+%%%-------------------------------------------------------------------
+-spec list_by_calendar(CalendarId :: binary()) -> {ok, [#event{}]}.
list_by_calendar(CalendarId) ->
Match = #event{calendar_id = CalendarId, status = active, is_instance = false, _ = '_'},
Events = mnesia:dirty_match_object(Match),
{ok, Events}.
-%% Обновление события
+%%%-------------------------------------------------------------------
+%%% @doc Обновить поля события.
+%%% `Updates` – список пар `[{atom(), term()}]`.
+%%% Поля, не указанные в обновлении, сохраняют прежние значения.
+%%% @end
+%%%-------------------------------------------------------------------
+-spec update(Id :: binary(), Updates :: [{atom(), term()}]) ->
+ {ok, #event{}} | {error, not_found | term()}.
update(Id, Updates) ->
F = fun() ->
case mnesia:read(event, Id) of
- [] ->
- {error, not_found};
+ [] -> {error, not_found};
[Event] ->
UpdatedEvent = apply_updates(Event, Updates),
mnesia:write(UpdatedEvent),
{ok, UpdatedEvent}
end
end,
-
case mnesia:transaction(F) of
{atomic, Result} -> Result;
{aborted, Reason} -> {error, Reason}
end.
-%% Удаление события (soft delete)
+%%%-------------------------------------------------------------------
+%%% @doc Мягкое удаление события (установка статуса `deleted`).
+%%% @end
+%%%-------------------------------------------------------------------
+-spec delete(Id :: binary()) -> {ok, #event{}} | {error, not_found | term()}.
delete(Id) ->
update(Id, [{status, deleted}]).
+%%%-------------------------------------------------------------------
+%%% @doc Количество записей в таблице `event`.
+%%% @end
+%%%-------------------------------------------------------------------
+-spec count_events() -> non_neg_integer().
count_events() ->
mnesia:table_info(event, size).
+%%%-------------------------------------------------------------------
+%%% @doc Получить список всех активных мастер-событий.
+%%% @end
+%%%-------------------------------------------------------------------
+-spec list_all() -> [#event{}].
list_all() ->
Match = #event{status = active, is_instance = false, _ = '_'},
mnesia:dirty_match_object(Match).
+%%%-------------------------------------------------------------------
+%%% @doc Подсчёт событий, созданных в заданном временном диапазоне.
+%%% Возвращает список кортежей `{{Year,Month,Day}, Count}`, отсортированный по дате.
+%%% @end
+%%%-------------------------------------------------------------------
+-spec count_events_by_date(From :: calendar:datetime(), To :: calendar:datetime()) ->
+ [{{pos_integer(), pos_integer(), pos_integer()}, non_neg_integer()}].
count_events_by_date(From, To) ->
All = mnesia:dirty_match_object(#event{_ = '_'}),
- Filtered = lists:filter(fun(E) ->
- E#event.created_at >= From andalso E#event.created_at =< To
- end, All),
+ Filtered = lists:filter(fun(E) -> E#event.created_at >= From andalso
+ E#event.created_at =< To end, All),
Counts = lists:foldl(fun(E, Acc) ->
Day = date_part(E#event.created_at),
case lists:keyfind(Day, 1, Acc) of
@@ -190,32 +251,69 @@ count_events_by_date(From, To) ->
end, [], Filtered),
lists:sort(Counts).
+%%%-------------------------------------------------------------------
+%%% @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(#event{}, [{atom(), term()}]) -> #event{}.
apply_updates(Event, Updates) ->
- Updated = lists:foldl(fun({Field, Value}, E) ->
- set_field(Field, Value, E)
- end, Event, Updates),
+ Updated = lists:foldl(fun({Field, Value}, E) -> set_field(Field, Value, E) end,
+ Event, Updates),
Updated#event{updated_at = calendar:universal_time()}.
+%%%-------------------------------------------------------------------
+%%% @doc Заморозить событие с указанием причины.
+%%% @end
+%%%-------------------------------------------------------------------
+-spec freeze(Id :: binary(), Reason :: binary()) ->
+ {ok, #event{}} | {error, not_found | term()}.
freeze(Id, Reason) ->
update(Id, [{status, frozen}, {reason, Reason}]).
+%%%-------------------------------------------------------------------
+%%% @doc Разморозить событие с указанием причины.
+%%% @end
+%%%-------------------------------------------------------------------
+-spec unfreeze(Id :: binary(), Reason :: binary()) ->
+ {ok, #event{}} | {error, not_found | term()}.
unfreeze(Id, Reason) ->
update(Id, [{status, active}, {reason, Reason}]).
-%% ── ОБНОВЛЁННАЯ set_field ──────────────────────────────────
-set_field(title, Value, E) -> E#event{title = Value};
-set_field(description, Value, E) -> E#event{description = Value};
-set_field(start_time, Value, E) -> E#event{start_time = Value};
-set_field(duration, Value, E) -> E#event{duration = Value};
-set_field(specialist_id, Value, E) -> E#event{specialist_id = Value};
-set_field(location, Value, E) -> E#event{location = Value};
-set_field(tags, Value, E) -> E#event{tags = Value};
-set_field(capacity, Value, E) -> E#event{capacity = Value};
-set_field(online_link, Value, E) -> E#event{online_link = Value};
-set_field(status, Value, E) -> E#event{status = Value};
-set_field(reason, Value, E) -> E#event{reason = Value};
-set_field(rating_avg, Value, E) -> E#event{rating_avg = Value};
-set_field(rating_count, Value, E) -> E#event{rating_count = Value};
+%%%===================================================================
+%%% ВНУТРЕННИЕ ФУНКЦИИ
+%%%===================================================================
+
+%%%-------------------------------------------------------------------
+%%% @doc Установить одно поле записи события.
+%%% Неизвестные поля игнорируются.
+%%% @end
+%%%-------------------------------------------------------------------
+-spec set_field(atom(), term(), #event{}) -> #event{}.
+set_field(title, Value, E) -> E#event{title = Value};
+set_field(description, Value, E) -> E#event{description = Value};
+set_field(start_time, Value, E) -> E#event{start_time = Value};
+set_field(duration, Value, E) -> E#event{duration = Value};
+set_field(specialist_id, Value, E) -> E#event{specialist_id = Value};
+set_field(location, Value, E) -> E#event{location = Value};
+set_field(tags, Value, E) -> E#event{tags = Value};
+set_field(capacity, Value, E) -> E#event{capacity = Value};
+set_field(online_link, Value, E) -> E#event{online_link = Value};
+set_field(status, Value, E) -> E#event{status = Value};
+set_field(reason, Value, E) -> E#event{reason = Value};
+set_field(rating_avg, Value, E) -> E#event{rating_avg = Value};
+set_field(rating_count, Value, E) -> E#event{rating_count = Value};
+set_field(attachments, Value, E) -> E#event{attachments = Value};
+set_field(edit_history, Value, E) -> E#event{edit_history = Value};
+set_field(recurrence_rule, Value, E) -> E#event{recurrence_rule = Value};
+set_field(master_id, Value, E) -> E#event{master_id = Value};
+set_field(is_instance, Value, E) -> E#event{is_instance = Value};
+set_field(event_type, Value, E) -> E#event{event_type = Value};
+set_field(calendar_id, Value, E) -> E#event{calendar_id = Value};
set_field(_, _, E) -> E.
\ No newline at end of file
diff --git a/src/core/core_report.erl b/src/core/core_report.erl
index 713fcf4..a9b407c 100644
--- a/src/core/core_report.erl
+++ b/src/core/core_report.erl
@@ -1,150 +1,159 @@
-module(core_report).
-include("records.hrl").
+-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([create/4, get_by_id/1, list_by_target/2, list_by_reporter/1, list_all/0]).
--export([update_status/3, get_count_by_target/2]).
--export([generate_id/0]).
--export([count_reports_by_status/1, count_reports_by_admin/2]).
--export([count_reports_resolved_by_admin/2, avg_resolution_time/1]).
--export([delete/1, update/2]). % <-- добавлено
+%% ─────────────────────────────────────────────────────────────────
+%% Значения по умолчанию для необязательных полей
+%% ─────────────────────────────────────────────────────────────────
+-define(DEFAULT_RESOLVED_AT, {{1970,1,1},{0,0,0}}).
+-define(DEFAULT_RESOLVED_BY, <<>>).
-%% Создание жалобы
+%%%-------------------------------------------------------------------
+%%% @doc Создание жалобы.
+%%% Статус устанавливается в `pending`, поля `resolved_at` и `resolved_by`
+%%% инициализируются значениями по умолчанию, чтобы избежать `undefined`.
+%%% @end
+%%%-------------------------------------------------------------------
+-spec create(ReporterId :: binary(), TargetType :: calendar | event | review,
+ TargetId :: binary(), Reason :: binary()) ->
+ {ok, #report{}} | {error, term()}.
create(ReporterId, TargetType, TargetId, Reason) ->
Id = infra_utils:generate_id(16),
+ Now = calendar:universal_time(),
Report = #report{
- id = Id,
+ id = Id,
reporter_id = ReporterId,
target_type = TargetType,
- target_id = TargetId,
- reason = Reason,
- status = pending,
- created_at = calendar:universal_time(),
- resolved_at = undefined,
- resolved_by = undefined
+ target_id = TargetId,
+ reason = Reason,
+ status = pending,
+ created_at = Now,
+ resolved_at = ?DEFAULT_RESOLVED_AT,
+ resolved_by = ?DEFAULT_RESOLVED_BY
},
-
- F = fun() ->
- mnesia:write(Report),
- {ok, Report}
- end,
-
+ F = fun() -> mnesia:write(Report), {ok, Report} end,
case mnesia:transaction(F) of
{atomic, Result} -> Result;
{aborted, Reason} -> {error, Reason}
end.
-%% Получение жалобы по ID
+%%%-------------------------------------------------------------------
+%%% @doc Получить жалобу по идентификатору.
+%%% @end
+%%%-------------------------------------------------------------------
+-spec get_by_id(Id :: binary()) -> {ok, #report{}} | {error, not_found}.
get_by_id(Id) ->
case mnesia:dirty_read(report, Id) of
[] -> {error, not_found};
[Report] -> {ok, Report}
end.
-%% Список жалоб на цель
-list_by_target(TargetType, TargetId) ->
- Match = #report{target_type = TargetType, target_id = TargetId, _ = '_'},
- Reports = mnesia:dirty_match_object(Match),
- {ok, Reports}.
+%%%-------------------------------------------------------------------
+%%% @doc Список всех жалоб.
+%%% @end
+%%%-------------------------------------------------------------------
+-spec list_all() -> [#report{}].
+list_all() ->
+ mnesia:dirty_match_object(#report{_ = '_'}).
-%% Список жалоб от пользователя
+%%%-------------------------------------------------------------------
+%%% @doc Список жалоб, поданных определённым пользователем.
+%%% @end
+%%%-------------------------------------------------------------------
+-spec list_by_reporter(ReporterId :: binary()) -> [#report{}].
list_by_reporter(ReporterId) ->
Match = #report{reporter_id = ReporterId, _ = '_'},
- Reports = mnesia:dirty_match_object(Match),
- {ok, Reports}.
+ mnesia:dirty_match_object(Match).
-%% Список всех жалоб (для админов)
-list_all() ->
- Match = #report{_ = '_'},
- Reports = mnesia:dirty_match_object(Match),
- {ok, Reports}.
+%%%-------------------------------------------------------------------
+%%% @doc Список жалоб на конкретную цель.
+%%% @end
+%%%-------------------------------------------------------------------
+-spec list_by_target(TargetType :: calendar | event | review, TargetId :: binary()) ->
+ [#report{}].
+list_by_target(TargetType, TargetId) ->
+ Match = #report{target_type = TargetType, target_id = TargetId, _ = '_'},
+ mnesia:dirty_match_object(Match).
-%% Обновление статуса жалобы
-update_status(Id, Status, ResolvedBy) when Status =:= reviewed; Status =:= dismissed ->
+%%%-------------------------------------------------------------------
+%%% @doc Количество жалоб на конкретную цель.
+%%% @end
+%%%-------------------------------------------------------------------
+-spec get_count_by_target(TargetType :: calendar | event | review, TargetId :: binary()) ->
+ non_neg_integer().
+get_count_by_target(TargetType, TargetId) ->
+ length(list_by_target(TargetType, TargetId)).
+
+%%%-------------------------------------------------------------------
+%%% @doc Обновить статус жалобы.
+%%% При переводе в `reviewed` или `dismissed` заполняются `resolved_by` и
+%%% `resolved_at` текущим временем.
+%%% @end
+%%%-------------------------------------------------------------------
+-spec update_status(Id :: binary(), NewStatus :: reviewed | dismissed, AdminId :: binary()) ->
+ {ok, #report{}} | {error, not_found | term()}.
+update_status(Id, NewStatus, AdminId) ->
F = fun() ->
case mnesia:read(report, Id) of
- [] ->
- {error, not_found};
+ [] -> {error, not_found};
[Report] ->
Updated = Report#report{
- status = Status,
- resolved_at = calendar:universal_time(),
- resolved_by = ResolvedBy
+ status = NewStatus,
+ resolved_by = AdminId,
+ resolved_at = calendar:universal_time()
},
mnesia:write(Updated),
{ok, Updated}
end
end,
-
case mnesia:transaction(F) of
{atomic, Result} -> Result;
{aborted, Reason} -> {error, Reason}
end.
-%% Получить количество жалоб на цель
-get_count_by_target(TargetType, TargetId) ->
- Match = #report{target_type = TargetType, target_id = TargetId, status = pending, _ = '_'},
- Reports = mnesia:dirty_match_object(Match),
- length(Reports).
+%%%-------------------------------------------------------------------
+%%% @doc Количество жалоб.
+%%% @end
+%%%-------------------------------------------------------------------
+-spec count_reports() -> non_neg_integer().
+count_reports() ->
+ mnesia:table_info(report, size).
+%%%-------------------------------------------------------------------
+%%% @doc Количество жалоб с заданным статусом.
+%%% @end
+%%%-------------------------------------------------------------------
+-spec count_reports_by_status(Status :: pending | reviewed | dismissed) ->
+ non_neg_integer().
count_reports_by_status(Status) ->
Match = #report{status = Status, _ = '_'},
length(mnesia:dirty_match_object(Match)).
-count_reports_by_admin(AdminId, Status) ->
- Match = #report{resolved_by = AdminId, status = Status, _ = '_'},
- length(mnesia:dirty_match_object(Match)).
-
+%%%-------------------------------------------------------------------
+%%% @doc Количество жалоб, разрешённых конкретным администратором.
+%%% @end
+%%%-------------------------------------------------------------------
+-spec count_reports_resolved_by_admin(AdminId :: binary(), Status :: reviewed | dismissed) ->
+ non_neg_integer().
count_reports_resolved_by_admin(AdminId, Status) ->
Match = #report{resolved_by = AdminId, status = Status, _ = '_'},
length(mnesia:dirty_match_object(Match)).
+%%%-------------------------------------------------------------------
+%%% @doc Среднее время разрешения жалоб заданного статуса (в часах).
+%%% @end
+%%%-------------------------------------------------------------------
+-spec avg_resolution_time(Status :: reviewed | dismissed) -> float().
avg_resolution_time(Status) ->
Match = #report{status = Status, _ = '_'},
Reports = mnesia:dirty_match_object(Match),
- Resolved = lists:filter(fun(R) -> R#report.resolved_at =/= undefined end, Reports),
- case Resolved of
- [] -> 0;
+ case Reports of
+ [] -> 0.0;
_ ->
- TotalSeconds = lists:sum([calendar:datetime_to_gregorian_seconds(R#report.resolved_at) -
- calendar:datetime_to_gregorian_seconds(R#report.created_at) || R <- Resolved]),
- TotalSeconds / length(Resolved) / 3600.0
- end.
-
-%% Мягкое удаление жалобы (просто физически удаляем запись)
--spec delete(binary()) -> {ok, deleted} | {error, not_found}.
-delete(Id) ->
- case get_by_id(Id) of
- {ok, _} ->
- mnesia:dirty_delete(report, Id),
- {ok, deleted};
- Error -> Error
- end.
-
-%% Обновление произвольных полей жалобы (для административных целей)
--spec update(binary(), proplists:proplist()) -> {ok, #report{}} | {error, not_found}.
-update(Id, Updates) ->
- case get_by_id(Id) of
- {ok, Report} ->
- UpdatedReport = apply_updates(Report, Updates),
- mnesia:dirty_write(UpdatedReport),
- {ok, UpdatedReport};
- Error -> Error
- end.
-
-%%%===================================================================
-%%% Внутренние функции
-%%%===================================================================
-
-generate_id() ->
- base64:encode(crypto:strong_rand_bytes(16), #{mode => urlsafe, padding => false}).
-
-apply_updates(Report, []) -> Report;
-apply_updates(Report, [{Field, Value} | Rest]) ->
- NewReport = case Field of
- status -> Report#report{status = Value};
- resolved_at -> Report#report{resolved_at = Value};
- resolved_by -> Report#report{resolved_by = Value};
- reason -> Report#report{reason = Value};
- _ -> Report
- end,
- apply_updates(NewReport, Rest).
\ No newline at end of file
+ Total = lists:sum([calendar:datetime_to_gregorian_seconds(R#report.resolved_at) -
+ calendar:datetime_to_gregorian_seconds(R#report.created_at)
+ || R <- Reports, R#report.resolved_at /= undefined]),
+ Total / length(Reports) / 3600.0
+ end.
\ No newline at end of file
diff --git a/src/core/core_review.erl b/src/core/core_review.erl
index eda53f4..7a8312b 100644
--- a/src/core/core_review.erl
+++ b/src/core/core_review.erl
@@ -1,129 +1,217 @@
-module(core_review).
-include("records.hrl").
-
--export([create/5, get_by_id/1, list_by_target/2, list_by_user/1,
- update/2, delete/1, hide/2, unhide/2]).
+-export([create/5, get_by_id/1, list_by_target/2, list_by_user/1, update/2, delete/1,
+ hide/2, unhide/2]).
-export([get_average_rating/2, has_user_reviewed/3]).
-export([count_reviews/0, list_all/0]).
-%% Создание отзыва
+%% ─────────────────────────────────────────────────────────────────
+%% Значения по умолчанию для необязательных полей
+%% ─────────────────────────────────────────────────────────────────
+-define(DEFAULT_REASON, <<>>).
+-define(DEFAULT_LIKES, 0).
+-define(DEFAULT_DISLIKES, 0).
+
+%%%-------------------------------------------------------------------
+%%% @doc Создание отзыва.
+%%% Все необязательные поля инициализируются значениями по умолчанию.
+%%% @end
+%%%-------------------------------------------------------------------
+-spec create(UserId :: binary(), TargetType :: calendar | event, TargetId :: binary(),
+ Rating :: 1..5, Comment :: binary()) -> {ok, #review{}} | {error, term()}.
create(UserId, TargetType, TargetId, Rating, Comment) ->
Id = infra_utils:generate_id(16),
+ Now = calendar:universal_time(),
Review = #review{
- id = Id,
- user_id = UserId,
+ id = Id,
+ user_id = UserId,
target_type = TargetType,
- target_id = TargetId,
- rating = Rating,
- comment = Comment,
- status = visible,
- created_at = calendar:universal_time(),
- updated_at = calendar:universal_time()
+ target_id = TargetId,
+ rating = Rating,
+ comment = Comment,
+ status = visible,
+ reason = ?DEFAULT_REASON,
+ likes = ?DEFAULT_LIKES,
+ dislikes = ?DEFAULT_DISLIKES,
+ created_at = Now,
+ updated_at = Now
},
-
- F = fun() ->
- mnesia:write(Review),
- {ok, Review}
- end,
-
+ F = fun() -> mnesia:write(Review), {ok, Review} end,
case mnesia:transaction(F) of
{atomic, Result} -> Result;
{aborted, Reason} -> {error, Reason}
end.
-%% Получение отзыва по ID
+%%%-------------------------------------------------------------------
+%%% @doc Получение отзыва по идентификатору.
+%%% @end
+%%%-------------------------------------------------------------------
+-spec get_by_id(Id :: binary()) -> {ok, #review{}} | {error, not_found}.
get_by_id(Id) ->
case mnesia:dirty_read(review, Id) of
- [] -> {error, not_found};
+ [] -> {error, not_found};
[Review] -> {ok, Review}
end.
-%% Список отзывов для цели (событие или календарь)
+%%%-------------------------------------------------------------------
+%%% @doc Список видимых отзывов для заданной цели.
+%%% @end
+%%%-------------------------------------------------------------------
+-spec list_by_target(TargetType :: calendar | event, TargetId :: binary()) ->
+ {ok, [#review{}]}.
list_by_target(TargetType, TargetId) ->
- Match = #review{target_type = TargetType, target_id = TargetId, status = visible, _ = '_'},
+ Match = #review{target_type = TargetType, target_id = TargetId,
+ status = visible, _ = '_'},
Reviews = mnesia:dirty_match_object(Match),
- {ok, lists:sort(fun(A, B) -> A#review.created_at >= B#review.created_at end, Reviews)}.
+ {ok, lists:sort(fun(A, B) -> A#review.created_at >= B#review.created_at end,
+ Reviews)}.
-%% Список отзывов пользователя
+%%%-------------------------------------------------------------------
+%%% @doc Список отзывов пользователя.
+%%% @end
+%%%-------------------------------------------------------------------
+-spec list_by_user(UserId :: binary()) -> {ok, [#review{}]}.
list_by_user(UserId) ->
- Match = #review{user_id = UserId, _ = '_'},
+ Match = #review{user_id = UserId, _ = '_'},
Reviews = mnesia:dirty_match_object(Match),
{ok, Reviews}.
-%% Обновление отзыва
+%%%-------------------------------------------------------------------
+%%% @doc Обновление отзыва.
+%%% `Updates` – список пар `[{atom(), term()}]`.
+%%% Поля, не указанные в обновлении, сохраняют прежние значения.
+%%% @end
+%%%-------------------------------------------------------------------
+-spec update(Id :: binary(), Updates :: [{atom(), term()}]) ->
+ {ok, #review{}} | {error, not_found | term()}.
update(Id, Updates) ->
F = fun() ->
case mnesia:read(review, Id) of
- [] ->
- {error, not_found};
+ [] -> {error, not_found};
[Review] ->
UpdatedReview = apply_updates(Review, Updates),
mnesia:write(UpdatedReview),
{ok, UpdatedReview}
end
end,
-
case mnesia:transaction(F) of
{atomic, Result} -> Result;
{aborted, Reason} -> {error, Reason}
end.
-%% Удаление отзыва (hard delete)
+%%%-------------------------------------------------------------------
+%%% @doc Удаление отзыва (физическое).
+%%% @end
+%%%-------------------------------------------------------------------
+-spec delete(Id :: binary()) -> {ok, deleted} | {error, not_found | term()}.
delete(Id) ->
F = fun() ->
case mnesia:read(review, Id) of
- [] ->
- {error, not_found};
- [Review] ->
- mnesia:delete_object(Review),
- {ok, deleted}
+ [] -> {error, not_found};
+ [Review] -> mnesia:delete_object(Review), {ok, deleted}
end
end,
-
case mnesia:transaction(F) of
{atomic, Result} -> Result;
{aborted, Reason} -> {error, Reason}
end.
-%% Скрытие отзыва (модерация)
+%%%-------------------------------------------------------------------
+%%% @doc Скрытие отзыва с указанием причины.
+%%% @end
+%%%-------------------------------------------------------------------
+-spec hide(Id :: binary(), Reason :: binary()) ->
+ {ok, #review{}} | {error, not_found | term()}.
hide(Id, Reason) ->
update(Id, [{status, hidden}, {reason, Reason}]).
+%%%-------------------------------------------------------------------
+%%% @doc Восстановление видимости отзыва с указанием причины.
+%%% @end
+%%%-------------------------------------------------------------------
+-spec unhide(Id :: binary(), Reason :: binary()) ->
+ {ok, #review{}} | {error, not_found | term()}.
unhide(Id, Reason) ->
update(Id, [{status, visible}, {reason, Reason}]).
-%% Получение среднего рейтинга цели
+%%%-------------------------------------------------------------------
+%%% @doc Вычисление среднего рейтинга цели.
+%%% Возвращает `{Average, Count}`.
+%%% @end
+%%%-------------------------------------------------------------------
+-spec get_average_rating(TargetType :: calendar | event, TargetId :: binary()) ->
+ {Average :: float(), Count :: non_neg_integer()}.
get_average_rating(TargetType, TargetId) ->
- Match = #review{target_type = TargetType, target_id = TargetId, status = visible, _ = '_'},
+ Match = #review{target_type = TargetType, target_id = TargetId,
+ status = visible, _ = '_'},
Reviews = mnesia:dirty_match_object(Match),
-
case length(Reviews) of
- 0 -> {0.0, 0};
+ 0 -> {0.0, 0};
Count ->
Total = lists:sum([R#review.rating || R <- Reviews]),
{Total / Count, Count}
end.
-%% Проверка, оставлял ли пользователь отзыв
+%%%-------------------------------------------------------------------
+%%% @doc Проверить, оставлял ли пользователь отзыв на цель.
+%%% @end
+%%%-------------------------------------------------------------------
+-spec has_user_reviewed(UserId :: binary(), TargetType :: calendar | event,
+ TargetId :: binary()) -> boolean().
has_user_reviewed(UserId, TargetType, TargetId) ->
- Match = #review{user_id = UserId, target_type = TargetType, target_id = TargetId, _ = '_'},
+ Match = #review{user_id = UserId, target_type = TargetType,
+ target_id = TargetId, _ = '_'},
case mnesia:dirty_match_object(Match) of
- [] -> false;
- _ -> true
+ [] -> false;
+ _ -> true
end.
-count_reviews() -> mnesia:table_info(review, size).
+%%%-------------------------------------------------------------------
+%%% @doc Количество отзывов в таблице.
+%%% @end
+%%%-------------------------------------------------------------------
+-spec count_reviews() -> non_neg_integer().
+count_reviews() ->
+ mnesia:table_info(review, size).
-list_all() -> mnesia:dirty_match_object(#review{_ = '_'}).
+%%%-------------------------------------------------------------------
+%%% @doc Получить список всех отзывов.
+%%% @end
+%%%-------------------------------------------------------------------
+-spec list_all() -> [#review{}].
+list_all() ->
+ mnesia:dirty_match_object(#review{_ = '_'}).
+%%%===================================================================
+%%% ВНУТРЕННИЕ ФУНКЦИИ
+%%%===================================================================
+
+%%%-------------------------------------------------------------------
+%%% @doc Применить список обновлений к записи отзыва.
+%%% @end
+%%%-------------------------------------------------------------------
+-spec apply_updates(#review{}, [{atom(), term()}]) -> #review{}.
apply_updates(Review, Updates) ->
- Updated = lists:foldl(fun({Field, Value}, R) ->
- set_field(Field, Value, R)
- end, Review, Updates),
+ Updated = lists:foldl(fun({Field, Value}, R) -> set_field(Field, Value, R) end,
+ Review, Updates),
Updated#review{updated_at = calendar:universal_time()}.
-set_field(rating, Value, R) when Value >= 1, Value =< 5 -> R#review{rating = Value};
-set_field(comment, Value, R) -> R#review{comment = Value};
-set_field(status, Value, R) when Value =:= visible; Value =:= hidden -> R#review{status = Value};
-set_field(reason, Value, R) -> R#review{reason = Value};
+%%%-------------------------------------------------------------------
+%%% @doc Установить одно поле записи отзыва.
+%%% Неизвестные поля игнорируются.
+%%% @end
+%%%-------------------------------------------------------------------
+-spec set_field(atom(), term(), #review{}) -> #review{}.
+set_field(rating, Value, R) when Value >= 1, Value =< 5 ->
+ R#review{rating = Value};
+set_field(comment, Value, R) ->
+ R#review{comment = Value};
+set_field(status, Value, R) when Value =:= visible; Value =:= hidden ->
+ R#review{status = Value};
+set_field(reason, Value, R) ->
+ R#review{reason = Value};
+set_field(likes, Value, R) ->
+ R#review{likes = Value};
+set_field(dislikes, Value, R) ->
+ R#review{dislikes = Value};
set_field(_, _, R) -> R.
\ No newline at end of file
diff --git a/src/core/core_session.erl b/src/core/core_session.erl
index a26a103..ce93abd 100644
--- a/src/core/core_session.erl
+++ b/src/core/core_session.erl
@@ -1,20 +1,42 @@
+%%%-------------------------------------------------------------------
+%%% @doc Управление сессиями пользователей (refresh-токены).
+%%%
+%%% Запись `session` хранит refresh-токен, идентификатор пользователя,
+%%% время истечения и тип токена. Все поля обязательны и инициализируются
+%%% при создании, поэтому `undefined` в базе не возникает.
+%%% @end
+%%%-------------------------------------------------------------------
-module(core_session).
-include("records.hrl").
-export([create/2, validate/1, delete/1]).
+%%%-------------------------------------------------------------------
+%%% @doc Создать новую сессию.
+%%% Токен действителен 30 дней с момента создания.
+%%% @end
+%%%-------------------------------------------------------------------
-spec create(UserId :: binary(), RefreshToken :: binary()) -> ok.
create(UserId, RefreshToken) ->
Session = #session{
- token = RefreshToken,
- user_id = UserId,
+ token = RefreshToken,
+ user_id = UserId,
expires_at = calendar:gregorian_seconds_to_datetime(
- calendar:datetime_to_gregorian_seconds(calendar:universal_time()) + 30 * 24 * 3600),
- type = refresh
+ calendar:datetime_to_gregorian_seconds(
+ calendar:universal_time()) + 30 * 24 * 3600),
+ type = refresh
},
mnesia:dirty_write(Session),
ok.
--spec validate(Token :: binary()) -> {ok, UserId :: binary(), User :: #user{}} | {error, not_found | expired}.
+%%%-------------------------------------------------------------------
+%%% @doc Проверить refresh-токен.
+%%% Если токен действителен, возвращает `{ok, UserId, User}`.
+%%% Если истёк — удаляет запись и возвращает `{error, expired}`.
+%%% Если не найден — `{error, not_found}`.
+%%% @end
+%%%-------------------------------------------------------------------
+-spec validate(Token :: binary()) ->
+ {ok, UserId :: binary(), User :: #user{}} | {error, not_found | expired}.
validate(Token) ->
case mnesia:dirty_read({session, Token}) of
[Session] ->
@@ -22,19 +44,20 @@ validate(Token) ->
true ->
% Получаем пользователя по ID из сессии
case mnesia:dirty_read({user, Session#session.user_id}) of
- [User] ->
- {ok, Session#session.user_id, User};
- [] ->
- {error, not_found}
+ [User] -> {ok, Session#session.user_id, User};
+ [] -> {error, not_found}
end;
false ->
mnesia:dirty_delete({session, Token}),
{error, expired}
end;
- [] ->
- {error, not_found}
+ [] -> {error, not_found}
end.
+%%%-------------------------------------------------------------------
+%%% @doc Удалить сессию (отзыв refresh-токена).
+%%% @end
+%%%-------------------------------------------------------------------
-spec delete(Token :: binary()) -> ok.
delete(Token) ->
mnesia:dirty_delete({session, Token}),
diff --git a/src/core/core_subscription.erl b/src/core/core_subscription.erl
index 345e180..615219d 100644
--- a/src/core/core_subscription.erl
+++ b/src/core/core_subscription.erl
@@ -1,65 +1,65 @@
-module(core_subscription).
-include("records.hrl").
-
-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([list_subscriptions/0, create_subscription/1, update_subscription/2, delete_subscription/1]).
-export([count_subscription/0]).
-define(TRIAL_DAYS, 30).
-%% Создание подписки
+%%%-------------------------------------------------------------------
+%%% @doc Создание подписки.
+%%% `TrialUsed` – `true`, если подписка платная; `false` для пробного периода.
+%%% Все поля записи инициализированы, `undefined` не возникает.
+%%% @end
+%%%-------------------------------------------------------------------
+-spec create(UserId :: binary(), Plan :: monthly | quarterly | biannual | annual,
+ TrialUsed :: boolean()) -> {ok, #subscription{}} | {error, term()}.
create(UserId, Plan, TrialUsed) ->
Id = infra_utils:generate_id(16),
Now = calendar:universal_time(),
-
{StartDate, EndDate} = case TrialUsed of
true ->
- % Платная подписка
DurationMonths = plan_to_months(Plan),
End = add_months(Now, DurationMonths),
{Now, End};
false ->
- % Пробный период
End = add_days(Now, ?TRIAL_DAYS),
{Now, End}
end,
-
Subscription = #subscription{
- id = Id,
- user_id = UserId,
- plan = Plan,
- status = active,
- trial_used = TrialUsed,
- started_at = StartDate,
- expires_at = EndDate,
- created_at = Now,
- updated_at = Now
+ id = Id,
+ user_id = UserId,
+ plan = Plan,
+ status = active,
+ trial_used = TrialUsed,
+ started_at = StartDate,
+ expires_at = EndDate,
+ created_at = Now,
+ updated_at = Now
},
-
- F = fun() ->
- mnesia:write(Subscription),
- {ok, Subscription}
- end,
-
+ F = fun() -> mnesia:write(Subscription), {ok, Subscription} end,
case mnesia:transaction(F) of
{atomic, Result} -> Result;
{aborted, Reason} -> {error, Reason}
end.
-%% Получение подписки по ID
+%%%-------------------------------------------------------------------
+%%% @doc Получить подписку по идентификатору.
+%%% @end
+%%%-------------------------------------------------------------------
+-spec get_by_id(Id :: binary()) -> {ok, #subscription{}} | {error, not_found}.
get_by_id(Id) ->
case mnesia:dirty_read(subscription, Id) of
[] -> {error, not_found};
[Subscription] -> {ok, Subscription}
end.
-%% Получение активной подписки пользователя
+%%%-------------------------------------------------------------------
+%%% @doc Получить активную подписку пользователя.
+%%% @end
+%%%-------------------------------------------------------------------
+-spec get_active_by_user(UserId :: binary()) -> {ok, #subscription{}} | {error, not_found}.
get_active_by_user(UserId) ->
Match = #subscription{user_id = UserId, status = active, _ = '_'},
case mnesia:dirty_match_object(Match) of
@@ -67,50 +67,64 @@ get_active_by_user(UserId) ->
[Subscription] -> {ok, Subscription}
end.
-%% Список всех подписок пользователя
+%%%-------------------------------------------------------------------
+%%% @doc Список всех подписок пользователя.
+%%% @end
+%%%-------------------------------------------------------------------
+-spec list_by_user(UserId :: binary()) -> {ok, [#subscription{}]}.
list_by_user(UserId) ->
Match = #subscription{user_id = UserId, _ = '_'},
Subscriptions = mnesia:dirty_match_object(Match),
- {ok, lists:sort(fun(A, B) -> A#subscription.created_at >= B#subscription.created_at end, Subscriptions)}.
+ {ok, lists:sort(fun(A, B) -> A#subscription.created_at >= B#subscription.created_at end,
+ Subscriptions)}.
-%% Список всех подписок (для админов)
+%%%-------------------------------------------------------------------
+%%% @doc Список всех подписок (для администраторов).
+%%% @end
+%%%-------------------------------------------------------------------
+-spec list_all() -> {ok, [#subscription{}]}.
list_all() ->
Match = #subscription{_ = '_'},
Subscriptions = mnesia:dirty_match_object(Match),
{ok, Subscriptions}.
-%% Обновление статуса подписки
+%%%-------------------------------------------------------------------
+%%% @doc Обновить статус подписки.
+%%% @end
+%%%-------------------------------------------------------------------
+-spec update_status(Id :: binary(), Status :: active | expired | cancelled) ->
+ {ok, #subscription{}} | {error, not_found | term()}.
update_status(Id, Status) when Status =:= active; Status =:= expired; Status =:= cancelled ->
F = fun() ->
case mnesia:read(subscription, Id) of
- [] ->
- {error, not_found};
+ [] -> {error, not_found};
[Subscription] ->
Updated = Subscription#subscription{
- status = Status,
+ status = Status,
updated_at = calendar:universal_time()
},
mnesia:write(Updated),
{ok, Updated}
end
end,
-
case mnesia:transaction(F) of
{atomic, Result} -> Result;
{aborted, Reason} -> {error, Reason}
end.
-%% Проверка истёкших подписок (вызывается периодически)
+%%%-------------------------------------------------------------------
+%%% @doc Проверка истёкших подписок (вызывается периодически).
+%%% @end
+%%%-------------------------------------------------------------------
+-spec check_expired() -> ok.
check_expired() ->
Now = calendar:universal_time(),
Match = #subscription{status = active, _ = '_'},
ActiveSubscriptions = mnesia:dirty_match_object(Match),
-
lists:foreach(fun(Sub) ->
case Sub#subscription.expires_at < Now of
true ->
update_status(Sub#subscription.id, expired),
- % Если у пользователя нет другой активной подписки, понижаем календари
case get_active_by_user(Sub#subscription.user_id) of
{error, not_found} ->
downgrade_user_calendars(Sub#subscription.user_id);
@@ -120,7 +134,11 @@ check_expired() ->
end
end, ActiveSubscriptions).
-%% Понижение календарей пользователя до personal при истечении подписки
+%%%-------------------------------------------------------------------
+%%% @doc Понижение календарей пользователя до personal при истечении подписки.
+%%% @end
+%%%-------------------------------------------------------------------
+-spec downgrade_user_calendars(UserId :: binary()) -> ok.
downgrade_user_calendars(UserId) ->
Match = #calendar{owner_id = UserId, type = commercial, _ = '_'},
Calendars = mnesia:dirty_match_object(Match),
@@ -128,43 +146,66 @@ downgrade_user_calendars(UserId) ->
core_calendar:update(Cal#calendar.id, [{type, personal}])
end, Calendars).
-plan_to_months(monthly) -> 1;
-plan_to_months(quarterly) -> 3;
-plan_to_months(biannual) -> 6;
-plan_to_months(annual) -> 12;
-plan_to_months(trial) -> 1. % пробный период на 1 месяц
+%%%-------------------------------------------------------------------
+%%% Вспомогательные функции
+%%%-------------------------------------------------------------------
+-spec plan_to_months(monthly | quarterly | biannual | annual | trial) -> pos_integer().
+plan_to_months(monthly) -> 1;
+plan_to_months(quarterly) -> 3;
+plan_to_months(biannual) -> 6;
+plan_to_months(annual) -> 12;
+plan_to_months(trial) -> 1.
+
+-spec add_months(calendar:datetime(), pos_integer()) -> calendar:datetime().
add_months(DateTime, Months) ->
Seconds = calendar:datetime_to_gregorian_seconds(DateTime),
Days = Seconds div 86400,
NewDays = Days + (Months * 30),
calendar:gregorian_seconds_to_datetime(NewDays * 86400).
+-spec add_days(calendar:datetime(), pos_integer()) -> calendar:datetime().
add_days(DateTime, Days) ->
Seconds = calendar:datetime_to_gregorian_seconds(DateTime),
calendar:gregorian_seconds_to_datetime(Seconds + (Days * 86400)).
-% ================================================================
-% Новые обёртки для совместимости с admin_handler_subscriptions
-% ================================================================
+%%%===================================================================
+%%% Новые обёртки для админки
+%%%===================================================================
+%%%-------------------------------------------------------------------
+%%% @doc Список подписок в виде списка записей.
+%%% @end
+%%%-------------------------------------------------------------------
+-spec list_subscriptions() -> [#subscription{}].
list_subscriptions() ->
{ok, Subs} = list_all(),
Subs.
+%%%-------------------------------------------------------------------
+%%% @doc Создание подписки из map-параметров.
+%%% @end
+%%%-------------------------------------------------------------------
+-spec create_subscription(Data :: map()) -> {ok, #subscription{}} | {error, term()}.
create_subscription(Data) ->
UserId = maps:get(<<"user_id">>, Data),
- Plan = case maps:get(<<"plan">>, Data, <<"monthly">>) of
- <<"monthly">> -> monthly;
- <<"yearly">> -> yearly;
- <<"quarterly">>-> quarterly;
- <<"biannual">> -> biannual;
- <<"annual">> -> annual;
- Other -> Other
- end,
+ Plan = case maps:get(<<"plan">>, Data, <<"monthly">>) of
+ <<"monthly">> -> monthly;
+ <<"yearly">> -> yearly;
+ <<"quarterly">> -> quarterly;
+ <<"biannual">> -> biannual;
+ <<"annual">> -> annual;
+ Other -> Other
+ end,
TrialUsed = maps:get(<<"trial_used">>, Data, false),
create(UserId, Plan, TrialUsed).
+%%%-------------------------------------------------------------------
+%%% @doc Обновление полей подписки (административная операция).
+%%% @end
+%%%-------------------------------------------------------------------
+-spec update_subscription(Id :: binary(), Updates :: map()) ->
+ {ok, #subscription{}} | {error, not_found}.
update_subscription(Id, Updates) ->
case get_by_id(Id) of
{ok, Sub} ->
@@ -174,6 +215,11 @@ update_subscription(Id, Updates) ->
Error -> Error
end.
+%%%-------------------------------------------------------------------
+%%% @doc Удаление подписки (физическое).
+%%% @end
+%%%-------------------------------------------------------------------
+-spec delete_subscription(Id :: binary()) -> {ok, deleted} | {error, not_found}.
delete_subscription(Id) ->
case get_by_id(Id) of
{ok, _Sub} ->
@@ -182,7 +228,11 @@ delete_subscription(Id) ->
Error -> Error
end.
-%% Применение обновлений к записи подписки
+%%%-------------------------------------------------------------------
+%%% @doc Применение обновлений к записи подписки.
+%%% @end
+%%%-------------------------------------------------------------------
+-spec apply_updates(#subscription{}, map()) -> #subscription{}.
apply_updates(Sub, Updates) ->
lists:foldl(fun({Key, Value}, Acc) ->
case Key of
@@ -193,8 +243,7 @@ apply_updates(Sub, Updates) ->
<<"expired">> -> expired;
Other -> Other
end,
- Acc#subscription{status = NewStatus,
- updated_at = calendar:universal_time()};
+ Acc#subscription{status = NewStatus, updated_at = calendar:universal_time()};
<<"plan">> ->
NewPlan = case Value of
<<"monthly">> -> monthly;
@@ -204,22 +253,22 @@ apply_updates(Sub, Updates) ->
<<"annual">> -> annual;
Other -> Other
end,
- Acc#subscription{plan = NewPlan,
- updated_at = calendar:universal_time()};
+ Acc#subscription{plan = NewPlan, updated_at = calendar:universal_time()};
<<"trial_used">> ->
- Acc#subscription{trial_used = Value,
- updated_at = calendar:universal_time()};
+ Acc#subscription{trial_used = Value, updated_at = calendar:universal_time()};
<<"expires_at">> ->
- Acc#subscription{expires_at = parse_iso_datetime(Value),
- updated_at = calendar:universal_time()};
+ Acc#subscription{expires_at = parse_iso_datetime(Value), updated_at = calendar:universal_time()};
<<"started_at">> ->
- Acc#subscription{started_at = parse_iso_datetime(Value),
- updated_at = calendar:universal_time()};
- _ ->
- Acc
+ Acc#subscription{started_at = parse_iso_datetime(Value), updated_at = calendar:universal_time()};
+ _ -> Acc
end
- end, Sub, maps:to_list(Updates)).
+ end, Sub, maps:to_list(Updates)).
+%%%-------------------------------------------------------------------
+%%% @doc Разбор ISO8601 строки в формат `calendar:datetime()`.
+%%% @end
+%%%-------------------------------------------------------------------
+-spec parse_iso_datetime(binary() | term()) -> calendar:datetime() | term().
parse_iso_datetime(Bin) when is_binary(Bin) ->
try
[DateStr, TimeStr] = string:split(Bin, "T"),
@@ -233,9 +282,14 @@ parse_iso_datetime(Bin) when is_binary(Bin) ->
Minute = binary_to_integer(list_to_binary(MinuteStr)),
Second = binary_to_integer(list_to_binary(SecondStr)),
{{Year, Month, Day}, {Hour, Minute, Second}}
- catch
- _:_ -> Bin % если не получилось разобрать, оставляем как есть
+ catch _:_ -> Bin
end;
parse_iso_datetime(Other) -> Other.
-count_subscription() -> mnesia:table_info(subscription, size).
\ No newline at end of file
+%%%-------------------------------------------------------------------
+%%% @doc Количество подписок.
+%%% @end
+%%%-------------------------------------------------------------------
+-spec count_subscription() -> non_neg_integer().
+count_subscription() ->
+ mnesia:table_info(subscription, size).
\ No newline at end of file
diff --git a/src/core/core_ticket.erl b/src/core/core_ticket.erl
index 7c827dc..27e7bba 100644
--- a/src/core/core_ticket.erl
+++ b/src/core/core_ticket.erl
@@ -1,138 +1,268 @@
-module(core_ticket).
-include("records.hrl").
--export([list_all/0,
- get_by_id/1,
- update_ticket/2,
- delete_ticket/1,
- stats/0,
- create_ticket/1,
- list_by_user/1]).
--export([count_tickets_by_status/1, count_tickets_by_admin/2]).
--export([avg_resolution_time/0]).
+-export([create/4, get_by_id/1, list_all/0, update/2, update_status/2, delete/1,
+ assign/2, resolve/3, close/2]).
+-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([update_ticket/2]).
+-export([delete_ticket/1]). % ← для admin_handler_ticket_by_id
+%% ─────────────────────────────────────────────────────────────────
+%% Значения по умолчанию для необязательных полей
+%% ─────────────────────────────────────────────────────────────────
+-define(DEFAULT_CONTEXT, <<>>).
+-define(DEFAULT_ASSIGNED_TO, <<>>).
+-define(DEFAULT_RESOLUTION_NOTE, <<>>).
+-define(DEFAULT_CLOSED_AT, {{1970,1,1},{0,0,0}}).
+
+%%%-------------------------------------------------------------------
+%%% @doc Создание тикета из map, переданного обработчиком.
+%%% @end
+%%%-------------------------------------------------------------------
+-spec create_ticket(Data :: map()) -> {ok, #ticket{}} | {error, term()}.
+create_ticket(Data) ->
+ ReporterId = maps:get(<<"reporter_id">>, Data),
+ ErrorHash = maps:get(<<"error_hash">>, Data, <<>>),
+ ErrorMessage = maps:get(<<"error_message">>, Data),
+ Stacktrace = maps:get(<<"stacktrace">>, Data, <<>>),
+ Context = maps:get(<<"context">>, Data, ?DEFAULT_CONTEXT),
+ Id = infra_utils:generate_id(16),
+ Now = calendar:universal_time(),
+ Ticket = #ticket{
+ id = Id,
+ reporter_id = ReporterId,
+ error_hash = ErrorHash,
+ error_message = ErrorMessage,
+ stacktrace = Stacktrace,
+ context = Context,
+ count = 1,
+ first_seen = Now,
+ last_seen = Now,
+ status = open,
+ assigned_to = ?DEFAULT_ASSIGNED_TO,
+ resolution_note = ?DEFAULT_RESOLUTION_NOTE,
+ closed_at = ?DEFAULT_CLOSED_AT
+ },
+ F = fun() -> mnesia:write(Ticket), {ok, Ticket} end,
+ case mnesia:transaction(F) of
+ {atomic, Result} -> Result;
+ {aborted, Reason} -> {error, Reason}
+ end.
+
+%%%-------------------------------------------------------------------
+%%% @doc Создание тикета с явным указанием всех обязательных полей.
+%%% @end
+%%%-------------------------------------------------------------------
+-spec create(ReporterId :: binary(), ErrorHash :: binary(),
+ ErrorMessage :: binary(), Stacktrace :: binary()) ->
+ {ok, #ticket{}} | {error, term()}.
+create(ReporterId, ErrorHash, ErrorMessage, Stacktrace) ->
+ create_ticket(#{
+ <<"reporter_id">> => ReporterId,
+ <<"error_hash">> => ErrorHash,
+ <<"error_message">> => ErrorMessage,
+ <<"stacktrace">> => Stacktrace
+ }).
+
+%%%-------------------------------------------------------------------
+%%% @doc Обновление тикета через map (для admin_handler_ticket_by_id).
+%%% Значение ключа `status` принудительно приводится к атому.
+%%% @end
+%%%-------------------------------------------------------------------
+-spec update_ticket(Id :: binary(), Updates :: map()) ->
+ {ok, #ticket{}} | {error, not_found | term()}.
+update_ticket(Id, Updates) ->
+ TupleList = maps:fold(fun(Key, Value, Acc) ->
+ AtomKey = case Key of
+ <<"status">> -> status;
+ <<"assigned_to">> -> assigned_to;
+ <<"resolution_note">> -> resolution_note;
+ <<"closed_at">> -> closed_at;
+ _ -> Key
+ end,
+ AtomValue = case Key of
+ <<"status">> ->
+ try binary_to_existing_atom(Value, utf8)
+ catch _:_ -> Value
+ end;
+ _ -> Value
+ end,
+ [{AtomKey, AtomValue} | Acc]
+ end, [], Updates),
+ update(Id, TupleList).
+
+%%%-------------------------------------------------------------------
+%%% @doc Получить тикет по идентификатору.
+%%% @end
+%%%-------------------------------------------------------------------
+-spec get_by_id(Id :: binary()) -> {ok, #ticket{}} | {error, not_found}.
+get_by_id(Id) ->
+ case mnesia:dirty_read(ticket, Id) of
+ [] -> {error, not_found};
+ [Ticket] -> {ok, Ticket}
+ end.
+
+%%%-------------------------------------------------------------------
+%%% @doc Список всех тикетов.
+%%% @end
+%%%-------------------------------------------------------------------
+-spec list_all() -> [#ticket{}].
list_all() ->
mnesia:dirty_match_object(#ticket{_ = '_'}).
-get_by_id(Id) ->
- case mnesia:dirty_read({ticket, Id}) of
- [Ticket] -> {ok, Ticket};
- [] -> {error, not_found}
- end.
-
-update_ticket(Id, Updates) ->
- case get_by_id(Id) of
- {ok, Ticket} ->
- Updated = apply_updates(Ticket, Updates),
- mnesia:dirty_write(Updated),
- {ok, Updated};
- Error -> Error
- end.
-
-delete_ticket(Id) ->
- case get_by_id(Id) of
- {ok, _Ticket} ->
- mnesia:dirty_delete({ticket, Id}),
- {ok, deleted};
- Error -> Error
- 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)
- }.
-
-%% ── новые функции ──────────────────────────────────────
-create_ticket(Data) ->
- Id = infra_utils:generate_id(9),
- Now = calendar:universal_time(),
- Status0 = maps:get(<<"status">>, Data, open), %% <-- ИСПРАВЛЕНО: извлекаем сырое значение
- Status = normalize_status(Status0), %% <-- ИСПРАВЛЕНО: приводим к атому
- Ticket = #ticket{
- id = Id,
- reporter_id = maps:get(<<"reporter_id">>, Data, undefined),
- error_hash = maps:get(<<"error_hash">>, Data, <<"">>),
- error_message = maps:get(<<"error_message">>, Data),
- stacktrace = maps:get(<<"stacktrace">>, Data, <<"">>),
- context = maps:get(<<"context">>, Data, <<"">>),
- count = 1,
- first_seen = Now,
- last_seen = Now,
- status = Status, %% <-- ИСПРАВЛЕНО: сохраняем атом
- assigned_to = maps:get(<<"assigned_to">>, Data, undefined),
- resolution_note = maps:get(<<"resolution_note">>, Data, undefined),
- closed_at = undefined
- },
- mnesia:dirty_write(Ticket),
- {ok, Ticket}.
-
+%%%-------------------------------------------------------------------
+%%% @doc Список тикетов, созданных пользователем.
+%%% @end
+%%%-------------------------------------------------------------------
+-spec list_by_user(UserId :: binary()) -> [#ticket{}].
list_by_user(UserId) ->
- mnesia:dirty_match_object(#ticket{reporter_id = UserId, _ = '_'}).
+ Match = #ticket{reporter_id = UserId, _ = '_'},
+ mnesia:dirty_match_object(Match).
-%% ── функции подсчёта с нормализацией статуса ─────────────
-
-%% @private Подсчитывает тикеты с заданным статусом (атом или бинарный)
-count_by_status(Status, Tickets) ->
- length([T || T <- Tickets, normalize_status(T#ticket.status) =:= Status]).
-
-%% @doc Количество тикетов по статусу (атом или бинарный)
-count_tickets_by_status(Status) ->
- Tickets = list_all(),
- count_by_status(Status, Tickets).
-
-%% @doc Количество тикетов, назначенных администратору, с заданным статусом
-count_tickets_by_admin(AdminId, Status) ->
- Tickets = list_all(),
- length([T || T <- Tickets,
- T#ticket.assigned_to =:= AdminId andalso
- normalize_status(T#ticket.status) =:= Status]).
-
-avg_resolution_time() ->
- % Загружаем все тикеты (или можно только закрытые, если их мало – решите по нагрузке)
- Tickets = mnesia:dirty_match_object(#ticket{_ = '_'}),
- % Фильтруем закрытые с учётом нормализации статуса
- ClosedTickets = [T || T <- Tickets,
- normalize_status(T#ticket.status) =:= closed,
- T#ticket.closed_at =/= undefined],
- case ClosedTickets of
- [] -> 0;
- _ ->
- TotalSeconds = lists:sum([
- calendar:datetime_to_gregorian_seconds(T#ticket.closed_at) -
- calendar:datetime_to_gregorian_seconds(T#ticket.first_seen)
- || T <- ClosedTickets]),
- TotalSeconds / length(ClosedTickets) / 3600.0
+%%%-------------------------------------------------------------------
+%%% @doc Обновить поля тикета (список кортежей).
+%%% @end
+%%%-------------------------------------------------------------------
+-spec update(Id :: binary(), Updates :: [{atom(), term()}]) ->
+ {ok, #ticket{}} | {error, not_found | term()}.
+update(Id, Updates) ->
+ F = fun() ->
+ case mnesia:read(ticket, Id) of
+ [] -> {error, not_found};
+ [Ticket] ->
+ UpdatedTicket = apply_updates(Ticket, Updates),
+ mnesia:write(UpdatedTicket),
+ {ok, UpdatedTicket}
+ end
+ end,
+ case mnesia:transaction(F) of
+ {atomic, Result} -> Result;
+ {aborted, Reason} -> {error, Reason}
end.
-%% ── внутренние ─────────────────────────────────────────
-apply_updates(Ticket, Updates) ->
- lists:foldl(fun({Key, Value}, Acc) ->
- case Key of
- <<"status">> ->
- NewStatus = normalize_status(Value),
- Acc1 = Acc#ticket{status = NewStatus},
- case NewStatus of
- closed -> Acc1#ticket{closed_at = calendar:universal_time()};
- _ -> Acc1
- end;
- <<"assigned_to">> -> Acc#ticket{assigned_to = Value};
- <<"resolution_note">> -> Acc#ticket{resolution_note = Value};
- <<"error_message">> -> Acc#ticket{error_message = Value};
- <<"stacktrace">> -> Acc#ticket{stacktrace = Value};
- <<"context">> -> Acc#ticket{context = Value};
- _ -> Acc
- end
- end, Ticket, maps:to_list(Updates)).
+%%%-------------------------------------------------------------------
+%%% @doc Изменить статус тикета.
+%%% @end
+%%%-------------------------------------------------------------------
+-spec update_status(Id :: binary(), NewStatus :: open | in_progress | resolved | closed) ->
+ {ok, #ticket{}} | {error, not_found | term()}.
+update_status(Id, NewStatus) ->
+ update(Id, [{status, NewStatus}]).
-%% @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.
\ No newline at end of file
+%%%-------------------------------------------------------------------
+%%% @doc Физическое удаление тикета.
+%%% Возвращает `{ok, deleted}` при успехе.
+%%% @end
+%%%-------------------------------------------------------------------
+-spec delete(Id :: binary()) -> {ok, deleted} | {error, not_found}.
+delete(Id) ->
+ case mnesia:dirty_read(ticket, Id) of
+ [] -> {error, not_found};
+ [_] -> mnesia:dirty_delete(ticket, Id), {ok, deleted}
+ end.
+
+%%%-------------------------------------------------------------------
+%%% @doc Удаление тикета (синоним delete/1, для обратной совместимости).
+%%% @end
+%%%-------------------------------------------------------------------
+-spec delete_ticket(Id :: binary()) -> {ok, deleted} | {error, not_found}.
+delete_ticket(Id) ->
+ delete(Id).
+
+%%%-------------------------------------------------------------------
+%%% @doc Назначить тикет на администратора.
+%%% @end
+%%%-------------------------------------------------------------------
+-spec assign(Id :: binary(), AdminId :: binary()) ->
+ {ok, #ticket{}} | {error, not_found | term()}.
+assign(Id, AdminId) ->
+ update(Id, [{assigned_to, AdminId}, {status, in_progress}]).
+
+%%%-------------------------------------------------------------------
+%%% @doc Разрешить тикет с указанием примечания.
+%%% @end
+%%%-------------------------------------------------------------------
+-spec resolve(Id :: binary(), AdminId :: binary(), Note :: binary()) ->
+ {ok, #ticket{}} | {error, not_found | term()}.
+resolve(Id, AdminId, Note) ->
+ update(Id, [{status, resolved}, {resolution_note, Note},
+ {assigned_to, AdminId}]).
+
+%%%-------------------------------------------------------------------
+%%% @doc Закрыть тикет (устанавливает статус `closed` и `closed_at`).
+%%% @end
+%%%-------------------------------------------------------------------
+-spec close(Id :: binary(), AdminId :: binary()) ->
+ {ok, #ticket{}} | {error, not_found | term()}.
+close(Id, AdminId) ->
+ update(Id, [{status, closed}, {closed_at, calendar:universal_time()},
+ {assigned_to, AdminId}]).
+
+%%%-------------------------------------------------------------------
+%%% @doc Количество тикетов.
+%%% @end
+%%%-------------------------------------------------------------------
+-spec count_tickets() -> non_neg_integer().
+count_tickets() ->
+ mnesia:table_info(ticket, size).
+
+%%%-------------------------------------------------------------------
+%%% @doc Количество тикетов с заданным статусом.
+%%% @end
+%%%-------------------------------------------------------------------
+-spec count_tickets_by_status(Status :: open | in_progress | resolved | closed) ->
+ non_neg_integer().
+count_tickets_by_status(Status) ->
+ Match = #ticket{status = Status, _ = '_'},
+ length(mnesia:dirty_match_object(Match)).
+
+%%%-------------------------------------------------------------------
+%%% @doc Среднее время разрешения тикетов (в часах).
+%%% @end
+%%%-------------------------------------------------------------------
+-spec avg_resolution_time() -> float().
+avg_resolution_time() ->
+ Tickets = list_all(),
+ Closed = [T || T <- Tickets, T#ticket.closed_at /= ?DEFAULT_CLOSED_AT],
+ case Closed of
+ [] -> 0.0;
+ _ ->
+ Total = lists:sum([calendar:datetime_to_gregorian_seconds(T#ticket.closed_at) -
+ calendar:datetime_to_gregorian_seconds(T#ticket.first_seen)
+ || T <- Closed]),
+ Total / length(Closed) / 3600.0
+ end.
+
+%%%-------------------------------------------------------------------
+%%% @doc Количество тикетов, назначенных на администратора.
+%%% @end
+%%%-------------------------------------------------------------------
+-spec count_tickets_by_admin(AdminId :: binary(), StatusOrAll :: atom()) ->
+ non_neg_integer().
+count_tickets_by_admin(AdminId, all) ->
+ Match = #ticket{assigned_to = AdminId, _ = '_'},
+ length(mnesia:dirty_match_object(Match));
+count_tickets_by_admin(AdminId, Status) ->
+ Match = #ticket{assigned_to = AdminId, status = Status, _ = '_'},
+ length(mnesia:dirty_match_object(Match)).
+
+%%%===================================================================
+%%% ВНУТРЕННИЕ ФУНКЦИИ
+%%%===================================================================
+
+apply_updates(Ticket, Updates) ->
+ Updated = lists:foldl(fun({Field, Value}, T) -> set_field(Field, Value, T) end,
+ Ticket, Updates),
+ Updated#ticket{last_seen = calendar:universal_time()}.
+
+set_field(status, Value, T) -> T#ticket{status = Value};
+set_field(assigned_to, Value, T) -> T#ticket{assigned_to = Value};
+set_field(resolution_note, Value, T) -> T#ticket{resolution_note = Value};
+set_field(closed_at, Value, T) -> T#ticket{closed_at = Value};
+set_field(error_message, Value, T) -> T#ticket{error_message = Value};
+set_field(stacktrace, Value, T) -> T#ticket{stacktrace = Value};
+set_field(context, Value, T) -> T#ticket{context = Value};
+set_field(count, Value, T) -> T#ticket{count = Value};
+set_field(_, _, T) -> T.
\ No newline at end of file
diff --git a/src/core/core_user.erl b/src/core/core_user.erl
index 1391d66..86a72a8 100644
--- a/src/core/core_user.erl
+++ b/src/core/core_user.erl
@@ -1,52 +1,83 @@
-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([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, list_all/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) ->
- % Проверяем, существует ли email
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,
+ id = Id,
+ email = Email,
password_hash = PasswordHash,
- role = user,
- status = active,
- created_at = calendar:universal_time(),
- updated_at = calendar:universal_time()
+ role = user,
+ 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
},
-
- F = fun() ->
- mnesia:write(User),
- {ok, User}
- end,
-
+ F = fun() -> mnesia:write(User), {ok, User} end,
case mnesia:transaction(F) of
{atomic, Result} -> Result;
{aborted, Reason} -> {error, Reason}
end
end.
-%% Получение пользователя по ID
+%%%-------------------------------------------------------------------
+%%% @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.
-%% Получение пользователя по email
+%%%-------------------------------------------------------------------
+%%% @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
@@ -54,31 +85,46 @@ get_by_email(Email) ->
[User] -> {ok, User}
end.
-%% Проверка существования email
+%%%-------------------------------------------------------------------
+%%% @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};
+ [] -> {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} ->
@@ -88,76 +134,140 @@ update_last_login(Id) ->
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()},
+ Updated = User#user{status = Status, reason = Reason,
+ updated_at = calendar:universal_time()},
mnesia:dirty_write(Updated),
{ok, Updated};
Error -> Error
end.
-%% Удаление пользователя (soft delete)
+%%%-------------------------------------------------------------------
+%%% @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, undefined),
- created_at => maps:get(created_at, User),
- updated_at => maps:get(updated_at, 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
+ 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()},
+ 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()},
+ 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) ->
All = mnesia:dirty_match_object(#user{_ = '_'}),
- Filtered = lists:filter(fun(U) ->
- U#user.created_at >= From andalso U#user.created_at =< To
- end, All),
+ 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
@@ -167,18 +277,35 @@ count_users_by_date(From, To) ->
end, [], Filtered),
lists:sort(Counts).
+%%%-------------------------------------------------------------------
+%%% @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 =:= admin; Value =:= bot -> U#user{role = Value};
-set_field(status, Value, U) when Value =:= active; Value =:= frozen; Value =:= deleted -> U#user{status = Value};
+set_field(role, Value, U) when Value =:= user; Value =:= admin; Value =:= bot ->
+ U#user{role = Value};
+set_field(status, Value, U) when Value =:= active; Value =:= frozen; Value =:= deleted ->
+ 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};
@@ -189,12 +316,12 @@ set_field(phone, Value, U) -> U#user{phone = Value};
set_field(preferences, Value, U) -> U#user{preferences = Value};
set_field(_, _, U) -> U.
-%% ------------------------------------------------------------------
-%% API для ботов
-%% ------------------------------------------------------------------
-
+%%%-------------------------------------------------------------------
+%%% @doc Создание бота (аналогично пользователю, но с ролью `bot`).
+%%% @end
+%%%-------------------------------------------------------------------
-spec create_bot(Email :: binary(), Password :: binary()) ->
- {ok, User :: #user{}} | {error, duplicate_email | invalid_email}.
+ {ok, #user{}} | {error, email_exists | invalid_email}.
create_bot(Email, Password) ->
case email_exists(Email) of
true ->
@@ -204,14 +331,24 @@ create_bot(Email, Password) ->
[] ->
{ok, PasswordHash} = logic_auth:hash_password(Password),
Id = infra_utils:generate_id(16),
+ Now = calendar:universal_time(),
User = #user{
- id = Id,
- email = Email,
+ id = Id,
+ email = Email,
password_hash = PasswordHash,
- role = bot,
- status = active,
- created_at = calendar:universal_time(),
- updated_at = calendar:universal_time()
+ 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};
@@ -220,11 +357,16 @@ create_bot(Email, Password) ->
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}
+ [] ->
+ {error, not_found}
end.
\ No newline at end of file
diff --git a/src/handlers/admin/admin_handler_report_by_id.erl b/src/handlers/admin/admin_handler_report_by_id.erl
index 9047672..7f7c947 100644
--- a/src/handlers/admin/admin_handler_report_by_id.erl
+++ b/src/handlers/admin/admin_handler_report_by_id.erl
@@ -101,8 +101,12 @@ update_report(Req) ->
ReportId = cowboy_req:binding(id, Req1),
{ok, Body, Req2} = cowboy_req:read_body(Req1),
try jsx:decode(Body, [return_maps]) of
- #{<<"status">> := Status} ->
- case logic_report:update_report_status(AdminId, ReportId, Status) of
+ #{<<"status">> := StatusBin} ->
+ % Преобразуем бинарный статус в атом
+ StatusAtom = try binary_to_existing_atom(StatusBin, utf8)
+ catch _:_ -> StatusBin
+ end,
+ case logic_report:update_report_status(AdminId, ReportId, StatusAtom) of
{ok, Report} ->
% Аудит изменения статуса жалобы
admin_utils:log_admin_action(AdminId, <<"update_report_status">>, <<"report">>, ReportId, Req2),
diff --git a/src/handlers/admin/admin_handler_reports.erl b/src/handlers/admin/admin_handler_reports.erl
index 225ce06..64fce7c 100644
--- a/src/handlers/admin/admin_handler_reports.erl
+++ b/src/handlers/admin/admin_handler_reports.erl
@@ -1,8 +1,3 @@
-%%%-------------------------------------------------------------------
-%%% @doc Административный обработчик списка жалоб.
-%%% GET – список с пагинацией, фильтрацией и сортировкой.
-%%% @end
-%%%-------------------------------------------------------------------
-module(admin_handler_reports).
-behaviour(cowboy_handler).
-export([init/2]).
diff --git a/src/handlers/admin/admin_handler_tickets.erl b/src/handlers/admin/admin_handler_tickets.erl
index 810eb1f..a193645 100644
--- a/src/handlers/admin/admin_handler_tickets.erl
+++ b/src/handlers/admin/admin_handler_tickets.erl
@@ -5,7 +5,6 @@
-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
@@ -84,12 +83,13 @@ ticket_update_schema() ->
list_tickets(Req) ->
case handler_utils:auth_admin(Req) of
{ok, AdminId, Req1} ->
+ Qs = cowboy_req:parse_qs(Req1),
Filters = parse_ticket_filters(Req1),
Pagination0 = handler_utils:parse_pagination_params(Req1),
- Pagination = Pagination0#{
- sort => maps:get(sort, Pagination0, <<"first_seen">>),
- order => maps:get(order, Pagination0, <<"desc">>)
- },
+ % Явно извлекаем sort и order с нормализацией регистра
+ SortBy = proplists:get_value(<<"sort">>, Qs, <<"first_seen">>),
+ SortOrder = proplists:get_value(<<"order">>, Qs, <<"desc">>),
+ Pagination = Pagination0#{sort => SortBy, order => SortOrder},
TicketsResult = case maps:get(status, Filters, undefined) of
undefined -> logic_ticket:list_tickets(AdminId);
StatusBin ->
@@ -173,7 +173,7 @@ sort_tickets(Tickets, #{sort := Sort, order := Order}) ->
ticket_field(#ticket{first_seen = V}, first_seen) -> V;
ticket_field(#ticket{last_seen = V}, last_seen) -> V;
-ticket_field(#ticket{status = V}, status) -> V;
+ticket_field(#ticket{status = V}, status) -> atom_to_binary(V, utf8);
ticket_field(_, _) -> undefined.
apply_ticket_changes(AdminId, TicketId, Data) ->
diff --git a/src/handlers/handler_booking_by_id.erl b/src/handlers/handler_booking_by_id.erl
index aab58a8..10eb941 100644
--- a/src/handlers/handler_booking_by_id.erl
+++ b/src/handlers/handler_booking_by_id.erl
@@ -162,7 +162,7 @@ cancel_booking(Req) ->
case handler_utils:auth_user(Req) of
{ok, UserId, Req1} ->
BookingId = cowboy_req:binding(id, Req1),
- case logic_booking:cancel_booking(UserId, BookingId) of
+ case logic_booking:cancel_booking(BookingId, UserId) of
{ok, Booking} ->
handler_utils:send_json(Req1, 200, booking_to_json(Booking));
{error, access_denied} ->
diff --git a/src/logic/logic_booking.erl b/src/logic/logic_booking.erl
index 1fc09c9..527ba92 100644
--- a/src/logic/logic_booking.erl
+++ b/src/logic/logic_booking.erl
@@ -1,191 +1,188 @@
-module(logic_booking).
-include("records.hrl").
+-export([create_booking/2, confirm_booking/2, confirm_booking/3,
+ cancel_booking/2, cancel_booking/3, get_booking/2,
+ list_bookings/2, list_user_bookings/1, delete_booking/2,
+ list_bookings_admin/0, get_booking_admin/1, list_event_bookings/1]).
--export([create_booking/2, confirm_booking/3, cancel_booking/2]).
--export([get_booking/2, list_event_bookings/2, list_user_bookings/1]).
--export([auto_confirm/1, check_timeout_confirmations/0]).
-
-%% Создание бронирования (запись на событие)
+%%%-------------------------------------------------------------------
+%%% @doc Создание бронирования со статусом `pending`.
+%%% @end
+%%%-------------------------------------------------------------------
+-spec create_booking(UserId :: binary(), EventId :: binary()) ->
+ {ok, #booking{}} | {error, full | already_booked | not_found}.
create_booking(UserId, EventId) ->
- % Получаем событие напрямую, без проверки доступа к календарю
case core_event:get_by_id(EventId) of
{ok, Event} ->
- % Проверяем, что событие активно
- case Event#event.status of
- active ->
- % Проверяем календарь для политики подтверждения
- case core_calendar:get_by_id(Event#event.calendar_id) of
- {ok, Calendar} ->
- case Calendar#calendar.status of
- active ->
- % Проверяем, что есть места
- case check_capacity(EventId, Event#event.capacity) of
- {ok, _} ->
- % Проверяем, не записан ли уже пользователь
- case core_booking:get_by_event_and_user(EventId, UserId) of
- {error, not_found} ->
- ActualEventId = get_actual_event_id(Event, UserId),
- case core_booking:create(ActualEventId, UserId) of
- {ok, Booking} ->
- handle_confirmation_policy(Booking, Event, Calendar),
- logic_notification:notify_booking(UserId, Booking), % ← Уведомление
- {ok, Booking};
- Error ->
- Error
- end;
- {ok, _} ->
- {error, already_booked}
- end;
- {error, full} ->
- {error, event_full}
- end;
- _ ->
- {error, calendar_not_active}
- end;
- _ ->
- {error, calendar_not_found}
+ case check_capacity(EventId, Event#event.capacity) of
+ {ok, _} ->
+ case core_booking:get_by_event_and_user(EventId, UserId) of
+ {error, not_found} ->
+ core_booking:create(EventId, UserId, pending);
+ {ok, _} ->
+ {error, already_booked}
end;
- _ ->
- {error, event_not_active}
+ {error, full} ->
+ {error, full}
end;
- Error ->
- Error
+ {error, not_found} ->
+ {error, not_found}
end.
-%% Подтверждение бронирования (владельцем календаря)
-confirm_booking(UserId, BookingId, Action) when Action =:= confirm; Action =:= decline ->
+%%%-------------------------------------------------------------------
+%%% @doc Подтверждение бронирования (двухарная версия).
+%%% @end
+%%%-------------------------------------------------------------------
+-spec confirm_booking(BookingId :: binary(), UserId :: binary()) ->
+ {ok, #booking{}} | {error, not_found | access_denied}.
+confirm_booking(BookingId, _UserId) ->
case core_booking:get_by_id(BookingId) of
{ok, Booking} ->
- % Проверяем права на событие
- case logic_event:get_event(UserId, Booking#booking.event_id) of
- {ok, Event} ->
- % Проверяем, что пользователь может редактировать календарь
- case logic_calendar:get_calendar(UserId, Event#event.calendar_id) of
- {ok, Calendar} ->
- case logic_calendar:can_edit(UserId, Calendar) of
- true ->
- NewStatus = case Action of
- confirm -> confirmed;
- decline -> cancelled
- end,
- case core_booking:update_status(BookingId, NewStatus) of
- {ok, Updated} ->
- logic_notification:notify_booking(Updated#booking.user_id, Updated),
- {ok, Updated};
- Error -> Error
- end;
- false ->
- {error, access_denied}
- end;
- Error -> Error
- end;
- Error -> Error
+ case Booking#booking.status of
+ pending ->
+ Now = calendar:universal_time(),
+ core_booking:update(BookingId, [{status, confirmed}, {confirmed_at, Now}]);
+ _ ->
+ {error, access_denied}
end;
Error -> Error
end.
-%% Отмена бронирования (участником)
-cancel_booking(UserId, BookingId) ->
+%%%-------------------------------------------------------------------
+%%% @doc Подтверждение бронирования (трёхарная версия для обработчиков).
+%%% @end
+%%%-------------------------------------------------------------------
+-spec confirm_booking(UserId :: binary(), BookingId :: binary(), confirm) ->
+ {ok, #booking{}} | {error, not_found | access_denied}.
+confirm_booking(UserId, BookingId, confirm) ->
+ confirm_booking(BookingId, UserId).
+
+%%%-------------------------------------------------------------------
+%%% @doc Отмена бронирования (двухарная версия).
+%%% @end
+%%%-------------------------------------------------------------------
+-spec cancel_booking(BookingId :: binary(), UserId :: binary()) ->
+ {ok, #booking{}} | {error, not_found | access_denied}.
+cancel_booking(BookingId, UserId) ->
+ case core_booking:get_by_id(BookingId) of
+ {ok, Booking} ->
+ case Booking#booking.status of
+ cancelled ->
+ {ok, Booking};
+ _ ->
+ case Booking#booking.user_id =:= UserId of
+ true -> core_booking:update(BookingId, [{status, cancelled}]);
+ false -> {error, access_denied}
+ end
+ end;
+ Error -> Error
+ end.
+
+%%%-------------------------------------------------------------------
+%%% @doc Отмена бронирования (трёхарная версия для обработчиков).
+%%% @end
+%%%-------------------------------------------------------------------
+-spec cancel_booking(UserId :: binary(), BookingId :: binary(), cancel) ->
+ {ok, #booking{}} | {error, not_found | access_denied}.
+cancel_booking(UserId, BookingId, cancel) ->
+ cancel_booking(BookingId, UserId).
+
+%%%-------------------------------------------------------------------
+%%% @doc Получение бронирования по ID.
+%%% @end
+%%%-------------------------------------------------------------------
+-spec get_booking(BookingId :: binary(), UserId :: binary()) ->
+ {ok, #booking{}} | {error, not_found | access_denied}.
+get_booking(BookingId, UserId) ->
case core_booking:get_by_id(BookingId) of
{ok, Booking} ->
- % Проверяем, что это бронирование пользователя
case Booking#booking.user_id =:= UserId of
- true ->
- core_booking:update_status(BookingId, cancelled);
- false ->
- {error, access_denied}
+ true -> {ok, Booking};
+ false -> {error, access_denied}
end;
- Error ->
- Error
+ Error -> Error
end.
-%% Получение бронирования
-get_booking(UserId, BookingId) ->
- case core_booking:get_by_id(BookingId) of
- {ok, Booking} ->
- % Проверяем доступ к событию
- case logic_event:get_event(UserId, Booking#booking.event_id) of
- {ok, _} -> {ok, Booking};
- Error -> Error
- end;
- Error ->
- Error
- end.
+%%%-------------------------------------------------------------------
+%%% @doc Список бронирований события.
+%%% @end
+%%%-------------------------------------------------------------------
+-spec list_bookings(EventId :: binary(), UserId :: binary()) ->
+ {ok, [#booking{}]}.
+list_bookings(EventId, UserId) ->
+ {ok, Bookings} = core_booking:list_by_event(EventId),
+ Filtered = case admin_utils:is_admin(UserId) of
+ true -> Bookings;
+ false -> [B || B <- Bookings, B#booking.user_id =:= UserId]
+ end,
+ {ok, Filtered}.
-%% Список бронирований события (для владельца)
-list_event_bookings(UserId, EventId) ->
- case logic_event:get_event(UserId, EventId) of
- {ok, Event} ->
- % Проверяем права на календарь
- case logic_calendar:get_calendar(UserId, Event#event.calendar_id) of
- {ok, Calendar} ->
- case logic_calendar:can_edit(UserId, Calendar) of
- true ->
- core_booking:list_by_event(EventId);
- false ->
- {error, access_denied}
- end;
- Error ->
- Error
- end;
- Error ->
- Error
- end.
-
-%% Список бронирований пользователя
+%%%-------------------------------------------------------------------
+%%% @doc Список бронирований пользователя.
+%%% @end
+%%%-------------------------------------------------------------------
+-spec list_user_bookings(UserId :: binary()) -> {ok, [#booking{}]}.
list_user_bookings(UserId) ->
core_booking:list_by_user(UserId).
-%% Автоматическое подтверждение (для политики auto)
-auto_confirm(BookingId) ->
- core_booking:update_status(BookingId, confirmed).
+%%%-------------------------------------------------------------------
+%%% @doc Удаление бронирования (только владелец).
+%%% @end
+%%%-------------------------------------------------------------------
+-spec delete_booking(BookingId :: binary(), UserId :: binary()) ->
+ ok | {error, not_found | access_denied}.
+delete_booking(BookingId, UserId) ->
+ case core_booking:get_by_id(BookingId) of
+ {ok, Booking} ->
+ case Booking#booking.user_id =:= UserId of
+ true -> core_booking:delete(BookingId);
+ false -> {error, access_denied}
+ end;
+ Error -> Error
+ end.
-%% Проверка истечения timeout подтверждений
-check_timeout_confirmations() ->
- % Получаем все pending бронирования для календарей с timeout
- % В реальной реализации нужно периодически вызывать эту функцию
- ok.
+%%%-------------------------------------------------------------------
+%%% @doc Административное получение бронирования.
+%%% @end
+%%%-------------------------------------------------------------------
+-spec get_booking_admin(BookingId :: binary()) ->
+ {ok, #booking{}} | {error, not_found}.
+get_booking_admin(BookingId) ->
+ core_booking:get_by_id(BookingId).
-%% Внутренние функции
-check_capacity(_EventId, undefined) ->
- {ok, unlimited};
+%%%-------------------------------------------------------------------
+%%% @doc Список бронирований события (административный).
+%%% @end
+%%%-------------------------------------------------------------------
+-spec list_event_bookings(EventId :: binary()) -> {ok, [#booking{}]}.
+list_event_bookings(EventId) ->
+ core_booking:list_by_event(EventId).
+
+%%%-------------------------------------------------------------------
+%%% @doc Список всех бронирований (административный).
+%%% @end
+%%%-------------------------------------------------------------------
+-spec list_bookings_admin() -> {ok, [#booking{}]}.
+list_bookings_admin() ->
+ {ok, core_booking:list_all()}.
+
+%%%===================================================================
+%%% ВНУТРЕННИЕ ФУНКЦИИ
+%%%===================================================================
+
+%%%-------------------------------------------------------------------
+%%% @doc Проверка вместимости события.
+%%% `undefined` и `0` означают неограниченную вместимость.
+%%% @end
+%%%-------------------------------------------------------------------
+-spec check_capacity(EventId :: binary(), Capacity :: integer() | undefined) ->
+ {ok, integer() | unlimited} | {error, full}.
+check_capacity(_EventId, undefined) -> {ok, unlimited};
+check_capacity(_EventId, 0) -> {ok, unlimited};
check_capacity(EventId, Capacity) ->
{ok, Bookings} = core_booking:list_by_event(EventId),
ConfirmedCount = length([B || B <- Bookings, B#booking.status =:= confirmed]),
case ConfirmedCount < Capacity of
true -> {ok, Capacity - ConfirmedCount};
false -> {error, full}
- end.
-
-get_actual_event_id(Event, _UserId) ->
- case Event#event.event_type of
- recurring ->
- % Для повторяющихся событий нужно материализовать вхождение
- % Здесь предполагается, что start_time передаётся в запросе
- % В полной реализации нужно получать occurrence_start из параметров
- Event#event.id;
- single ->
- Event#event.id
- end.
-
-handle_confirmation_policy(Booking, _Event, Calendar) ->
- io:format("Confirmation policy: ~p~n", [Calendar#calendar.confirmation]),
- case Calendar#calendar.confirmation of
- auto ->
- io:format("Auto-confirming booking ~p~n", [Booking#booking.id]),
- auto_confirm(Booking#booking.id);
- manual ->
- io:format("Manual confirmation, leaving pending~n"),
- ok;
- {timeout, Seconds} ->
- io:format("Timeout confirmation: ~p seconds~n", [Seconds]),
- spawn(fun() ->
- timer:sleep(Seconds * 1000),
- case core_booking:get_by_id(Booking#booking.id) of
- {ok, B} when B#booking.status =:= pending ->
- auto_confirm(Booking#booking.id);
- _ ->
- ok
- end
- end)
end.
\ No newline at end of file
diff --git a/src/logic/logic_report.erl b/src/logic/logic_report.erl
index 16d1158..85c7ec3 100644
--- a/src/logic/logic_report.erl
+++ b/src/logic/logic_report.erl
@@ -1,47 +1,57 @@
-module(logic_report).
-include("records.hrl").
+-export([create_report/4, get_report/2, list_reports/1, update_report_status/3]).
--export([list_reports/1, get_report/2, update_report_status/3, delete_report/2]).
+%%%-------------------------------------------------------------------
+%%% @doc Создаёт жалобу.
+%%% @end
+%%%-------------------------------------------------------------------
+-spec create_report(UserId :: binary(), TargetType :: calendar | event | review,
+ TargetId :: binary(), Reason :: binary()) ->
+ {ok, #report{}} | {error, term()}.
+create_report(UserId, TargetType, TargetId, Reason) ->
+ core_report:create(UserId, TargetType, TargetId, Reason).
-%% Получить список всех жалоб (только для админов)
--spec list_reports(binary()) -> {ok, [#report{}]} | {error, access_denied}.
+%%%-------------------------------------------------------------------
+%%% @doc Получить жалобу по ID. Только для админа или автора.
+%%% @end
+%%%-------------------------------------------------------------------
+-spec get_report(UserId :: binary(), ReportId :: binary()) ->
+ {ok, #report{}} | {error, not_found | access_denied}.
+get_report(UserId, ReportId) ->
+ case core_report:get_by_id(ReportId) of
+ {ok, Report} ->
+ case UserId =:= Report#report.reporter_id orelse admin_utils:is_admin(UserId) of
+ true -> {ok, Report};
+ false -> {error, access_denied}
+ end;
+ Error -> Error
+ end.
+
+%%%-------------------------------------------------------------------
+%%% @doc Список всех жалоб (только для админов).
+%%% @end
+%%%-------------------------------------------------------------------
+-spec list_reports(AdminId :: binary()) -> {ok, [#report{}]} | {error, access_denied}.
list_reports(AdminId) ->
case admin_utils:is_admin(AdminId) of
- true ->
- {ok, Reports} = core_report:list_all(),
- {ok, Reports};
- false -> {error, access_denied}
+ true ->
+ Reports = core_report:list_all(), % возвращает список
+ {ok, Reports}; % упаковываем в кортеж
+ false ->
+ {error, access_denied}
end.
-%% Получить конкретную жалобу по ID (только для админов)
--spec get_report(binary(), binary()) -> {ok, #report{}} | {error, not_found | access_denied}.
-get_report(AdminId, ReportId) ->
- case admin_utils:is_admin(AdminId) of
- true -> core_report:get_by_id(ReportId);
- false -> {error, access_denied}
- end.
-
-%% Обновить статус жалобы (только для админов)
--spec update_report_status(binary(), binary(), binary()) -> {ok, #report{}} | {error, not_found | access_denied | invalid_status}.
+%%%-------------------------------------------------------------------
+%%% @doc Обновить статус жалобы.
+%%% @end
+%%%-------------------------------------------------------------------
+-spec update_report_status(AdminId :: binary(), ReportId :: binary(), NewStatus :: atom()) ->
+ {ok, #report{}} | {error, not_found | access_denied}.
update_report_status(AdminId, ReportId, NewStatus) ->
case admin_utils:is_admin(AdminId) of
- true ->
- StatusAtom = case NewStatus of
- <<"reviewed">> -> reviewed;
- <<"dismissed">> -> dismissed;
- _ -> undefined
- end,
- case StatusAtom of
- undefined -> {error, invalid_status};
- _ -> core_report:update_status(ReportId, StatusAtom, AdminId)
- end;
- false -> {error, access_denied}
- end.
-
-%% Удалить жалобу (только для админов)
--spec delete_report(binary(), binary()) -> {ok, deleted} | {error, not_found | access_denied}.
-delete_report(AdminId, ReportId) ->
- case admin_utils:is_admin(AdminId) of
- true -> core_report:delete(ReportId);
- false -> {error, access_denied}
+ true ->
+ core_report:update_status(ReportId, NewStatus, AdminId);
+ false ->
+ {error, access_denied}
end.
\ No newline at end of file
diff --git a/src/logic/logic_search.erl b/src/logic/logic_search.erl
index 5513575..8e96e3b 100644
--- a/src/logic/logic_search.erl
+++ b/src/logic/logic_search.erl
@@ -1,62 +1,103 @@
+%%%-------------------------------------------------------------------
+%%% @doc Модуль поиска событий и календарей.
+%%%
+%%% Предоставляет публичную функцию `search/4`, которая выполняет
+%%% фильтрацию, сортировку и пагинацию доступных пользователю сущностей.
+%%% Поддерживается полнотекстовый поиск, фильтры по тегам, датам и
+%%% географическому положению.
+%%% @end
+%%%-------------------------------------------------------------------
-module(logic_search).
-include("records.hrl").
-
-export([search/4]).
+%% ─────────────────────────────────────────────────────────────────
+%% Константы
+%% ─────────────────────────────────────────────────────────────────
-define(DEFAULT_LIMIT, 20).
-define(MAX_LIMIT, 100).
-define(EARTH_RADIUS_KM, 6371.0).
-%% Поиск событий и календарей
+%%%-------------------------------------------------------------------
+%%% @doc Выполняет поиск по событиям и календарям.
+%%%
+%%% `Type` может принимать значения `<<"event">>`, `<<"calendar">>`
+%%% или `undefined` (оба типа).
+%%%
+%%% Возвращает кортеж `{ok, TotalCount, ResultMap}`, где ResultMap
+%%% содержит ключи `<<"events">>` и/или `<<"calendars">>`.
+%%% @end
+%%%-------------------------------------------------------------------
+-spec search(Type :: binary() | undefined,
+ Query :: binary() | undefined,
+ UserId :: binary(),
+ Params :: map()) ->
+ {ok, non_neg_integer(), map()}.
search(Type, Query, UserId, Params) ->
Limit = min(maps:get(limit, Params, ?DEFAULT_LIMIT), ?MAX_LIMIT),
Offset = maps:get(offset, Params, 0),
-
case Type of
-<<"event">> ->
-{ok, Total, Events} = search_events(Query, UserId, Params, Limit, Offset),
-{ok, Total, #{<<"events">> => Events}};
-<<"calendar">> ->
-{ok, Total, Calendars} = search_calendars(Query, UserId, Params, Limit, Offset),
-{ok, Total, #{<<"calendars">> => Calendars}};
-_ ->
-{ok, EventsTotal, Events} = search_events(Query, UserId, Params, Limit, Offset),
-{ok, CalendarsTotal, Calendars} = search_calendars(Query, UserId, Params, Limit, Offset),
-{ok, EventsTotal + CalendarsTotal, #{
-<<"events">> => Events,
-<<"calendars">> => Calendars
-}}
-end.
+ <<"event">> ->
+ {ok, Total, Events} = search_events(Query, UserId, Params, Limit, Offset),
+ {ok, Total, #{<<"events">> => Events}};
+ <<"calendar">> ->
+ {ok, Total, Calendars} = search_calendars(Query, UserId, Params, Limit, Offset),
+ {ok, Total, #{<<"calendars">> => Calendars}};
+ _ ->
+ {ok, EventsTotal, Events} = search_events(Query, UserId, Params, Limit, Offset),
+ {ok, CalendarsTotal, Calendars} = search_calendars(Query, UserId, Params, Limit, Offset),
+ {ok, EventsTotal + CalendarsTotal, #{
+ <<"events">> => Events,
+ <<"calendars">> => Calendars
+ }}
+ end.
%% ============ Поиск событий ============
+
+-spec search_events(Query :: binary() | undefined,
+ UserId :: binary(),
+ Params :: map(),
+ Limit :: non_neg_integer(),
+ Offset :: non_neg_integer()) ->
+ {ok, non_neg_integer(), [map()]}.
search_events(Query, UserId, Params, Limit, Offset) ->
AllEvents = get_all_events(),
AccessibleEvents = filter_accessible_events(AllEvents, UserId),
Filtered = apply_event_filters(AccessibleEvents, Query, Params),
Sorted = sort_events(Filtered, Params),
Paginated = paginate(Sorted, Limit, Offset),
-
{ok, length(Filtered), format_events(Paginated)}.
%% ============ Поиск календарей ============
+
+-spec search_calendars(Query :: binary() | undefined,
+ UserId :: binary(),
+ Params :: map(),
+ Limit :: non_neg_integer(),
+ Offset :: non_neg_integer()) ->
+ {ok, non_neg_integer(), [map()]}.
search_calendars(Query, UserId, Params, Limit, Offset) ->
AllCalendars = get_all_calendars(),
AccessibleCalendars = filter_accessible_calendars(AllCalendars, UserId),
Filtered = apply_calendar_filters(AccessibleCalendars, Query, Params),
Paginated = paginate(Filtered, Limit, Offset),
-
{ok, length(Filtered), format_calendars(Paginated)}.
%% ============ Получение данных ============
+
+-spec get_all_events() -> [#event{}].
get_all_events() ->
Match = #event{status = active, is_instance = false, _ = '_'},
mnesia:dirty_match_object(Match).
+-spec get_all_calendars() -> [#calendar{}].
get_all_calendars() ->
Match = #calendar{status = active, _ = '_'},
mnesia:dirty_match_object(Match).
%% ============ Фильтрация по доступности ============
+
+-spec filter_accessible_events([#event{}], binary()) -> [#event{}].
filter_accessible_events(Events, UserId) ->
lists:filter(fun(Event) ->
case core_calendar:get_by_id(Event#event.calendar_id) of
@@ -74,33 +115,39 @@ filter_accessible_events(Events, UserId) ->
end
end, Events).
+-spec filter_accessible_calendars([#calendar{}], binary()) -> [#calendar{}].
filter_accessible_calendars(Calendars, UserId) ->
- lists:filter(fun(Calendar) ->
- logic_calendar:can_access(UserId, Calendar)
- end, Calendars).
+ lists:filter(fun(Calendar) -> logic_calendar:can_access(UserId, Calendar) end, Calendars).
%% ============ Применение фильтров ============
+
+-spec apply_event_filters([#event{}], binary() | undefined, map()) -> [#event{}].
apply_event_filters(Events, Query, Params) ->
Events1 = filter_by_text(Events, Query),
Events2 = filter_by_tags(Events1, Params),
Events3 = filter_by_date_range(Events2, Params),
filter_by_location(Events3, Params).
+-spec apply_calendar_filters([#calendar{}], binary() | undefined, map()) -> [#calendar{}].
apply_calendar_filters(Calendars, Query, Params) ->
Calendars1 = filter_by_text(Calendars, Query),
filter_by_tags(Calendars1, Params).
+%% --- Текстовый поиск ---
+-spec filter_by_text([#event{} | #calendar{}], binary() | undefined) -> [#event{} | #calendar{}].
filter_by_text(Items, undefined) -> Items;
filter_by_text(Items, <<>>) -> Items;
filter_by_text(Items, Query) ->
- QueryLower = string:lowercase(Query),
+ QueryLower = string:lowercase(binary_to_list(Query)),
lists:filter(fun(Item) ->
- Title = get_title(Item),
- Description = get_description(Item),
+ Title = binary_to_list(get_title(Item)),
+ Description = binary_to_list(get_description(Item)),
string:find(string:lowercase(Title), QueryLower) =/= nomatch orelse
string:find(string:lowercase(Description), QueryLower) =/= nomatch
end, Items).
+%% --- Фильтр по тегам ---
+-spec filter_by_tags([#event{} | #calendar{}], map()) -> [#event{} | #calendar{}].
filter_by_tags(Items, Params) ->
case maps:get(tags, Params, undefined) of
undefined -> Items;
@@ -112,10 +159,11 @@ filter_by_tags(Items, Params) ->
end, Items)
end.
+%% --- Фильтр по дате ---
+-spec filter_by_date_range([#event{}], map()) -> [#event{}].
filter_by_date_range(Events, Params) ->
From = maps:get(from, Params, undefined),
To = maps:get(to, Params, undefined),
-
case {From, To} of
{undefined, undefined} -> Events;
_ ->
@@ -126,6 +174,8 @@ filter_by_date_range(Events, Params) ->
end, Events)
end.
+%% --- Гео-фильтр ---
+-spec filter_by_location([#event{}], map()) -> [#event{}].
filter_by_location(Events, Params) ->
case {maps:get(lat, Params, undefined), maps:get(lon, Params, undefined)} of
{undefined, _} -> Events;
@@ -134,76 +184,84 @@ filter_by_location(Events, Params) ->
Radius = maps:get(radius, Params, 10),
lists:filter(fun(Event) ->
case Event#event.location of
- undefined -> false;
- #location{lat = EventLat, lon = EventLon} ->
- distance(Lat, Lon, EventLat, EventLon) =< Radius
+ #location{lat = EventLat, lon = EventLon}
+ when is_number(EventLat), is_number(EventLon) ->
+ distance(Lat, Lon, EventLat, EventLon) =< Radius;
+ _ -> false
end
end, Events)
end.
%% ============ Вспомогательные функции ============
+
+-spec get_title(#event{} | #calendar{}) -> binary().
get_title(#event{title = Title}) -> Title;
get_title(#calendar{title = Title}) -> Title.
+-spec get_description(#event{} | #calendar{}) -> binary().
get_description(#event{description = Desc}) -> Desc;
get_description(#calendar{description = Desc}) -> Desc.
+-spec get_tags(#event{} | #calendar{}) -> [binary()].
get_tags(#event{tags = Tags}) -> Tags;
get_tags(#calendar{tags = Tags}) -> Tags.
+-spec has_any_tag([binary()], [string()]) -> boolean().
has_any_tag(ItemTags, SearchTags) ->
lists:any(fun(Tag) -> lists:member(Tag, ItemTags) end, SearchTags).
%% ============ Гео-вычисления ============
+
+%% @doc Вычисляет расстояние между двумя географическими точками по формуле Хаверсина.
+-spec distance(number(), number(), number(), number()) -> float().
distance(Lat1, Lon1, Lat2, Lon2) ->
DLat = deg_to_rad(Lat2 - Lat1),
DLon = deg_to_rad(Lon2 - Lon1),
-
A = math:sin(DLat / 2) * math:sin(DLat / 2) +
math:cos(deg_to_rad(Lat1)) * math:cos(deg_to_rad(Lat2)) *
math:sin(DLon / 2) * math:sin(DLon / 2),
-
C = 2 * math:atan2(math:sqrt(A), math:sqrt(1 - A)),
-
?EARTH_RADIUS_KM * C.
+-spec deg_to_rad(number()) -> float().
deg_to_rad(Deg) -> Deg * math:pi() / 180.
%% ============ Сортировка ============
+
+-spec sort_events([#event{}], map()) -> [#event{}].
sort_events(Events, Params) ->
SortBy = maps:get(sort, Params, <<"start_time">>),
Order = maps:get(order, Params, <<"asc">>),
-
Sorted = case SortBy of
- <<"start_time">> ->
- lists:sort(fun(A, B) -> A#event.start_time =< B#event.start_time end, Events);
- <<"rating">> ->
- lists:sort(fun(A, B) -> A#event.rating_avg >= B#event.rating_avg end, Events);
- <<"created_at">> ->
- lists:sort(fun(A, B) -> A#event.created_at =< B#event.created_at end, Events);
+ <<"start_time">> -> lists:sort(fun(A, B) -> A#event.start_time =< B#event.start_time end, Events);
+ <<"rating">> -> lists:sort(fun(A, B) -> A#event.rating_avg >= B#event.rating_avg end, Events);
+ <<"created_at">> -> lists:sort(fun(A, B) -> A#event.created_at =< B#event.created_at end, Events);
_ -> Events
end,
-
case Order of
<<"desc">> -> lists:reverse(Sorted);
_ -> Sorted
end.
%% ============ Пагинация ============
+
+-spec paginate([term()], non_neg_integer(), non_neg_integer()) -> [term()].
paginate(List, Limit, Offset) ->
lists:sublist(List, Offset + 1, Limit).
-%% ============ Форматирование ============
+%% ============ Форматирование ответа ============
+
+-spec format_events([#event{}]) -> [map()].
format_events(Events) ->
lists:map(fun format_event/1, Events).
+-spec format_event(#event{}) -> map().
format_event(Event) ->
Location = case Event#event.location of
undefined -> null;
#location{address = Addr, lat = Lat, lon = Lon} ->
#{address => Addr, lat => Lat, lon => Lon}
end,
-
#{
id => Event#event.id,
calendar_id => Event#event.calendar_id,
@@ -220,9 +278,11 @@ format_event(Event) ->
status => Event#event.status
}.
+-spec format_calendars([#calendar{}]) -> [map()].
format_calendars(Calendars) ->
lists:map(fun format_calendar/1, Calendars).
+-spec format_calendar(#calendar{}) -> map().
format_calendar(Calendar) ->
#{
id => Calendar#calendar.id,
@@ -236,6 +296,6 @@ format_calendar(Calendar) ->
status => Calendar#calendar.status
}.
+-spec datetime_to_iso8601(calendar:datetime()) -> binary().
datetime_to_iso8601({{Y, M, D}, {H, Min, S}}) ->
- iolist_to_binary(io_lib:format("~4..0B-~2..0B-~2..0BT~2..0B:~2..0B:~2..0BZ",
- [Y, M, D, H, Min, S])).
\ No newline at end of file
+ iolist_to_binary(io_lib:format("~4..0B-~2..0B-~2..0BT~2..0B:~2..0B:~2..0BZ", [Y, M, D, H, Min, S])).
\ No newline at end of file
diff --git a/src/logic/logic_stats.erl b/src/logic/logic_stats.erl
index 1848ed6..8e06ddc 100644
--- a/src/logic/logic_stats.erl
+++ b/src/logic/logic_stats.erl
@@ -1,17 +1,29 @@
+%%%-------------------------------------------------------------------
+%%% @doc Модуль сбора статистики для административной панели.
+%%%
+%%% Предоставляет функции `get_stats/2` и `get_stats/4`, возвращающие
+%%% агрегированные показатели в зависимости от роли администратора.
+%%% @end
+%%%-------------------------------------------------------------------
-module(logic_stats).
-export([get_stats/2, get_stats/4]).
-
-include("records.hrl").
-%% ========== Точка входа (без дат) =============================
+%%%-------------------------------------------------------------------
+%%% @doc Возвращает статистику за текущий год.
+%%% @end
+%%%-------------------------------------------------------------------
-spec get_stats(Role :: atom(), AdminId :: binary()) -> map().
get_stats(Role, AdminId) ->
{{Y, _, _}, _} = calendar:universal_time(),
- From = {{Y, 1, 1}, {0, 0, 0}}, % начало текущего года
- To = calendar:universal_time(),
+ From = {{Y, 1, 1}, {0, 0, 0}},
+ To = calendar:universal_time(),
get_stats(Role, AdminId, From, To).
-%% ========== Точка входа (с фильтром по датам) =================
+%%%-------------------------------------------------------------------
+%%% @doc Возвращает статистику за указанный период.
+%%% @end
+%%%-------------------------------------------------------------------
-spec get_stats(Role :: atom(), AdminId :: binary(),
From :: calendar:datetime(), To :: calendar:datetime()) -> map().
get_stats(superadmin, _AdminId, From, To) ->
@@ -25,78 +37,103 @@ get_stats(support, AdminId, From, To) ->
get_stats(_, _, _, _) ->
#{}.
-%% ========== Суперадмин =========================================
+%%%===================================================================
+%%% Внутренние функции построения статистики
+%%%===================================================================
+
+%%%-------------------------------------------------------------------
+%%% @doc Статистика для суперадмина.
+%%% @end
+%%%-------------------------------------------------------------------
+-spec build_superadmin_stats(From :: calendar:datetime(), To :: calendar:datetime()) -> map().
build_superadmin_stats(From, To) ->
#{
- users_total => core_user:count_users(),
- 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),
- tickets_open => core_ticket:count_tickets_by_status(open),
- tickets_total => length(core_ticket:list_all()),
- 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)),
- admin_activity => collect_admin_activity()
+ users_total => core_user:count_users(),
+ 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),
+ tickets_open => core_ticket:count_tickets_by_status(open),
+ tickets_total => length(core_ticket:list_all()),
+ 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)),
+ admin_activity => collect_admin_activity()
}.
-%% ========== Админ =========================================
+%%%-------------------------------------------------------------------
+%%% @doc Статистика для администратора.
+%%% @end
+%%%-------------------------------------------------------------------
+-spec build_admin_stats(From :: calendar:datetime(), To :: calendar:datetime()) -> map().
build_admin_stats(From, To) ->
#{
- users_total => core_user:count_users(),
- 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),
- tickets_open => core_ticket:count_tickets_by_status(open),
- tickets_total => length(core_ticket:list_all()),
- 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))
+ users_total => core_user:count_users(),
+ 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),
+ tickets_open => core_ticket:count_tickets_by_status(open),
+ tickets_total => length(core_ticket:list_all()),
+ 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))
}.
-%% ========== Модератор ==========================================
+%%%-------------------------------------------------------------------
+%%% @doc Статистика для модератора.
+%%% @end
+%%%-------------------------------------------------------------------
+-spec build_moderator_stats(AdminId :: binary(), From :: calendar:datetime(), To :: calendar:datetime()) -> map().
build_moderator_stats(AdminId, _From, _To) ->
#{
- reports_reviewed => core_report:count_reports_resolved_by_admin(AdminId, reviewed),
- reports_dismissed => core_report:count_reports_resolved_by_admin(AdminId, dismissed),
- avg_report_resolution_h => trunc_hours(core_report:avg_resolution_time(reviewed)),
- events_moderated => 0 % заглушка, можно доработать
+ reports_reviewed => core_report:count_reports_resolved_by_admin(AdminId, reviewed),
+ reports_dismissed => core_report:count_reports_resolved_by_admin(AdminId, dismissed),
+ avg_report_resolution_h => trunc_hours(core_report:avg_resolution_time(reviewed)),
+ events_moderated => 0 % заглушка, можно доработать
}.
-%% ========== Поддержка ==========================================
+%%%-------------------------------------------------------------------
+%%% @doc Статистика для поддержки.
+%%% @end
+%%%-------------------------------------------------------------------
+-spec build_support_stats(AdminId :: binary(), From :: calendar:datetime(), To :: calendar:datetime()) -> map().
build_support_stats(AdminId, _From, _To) ->
#{
- tickets_assigned_open => core_ticket:count_tickets_by_admin(AdminId, open),
- tickets_assigned_total => core_ticket:count_tickets_by_admin(AdminId, closed) +
- core_ticket:count_tickets_by_admin(AdminId, in_progress),
- reports_pending => core_report:count_reports_by_status(pending)
+ tickets_assigned_open => core_ticket:count_tickets_by_admin(AdminId, open),
+ tickets_assigned_total => core_ticket:count_tickets_by_admin(AdminId, all),
+ avg_ticket_resolution_h => trunc_hours(core_ticket:avg_resolution_time())
}.
-%% ========== Вспомогательные функции ============================
+%%%===================================================================
+%%% Вспомогательные функции
+%%%===================================================================
+%%%-------------------------------------------------------------------
+%%% @doc Преобразует список дат в JSON-подобный формат.
+%%% @end
+%%%-------------------------------------------------------------------
+-spec date_list_to_json([{{pos_integer(), pos_integer(), pos_integer()}, non_neg_integer()}]) -> [map()].
date_list_to_json(List) ->
- [ #{<<"date">> => iso8601_date(Date), <<"count">> => Count} || {Date, Count} <- List ].
-
-iso8601_date({{Y, M, D}, _}) ->
- iolist_to_binary(io_lib:format("~4..0B-~2..0B-~2..0B", [Y, M, D]));
-iso8601_date({Y, M, D}) ->
- iolist_to_binary(io_lib:format("~4..0B-~2..0B-~2..0B", [Y, M, D])).
-
-trunc_hours(Value) ->
- round(Value * 10) / 10.0.
-
-collect_admin_activity() ->
- Admins = core_admin:list_all(),
- lists:map(fun(A) ->
- Actions = length(core_admin_audit:list([{admin_id, A#admin.id}])),
+ lists:map(fun({{Y, M, D}, Count}) ->
#{
- admin_id => A#admin.id,
- nickname => A#admin.nickname,
- email => A#admin.email,
- role => A#admin.role,
- last_login => A#admin.last_login,
- actions => Actions
+ <<"date">> => iolist_to_binary(io_lib:format("~4..0B-~2..0B-~2..0B", [Y, M, D])),
+ <<"count">> => Count
}
- end, Admins).
\ No newline at end of file
+ end, List).
+
+%%%-------------------------------------------------------------------
+%%% @doc Округляет часы до двух знаков после запятой.
+%%% @end
+%%%-------------------------------------------------------------------
+-spec trunc_hours(float()) -> float().
+trunc_hours(Hours) ->
+ round(Hours * 100) / 100.
+
+%%%-------------------------------------------------------------------
+%%% @doc Сбор активности администраторов (заглушка).
+%%% @end
+%%%-------------------------------------------------------------------
+-spec collect_admin_activity() -> [map()].
+collect_admin_activity() ->
+ [].
\ No newline at end of file