From b78df927dd818a2f6edf271409da0233c1ff797a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=90=D0=BB=D0=B5=D0=BA=D1=81=D0=B5=D0=B9=20=D0=A1=D0=B0?= =?UTF-8?q?=D0=B1=D0=B8=D0=BB=D0=B8=D0=BD?= Date: Wed, 27 May 2026 16:18:33 +0300 Subject: [PATCH] =?UTF-8?q?=D0=98=D0=B7=D0=B1=D0=B0=D0=B2=D0=BB=D0=B5?= =?UTF-8?q?=D0=BD=D0=B8=D0=B5=20=D0=BE=D1=82=20undefined,=20=D0=B2=D1=81?= =?UTF-8?q?=D0=B5=D0=BC=20=D0=BD=D0=B5=D0=BE=D0=B1=D1=8F=D0=B7=D0=B0=D1=82?= =?UTF-8?q?=D0=B5=D0=BB=D1=8C=D0=BD=D1=8B=D0=BC=20=D0=BF=D0=BE=D0=BB=D1=8F?= =?UTF-8?q?=D0=BC=20=D0=BF=D1=80=D0=B8=D1=81=D0=B2=D0=B0=D0=B8=D0=B2=D0=B0?= =?UTF-8?q?=D1=8E=D1=82=D1=81=D1=8F=20=D0=B4=D0=B5=D1=84=D0=BE=D0=BB=D1=82?= =?UTF-8?q?=D0=BD=D1=8B=D0=B5=20=D0=B7=D0=BD=D0=B0=D1=87=D0=B5=D0=BD=D0=B8?= =?UTF-8?q?=D1=8F=20https://git.sabilin.com/EventHub/EventHubBack/issues/2?= =?UTF-8?q?2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/core/core_admin.erl | 149 +++++-- src/core/core_admin_audit.erl | 73 +++- src/core/core_admin_session.erl | 42 +- src/core/core_banned_words.erl | 139 ++++--- src/core/core_booking.erl | 193 +++++---- src/core/core_calendar.erl | 182 ++++++--- src/core/core_event.erl | 330 +++++++++------ src/core/core_report.erl | 203 ++++----- src/core/core_review.erl | 196 ++++++--- src/core/core_session.erl | 45 +- src/core/core_subscription.erl | 202 +++++---- src/core/core_ticket.erl | 384 ++++++++++++------ src/core/core_user.erl | 264 +++++++++--- .../admin/admin_handler_report_by_id.erl | 8 +- src/handlers/admin/admin_handler_reports.erl | 5 - src/handlers/admin/admin_handler_tickets.erl | 12 +- src/handlers/handler_booking_by_id.erl | 2 +- src/logic/logic_booking.erl | 309 +++++++------- src/logic/logic_report.erl | 82 ++-- src/logic/logic_search.erl | 148 +++++-- src/logic/logic_stats.erl | 157 ++++--- 21 files changed, 2037 insertions(+), 1088 deletions(-) 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): +%%% +%%% Все фильтры опциональны, применяются по `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