Избавление от undefined, всем необязательным полям присваиваются дефолтные значения #22

This commit is contained in:
2026-05-27 16:18:33 +03:00
parent ae6fe21ce6
commit b78df927dd
21 changed files with 2037 additions and 1088 deletions
+124 -25
View File
@@ -1,9 +1,28 @@
-module(core_admin). -module(core_admin).
-include("records.hrl"). -include("records.hrl").
-export([create/3, get_by_email/1, get_by_id/1, list_all/0, -export([create/3, get_by_email/1, get_by_id/1, list_all/0, update_role/2, block/1, unblock/1,
update_role/2, block/1, unblock/1, update_last_login/1]). update_last_login/1]).
-export([update/2, delete/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) -> create(Email, Password, Role) ->
case get_by_email(Email) of case get_by_email(Email) of
{ok, _} -> {ok, _} ->
@@ -13,23 +32,38 @@ create(Email, Password, Role) ->
{ok, Hash} = argon2:hash(Password), {ok, Hash} = argon2:hash(Password),
Now = calendar:universal_time(), Now = calendar:universal_time(),
Admin = #admin{ Admin = #admin{
id = Id, id = Id,
email = Email, email = Email,
password_hash = Hash, password_hash = Hash,
role = Role, role = Role,
status = active, status = active,
created_at = Now, nickname = extract_nickname(Email),
updated_at = Now 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), mnesia:dirty_write(Admin),
{ok, Admin} {ok, Admin}
end. end.
%% Обновление администратора (любые поля) %%%-------------------------------------------------------------------
%%% @doc Обновление администратора (любые поля).
%%% `Updates` список пар `[{atom(), term()}]`.
%%% Поля, не указанные в обновлении, сохраняют прежние значения.
%%% @end
%%%-------------------------------------------------------------------
-spec update(AdminId :: binary(), Updates :: [{atom(), term()}]) ->
{ok, #admin{}} | {error, not_found | term()}.
update(AdminId, Updates) -> update(AdminId, Updates) ->
F = fun() -> F = fun() ->
case mnesia:read(admin, AdminId) of case mnesia:read(admin, AdminId) of
[] -> {error, not_found}; [] ->
{error, not_found};
[Admin] -> [Admin] ->
UpdatedAdmin = apply_updates(Admin, Updates), UpdatedAdmin = apply_updates(Admin, Updates),
mnesia:write(UpdatedAdmin), mnesia:write(UpdatedAdmin),
@@ -41,22 +75,43 @@ update(AdminId, Updates) ->
{aborted, Reason} -> {error, Reason} {aborted, Reason} -> {error, Reason}
end. end.
%%%-------------------------------------------------------------------
%%% @doc Получить администратора по email.
%%% @end
%%%-------------------------------------------------------------------
-spec get_by_email(Email :: binary()) -> {ok, #admin{}} | {error, not_found}.
get_by_email(Email) -> get_by_email(Email) ->
Match = #admin{email = Email, _ = '_'}, Match = #admin{email = Email, _ = '_'},
case mnesia:dirty_match_object(Match) of case mnesia:dirty_match_object(Match) of
[Admin] -> {ok, Admin}; [Admin] -> {ok, Admin};
[] -> {error, not_found} [] -> {error, not_found}
end. end.
%%%-------------------------------------------------------------------
%%% @doc Получить администратора по идентификатору.
%%% @end
%%%-------------------------------------------------------------------
-spec get_by_id(Id :: binary()) -> {ok, #admin{}} | {error, not_found}.
get_by_id(Id) -> get_by_id(Id) ->
case mnesia:dirty_read({admin, Id}) of case mnesia:dirty_read({admin, Id}) of
[Admin] -> {ok, Admin}; [Admin] -> {ok, Admin};
[] -> {error, not_found} [] -> {error, not_found}
end. end.
%%%-------------------------------------------------------------------
%%% @doc Получить список всех администраторов.
%%% @end
%%%-------------------------------------------------------------------
-spec list_all() -> [#admin{}].
list_all() -> list_all() ->
mnesia:dirty_match_object(#admin{_ = '_'}). 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) -> update_role(Id, NewRole) when is_atom(NewRole) ->
case get_by_id(Id) of case get_by_id(Id) of
{ok, Admin} -> {ok, Admin} ->
@@ -66,6 +121,12 @@ update_role(Id, NewRole) when is_atom(NewRole) ->
Error -> Error Error -> Error
end. end.
%%%-------------------------------------------------------------------
%%% @doc Обновить время последнего входа администратора.
%%% Устанавливает `last_login` в текущее UTC-время.
%%% @end
%%%-------------------------------------------------------------------
-spec update_last_login(Id :: binary()) -> {ok, #admin{}} | {error, not_found}.
update_last_login(Id) -> update_last_login(Id) ->
case get_by_id(Id) of case get_by_id(Id) of
{ok, Admin} -> {ok, Admin} ->
@@ -75,12 +136,28 @@ update_last_login(Id) ->
Error -> Error Error -> Error
end. end.
%%%-------------------------------------------------------------------
%%% @doc Заблокировать администратора.
%%% @end
%%%-------------------------------------------------------------------
-spec block(Id :: binary()) -> {ok, #admin{}} | {error, not_found}.
block(Id) -> block(Id) ->
update_status(Id, blocked). update_status(Id, blocked).
%%%-------------------------------------------------------------------
%%% @doc Разблокировать администратора.
%%% @end
%%%-------------------------------------------------------------------
-spec unblock(Id :: binary()) -> {ok, #admin{}} | {error, not_found}.
unblock(Id) -> unblock(Id) ->
update_status(Id, active). update_status(Id, active).
%%%-------------------------------------------------------------------
%%% @doc Изменить статус администратора (внутренняя функция).
%%% @end
%%%-------------------------------------------------------------------
-spec update_status(Id :: binary(), Status :: atom()) ->
{ok, #admin{}} | {error, not_found}.
update_status(Id, Status) -> update_status(Id, Status) ->
case get_by_id(Id) of case get_by_id(Id) of
{ok, Admin} -> {ok, Admin} ->
@@ -90,7 +167,10 @@ update_status(Id, Status) ->
Error -> Error Error -> Error
end. end.
%% Физическое удаление администратора из базы %%%-------------------------------------------------------------------
%%% @doc Физическое удаление администратора из базы.
%%% @end
%%%-------------------------------------------------------------------
-spec delete(binary()) -> {ok, deleted} | {error, not_found}. -spec delete(binary()) -> {ok, deleted} | {error, not_found}.
delete(AdminId) -> delete(AdminId) ->
case get_by_id(AdminId) of 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]) -> apply_updates(Admin, [{Field, Value} | Rest]) ->
NewAdmin = case Field of NewAdmin = case Field of
email -> Admin#admin{email = Value}; email -> Admin#admin{email = Value};
password_hash -> Admin#admin{password_hash = Value}; password_hash -> Admin#admin{password_hash = Value};
role -> Admin#admin{role = Value}; role -> Admin#admin{role = Value};
status -> Admin#admin{status = Value}; status -> Admin#admin{status = Value};
nickname -> Admin#admin{nickname = Value}; nickname -> Admin#admin{nickname = Value};
avatar_url -> Admin#admin{avatar_url = Value}; avatar_url -> Admin#admin{avatar_url = Value};
timezone -> Admin#admin{timezone = Value}; timezone -> Admin#admin{timezone = Value};
language -> Admin#admin{language = Value}; language -> Admin#admin{language = Value};
phone -> Admin#admin{phone = Value}; phone -> Admin#admin{phone = Value};
preferences -> Admin#admin{preferences = Value}; preferences -> Admin#admin{preferences = Value};
_ -> Admin last_login -> Admin#admin{last_login = Value};
_ -> Admin
end, end,
apply_updates(NewAdmin#admin{updated_at = calendar:universal_time()}, Rest). 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.
+62 -11
View File
@@ -1,30 +1,76 @@
-module(core_admin_audit). -module(core_admin_audit).
-include("records.hrl"). -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]). -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) ->
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) -> log(AdminId, Email, Role, Action, EntityType, EntityId, Ip, Reason) ->
Id = infra_utils:generate_id(9), Id = infra_utils:generate_id(9),
Entry = #admin_audit{ Entry = #admin_audit{
id = Id, id = Id,
admin_id = AdminId, admin_id = AdminId,
email = Email, email = Email,
role = Role, role = Role,
action = Action, action = Action,
entity_type = EntityType, entity_type = EntityType,
entity_id = EntityId, entity_id = EntityId,
timestamp = calendar:universal_time(), timestamp = calendar:universal_time(),
ip = Ip, ip = Ip,
reason = Reason reason = Reason
}, },
mnesia:dirty_write(Entry), mnesia:dirty_write(Entry),
{ok, Entry}. {ok, Entry}.
%%%-------------------------------------------------------------------
%%% @doc Получить все записи аудита.
%%% @end
%%%-------------------------------------------------------------------
-spec list() -> [#admin_audit{}].
list() -> list() ->
mnesia:dirty_match_object(#admin_audit{_ = '_'}). mnesia:dirty_match_object(#admin_audit{_ = '_'}).
%%%-------------------------------------------------------------------
%%% @doc Получить записи аудита с фильтрацией.
%%%
%%% Поддерживаемые ключи фильтрации (proplist):
%%% <ul>
%%% <li>`admin_id` binary()</li>
%%% <li>`action` binary()</li>
%%% <li>`date_from` calendar:datetime()</li>
%%% <li>`date_to` calendar:datetime()</li>
%%% </ul>
%%% Все фильтры опциональны, применяются по `AND`.
%%% @end
%%%-------------------------------------------------------------------
-spec list(Filters :: proplists:proplist()) -> [#admin_audit{}].
list(Filters) -> list(Filters) ->
All = list(), All = list(),
lists:filter(fun(E) -> lists:filter(fun(E) ->
@@ -49,6 +95,11 @@ list(Filters) ->
end end
end, All). end, All).
%%%-------------------------------------------------------------------
%%% @doc Подсчитать количество действий определённого типа для администратора.
%%% @end
%%%-------------------------------------------------------------------
-spec count_actions_by_admin(AdminId :: binary(), Action :: binary()) -> non_neg_integer().
count_actions_by_admin(AdminId, Action) -> count_actions_by_admin(AdminId, Action) ->
Match = #admin_audit{admin_id = AdminId, action = Action, _ = '_'}, Match = #admin_audit{admin_id = AdminId, action = Action, _ = '_'},
length(mnesia:dirty_match_object(Match)). length(mnesia:dirty_match_object(Match)).
+33 -9
View File
@@ -1,34 +1,58 @@
%%%-------------------------------------------------------------------
%%% @doc Управление сессиями администраторов (refresh-токены).
%%%
%%% Запись `admin_session` хранит refresh-токен, идентификатор администратора,
%%% время истечения и тип токена. Все поля обязательны и инициализируются
%%% при создании, поэтому `undefined` в базе не возникает.
%%% @end
%%%-------------------------------------------------------------------
-module(core_admin_session). -module(core_admin_session).
-include("records.hrl"). -include("records.hrl").
-export([create/2, validate/1, delete/1]). -export([create/2, validate/1, delete/1]).
%%%-------------------------------------------------------------------
%%% @doc Создать новую сессию.
%%% Токен действителен 30 дней с момента создания.
%%% @end
%%%-------------------------------------------------------------------
-spec create(AdminId :: binary(), RefreshToken :: binary()) -> ok. -spec create(AdminId :: binary(), RefreshToken :: binary()) -> ok.
create(AdminId, RefreshToken) -> create(AdminId, RefreshToken) ->
Session = #admin_session{ Session = #admin_session{
token = RefreshToken, token = RefreshToken,
admin_id = AdminId, admin_id = AdminId,
expires_at = calendar:gregorian_seconds_to_datetime( expires_at = calendar:gregorian_seconds_to_datetime(
calendar:datetime_to_gregorian_seconds(calendar:universal_time()) + 30 * 24 * 3600), calendar:datetime_to_gregorian_seconds(
type = refresh calendar:universal_time()) + 30 * 24 * 3600),
type = refresh
}, },
mnesia:dirty_write(Session), mnesia:dirty_write(Session),
ok. 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) -> validate(Token) ->
case mnesia:dirty_read({admin_session, Token}) of case mnesia:dirty_read({admin_session, Token}) of
[Session] -> [Session] ->
case Session#admin_session.expires_at > calendar:universal_time() of case Session#admin_session.expires_at > calendar:universal_time() of
true -> true -> {ok, Session#admin_session.admin_id};
{ok, Session#admin_session.admin_id};
false -> false ->
mnesia:dirty_delete({admin_session, Token}), mnesia:dirty_delete({admin_session, Token}),
{error, expired} {error, expired}
end; end;
[] -> [] -> {error, not_found}
{error, not_found}
end. end.
%%%-------------------------------------------------------------------
%%% @doc Удалить сессию (отзыв refresh-токена).
%%% @end
%%%-------------------------------------------------------------------
-spec delete(Token :: binary()) -> ok. -spec delete(Token :: binary()) -> ok.
delete(Token) -> delete(Token) ->
mnesia:dirty_delete({admin_session, Token}), mnesia:dirty_delete({admin_session, Token}),
+77 -62
View File
@@ -1,74 +1,89 @@
-module(core_banned_words). -module(core_banned_words).
-export([list_banned_words/0,
add_banned_word/2,
remove_banned_word/1,
update_banned_word/2]).
-include("records.hrl"). -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() -> list_banned_words() ->
mnesia:dirty_match_object(#banned_word{_ = '_'}). 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) -> add_banned_word(Word, AddedBy) ->
Id = infra_utils:generate_id(9), case is_word_banned(Word) of
Now = calendar:universal_time(), true ->
BW = #banned_word{id = Id, word = Word, added_by = AddedBy, added_at = Now}, {error, already_exists};
case mnesia:transaction(fun() -> false ->
case mnesia:match_object(#banned_word{word = Word, _ = '_'}) of Id = infra_utils:generate_id(9),
[] -> mnesia:write(BW), {ok, BW}; Now = calendar:universal_time(),
_ -> mnesia:abort(already_exists) BannedWord = #banned_word{
end id = Id,
end) of word = Word,
{atomic, {ok, BWRec}} -> {ok, BWRec}; added_by = AddedBy,
{aborted, already_exists} -> {error, already_exists}; added_at = Now
{aborted, Reason} -> {error, Reason} },
mnesia:dirty_write(BannedWord),
{ok, BannedWord}
end. end.
%% Поиск запрещённого слова по его тексту %%%-------------------------------------------------------------------
-spec get_by_word(binary()) -> {ok, #banned_word{}} | {error, not_found}. %%% @doc Удалить слово из списка запрещённых.
get_by_word(Word) -> %%% @end
case mnesia:dirty_match_object(#banned_word{word = Word, _ = '_'}) of %%%-------------------------------------------------------------------
[Record] -> {ok, Record}; -spec remove_banned_word(Word :: binary()) -> {ok, deleted} | {error, not_found}.
[] -> {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 для надёжности)
remove_banned_word(Word) -> remove_banned_word(Word) ->
case get_by_word(Word) of case is_word_banned(Word) of
{ok, Record} -> true ->
F = fun() -> mnesia:delete({banned_word, Record#banned_word.id}), ok end, Match = #banned_word{word = Word, _ = '_'},
case mnesia:transaction(F) of [Record] = mnesia:dirty_match_object(Match),
{atomic, ok} -> {ok, Record}; mnesia:dirty_delete({banned_word, Record#banned_word.id}),
{aborted, Reason} -> {error, Reason} {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; end;
{error, _} = Error -> Error false ->
{error, not_found}
end. end.
+120 -73
View File
@@ -1,41 +1,127 @@
-module(core_booking). -module(core_booking).
-include("records.hrl"). -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), Id = infra_utils:generate_id(16),
Now = calendar:universal_time(),
Booking = #booking{ Booking = #booking{
id = Id, id = Id,
event_id = EventId, event_id = EventId,
user_id = UserId, user_id = UserId,
status = pending, status = Status,
confirmed_at = undefined, notes = ?DEFAULT_NOTES,
created_at = calendar:universal_time(), reminder_sent = ?DEFAULT_REMINDER_SENT,
updated_at = calendar:universal_time() 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 case mnesia:transaction(F) of
{atomic, Result} -> Result; {atomic, Result} -> Result;
{aborted, Reason} -> {error, Reason} {aborted, Reason} -> {error, Reason}
end. end.
%% Получение бронирования по ID %%%-------------------------------------------------------------------
%%% @doc Получить бронирование по идентификатору.
%%% @end
%%%-------------------------------------------------------------------
-spec get_by_id(Id :: binary()) -> {ok, #booking{}} | {error, not_found}.
get_by_id(Id) -> get_by_id(Id) ->
case mnesia:dirty_read(booking, Id) of case mnesia:dirty_read(booking, Id) of
[] -> {error, not_found}; [] -> {error, not_found};
[Booking] -> {ok, Booking} [Booking] -> {ok, Booking}
end. 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) -> get_by_event_and_user(EventId, UserId) ->
Match = #booking{event_id = EventId, user_id = UserId, _ = '_'}, Match = #booking{event_id = EventId, user_id = UserId, _ = '_'},
case mnesia:dirty_match_object(Match) of case mnesia:dirty_match_object(Match) of
@@ -43,58 +129,19 @@ get_by_event_and_user(EventId, UserId) ->
[Booking] -> {ok, Booking} [Booking] -> {ok, Booking}
end. end.
%% Список бронирований события %%%===================================================================
list_by_event(EventId) -> %%% ВНУТРЕННИЕ ФУНКЦИИ
Match = #booking{event_id = EventId, _ = '_'}, %%%===================================================================
Bookings = mnesia:dirty_match_object(Match),
{ok, Bookings}.
%% Список бронирований пользователя apply_updates(Booking, Updates) ->
list_by_user(UserId) -> Updated = lists:foldl(fun({Field, Value}, B) -> set_field(Field, Value, B) end,
Match = #booking{user_id = UserId, _ = '_'}, Booking, Updates),
Bookings = mnesia:dirty_match_object(Match), Updated#booking{updated_at = calendar:universal_time()}.
{ok, Bookings}.
%% Обновление статуса бронирования set_field(status, Value, B) -> B#booking{status = Value};
update_status(Id, Status) when Status =:= pending; Status =:= confirmed; Status =:= cancelled -> set_field(notes, Value, B) -> B#booking{notes = Value};
F = fun() -> set_field(reminder_sent, Value, B) -> B#booking{reminder_sent = Value};
case mnesia:read(booking, Id) of set_field(confirmed_at, Value, B) -> B#booking{confirmed_at = Value};
[] -> set_field(event_id, Value, B) -> B#booking{event_id = Value};
{error, not_found}; set_field(user_id, Value, B) -> B#booking{user_id = Value};
[Booking] -> set_field(_, _, B) -> B.
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).
+124 -58
View File
@@ -2,47 +2,60 @@
-include("records.hrl"). -include("records.hrl").
-export([create/4, create/5, get_by_id/1, list_by_owner/1, update/2, delete/1]). -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([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) -> create(OwnerId, Title, Description, Confirmation) ->
Id = infra_utils:generate_id(16), create(OwnerId, Title, Description, Confirmation, personal).
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.
%% Создание календаря с типом и политикой %%%-------------------------------------------------------------------
%%% @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) -> create(OwnerId, Title, Description, Confirmation, Type) ->
Id = infra_utils:generate_id(16), Id = infra_utils:generate_id(16),
Now = calendar:universal_time(),
Calendar = #calendar{ Calendar = #calendar{
id = Id, id = Id,
owner_id = OwnerId, owner_id = OwnerId,
title = Title, title = Title,
description = Description, description = Description,
tags = [], short_name = ?DEFAULT_SHORT_NAME,
type = Type, category = ?DEFAULT_CATEGORY,
confirmation = Confirmation, color = ?DEFAULT_COLOR,
rating_avg = 0.0, image_url = ?DEFAULT_IMAGE_URL,
rating_count = 0, settings = ?DEFAULT_SETTINGS,
status = active, tags = ?DEFAULT_TAGS,
created_at = calendar:universal_time(), type = Type,
updated_at = calendar:universal_time() 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, F = fun() -> mnesia:write(Calendar), {ok, Calendar} end,
case mnesia:transaction(F) of case mnesia:transaction(F) of
@@ -50,20 +63,35 @@ create(OwnerId, Title, Description, Confirmation, Type) ->
{aborted, Reason} -> {error, Reason} {aborted, Reason} -> {error, Reason}
end. end.
%% Получение календаря по ID %%%-------------------------------------------------------------------
%%% @doc Получить календарь по идентификатору.
%%% @end
%%%-------------------------------------------------------------------
-spec get_by_id(Id :: binary()) -> {ok, #calendar{}} | {error, not_found}.
get_by_id(Id) -> get_by_id(Id) ->
case mnesia:dirty_read(calendar, Id) of case mnesia:dirty_read(calendar, Id) of
[] -> {error, not_found}; [] -> {error, not_found};
[Calendar] -> {ok, Calendar} [Calendar] -> {ok, Calendar}
end. end.
%% Список календарей владельца %%%-------------------------------------------------------------------
%%% @doc Список активных календарей владельца.
%%% @end
%%%-------------------------------------------------------------------
-spec list_by_owner(OwnerId :: binary()) -> {ok, [#calendar{}]}.
list_by_owner(OwnerId) -> list_by_owner(OwnerId) ->
Match = #calendar{owner_id = OwnerId, status = active, _ = '_'}, Match = #calendar{owner_id = OwnerId, status = active, _ = '_'},
Calendars = mnesia:dirty_match_object(Match), Calendars = mnesia:dirty_match_object(Match),
{ok, Calendars}. {ok, Calendars}.
%% Обновление календаря %%%-------------------------------------------------------------------
%%% @doc Обновить поля календаря.
%%% `Updates` список пар `[{atom(), term()}]`.
%%% Поля, не указанные в обновлении, сохраняют прежние значения.
%%% @end
%%%-------------------------------------------------------------------
-spec update(Id :: binary(), Updates :: [{atom(), term()}]) ->
{ok, #calendar{}} | {error, not_found | term()}.
update(Id, Updates) -> update(Id, Updates) ->
F = fun() -> F = fun() ->
case mnesia:read(calendar, Id) of case mnesia:read(calendar, Id) of
@@ -79,45 +107,83 @@ update(Id, Updates) ->
{aborted, Reason} -> {error, Reason} {aborted, Reason} -> {error, Reason}
end. end.
%% Удаление календаря (soft delete) %%%-------------------------------------------------------------------
%%% @doc Мягкое удаление календаря (установка статуса `deleted`).
%%% @end
%%%-------------------------------------------------------------------
-spec delete(Id :: binary()) -> {ok, #calendar{}} | {error, not_found | term()}.
delete(Id) -> delete(Id) ->
update(Id, [{status, deleted}]). update(Id, [{status, deleted}]).
%%%-------------------------------------------------------------------
%%% @doc Количество записей в таблице `calendar`.
%%% @end
%%%-------------------------------------------------------------------
-spec count_calendars() -> non_neg_integer().
count_calendars() -> count_calendars() ->
mnesia:table_info(calendar, size). mnesia:table_info(calendar, size).
%%%-------------------------------------------------------------------
%%% @doc Получить список всех календарей (включая удалённые).
%%% @end
%%%-------------------------------------------------------------------
-spec list_all() -> [#calendar{}]. -spec list_all() -> [#calendar{}].
list_all() -> list_all() ->
{atomic, Calendars} = mnesia:transaction(fun() -> {atomic, Calendars} = mnesia:transaction(fun() ->
mnesia:foldl(fun(C, Acc) -> [C | Acc] end, [], calendar) mnesia:foldl(fun(C, Acc) -> [C | Acc] end, [], calendar)
end), end),
Calendars. Calendars.
%% ── НОВЫЕ ФУНКЦИИ ────────────────────────────────────────── %%%-------------------------------------------------------------------
%%% @doc Заморозить календарь с указанием причины.
%%% @end
%%%-------------------------------------------------------------------
-spec freeze(Id :: binary(), Reason :: binary()) ->
{ok, #calendar{}} | {error, not_found | term()}.
freeze(Id, Reason) -> freeze(Id, Reason) ->
update(Id, [{status, frozen}, {reason, Reason}]). update(Id, [{status, frozen}, {reason, Reason}]).
%%%-------------------------------------------------------------------
%%% @doc Разморозить календарь с указанием причины.
%%% @end
%%%-------------------------------------------------------------------
-spec unfreeze(Id :: binary(), Reason :: binary()) ->
{ok, #calendar{}} | {error, not_found | term()}.
unfreeze(Id, Reason) -> unfreeze(Id, Reason) ->
update(Id, [{status, active}, {reason, Reason}]). update(Id, [{status, active}, {reason, Reason}]).
%%%===================================================================
%%% ВНУТРЕННИЕ ФУНКЦИИ
%%%===================================================================
%%%-------------------------------------------------------------------
%%% @doc Применить список обновлений к записи календаря.
%%% @end
%%%-------------------------------------------------------------------
-spec apply_updates(#calendar{}, [{atom(), term()}]) -> #calendar{}.
apply_updates(Calendar, Updates) -> apply_updates(Calendar, Updates) ->
Updated = lists:foldl(fun({Field, Value}, C) -> Updated = lists:foldl(fun({Field, Value}, C) -> set_field(Field, Value, C) end,
set_field(Field, Value, C) Calendar, Updates),
end, Calendar, Updates),
Updated#calendar{updated_at = calendar:universal_time()}. 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}; %%% @doc Установить одно поле записи календаря.
set_field(short_name, Value, C) -> C#calendar{short_name = Value}; %%% Неизвестные поля игнорируются.
set_field(category, Value, C) -> C#calendar{category = Value}; %%% @end
set_field(color, Value, C) -> C#calendar{color = Value}; %%%-------------------------------------------------------------------
set_field(image_url, Value, C) -> C#calendar{image_url = Value}; -spec set_field(atom(), term(), #calendar{}) -> #calendar{}.
set_field(settings, Value, C) -> C#calendar{settings = Value}; set_field(title, Value, C) -> C#calendar{title = Value};
set_field(tags, Value, C) -> C#calendar{tags = Value}; set_field(description, Value, C) -> C#calendar{description = Value};
set_field(type, Value, C) -> C#calendar{type = Value}; set_field(short_name, Value, C) -> C#calendar{short_name = Value};
set_field(confirmation, Value, C) -> C#calendar{confirmation = Value}; set_field(category, Value, C) -> C#calendar{category = Value};
set_field(status, Value, C) -> C#calendar{status = Value}; set_field(color, Value, C) -> C#calendar{color = Value};
set_field(rating_avg, Value, C) -> C#calendar{rating_avg = Value}; set_field(image_url, Value, C) -> C#calendar{image_url = Value};
set_field(rating_count, Value, C) -> C#calendar{rating_count = Value}; set_field(settings, Value, C) -> C#calendar{settings = Value};
set_field(reason, Value, C) -> C#calendar{reason = 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. set_field(_, _, C) -> C.
+214 -116
View File
@@ -1,186 +1,247 @@
-module(core_event). -module(core_event).
-include("records.hrl"). -include("records.hrl").
-export([create/4, create_recurring/5, get_by_id/1, list_by_calendar/1, update/2, delete/1,
-export([create/4, create_recurring/5, get_by_id/1, list_by_calendar/1, materialize_occurrence/3]).
update/2, delete/1, materialize_occurrence/3]).
-export([count_events/0, count_events_by_date/2]). -export([count_events/0, count_events_by_date/2]).
-export([freeze/2, unfreeze/2]). -export([freeze/2, unfreeze/2]).
-export([list_all/0]). -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) -> create(CalendarId, Title, StartTime, Duration) ->
Id = infra_utils:generate_id(16), Id = infra_utils:generate_id(16),
Now = calendar:universal_time(),
Event = #event{ Event = #event{
id = Id, id = Id,
calendar_id = CalendarId, calendar_id = CalendarId,
title = Title, title = Title,
description = <<"">>, description = <<"">>,
event_type = single, event_type = single,
start_time = StartTime, start_time = StartTime,
duration = Duration, duration = Duration,
recurrence_rule = undefined, recurrence_rule = ?DEFAULT_RECURRENCE_RULE,
master_id = undefined, master_id = ?DEFAULT_MASTER_ID,
is_instance = false, is_instance = false,
specialist_id = undefined, specialist_id = ?DEFAULT_SPECIALIST_ID,
location = undefined, location = ?DEFAULT_LOCATION,
tags = [], tags = ?DEFAULT_TAGS,
capacity = undefined, capacity = ?DEFAULT_CAPACITY,
online_link = undefined, online_link = ?DEFAULT_ONLINE_LINK,
status = active, status = active,
rating_avg = 0.0, reason = ?DEFAULT_REASON,
rating_count = 0, rating_avg = 0.0,
created_at = calendar:universal_time(), rating_count = 0,
updated_at = calendar:universal_time() 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 case mnesia:transaction(F) of
{atomic, Result} -> Result; {atomic, Result} -> Result;
{aborted, Reason} -> {error, Reason} {aborted, Reason} -> {error, Reason}
end. 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) -> create_recurring(CalendarId, Title, StartTime, Duration, RRule) ->
Id = infra_utils:generate_id(16), Id = infra_utils:generate_id(16),
Now = calendar:universal_time(),
Event = #event{ Event = #event{
id = Id, id = Id,
calendar_id = CalendarId, calendar_id = CalendarId,
title = Title, title = Title,
description = <<"">>, description = <<"">>,
event_type = recurring, event_type = recurring,
start_time = StartTime, start_time = StartTime,
duration = Duration, duration = Duration,
recurrence_rule = jsx:encode(RRule), recurrence_rule = jsx:encode(RRule),
master_id = undefined, master_id = ?DEFAULT_MASTER_ID,
is_instance = false, is_instance = false,
specialist_id = undefined, specialist_id = ?DEFAULT_SPECIALIST_ID,
location = undefined, location = ?DEFAULT_LOCATION,
tags = [], tags = ?DEFAULT_TAGS,
capacity = undefined, capacity = ?DEFAULT_CAPACITY,
online_link = undefined, online_link = ?DEFAULT_ONLINE_LINK,
status = active, status = active,
rating_avg = 0.0, reason = ?DEFAULT_REASON,
rating_count = 0, rating_avg = 0.0,
created_at = calendar:universal_time(), rating_count = 0,
updated_at = calendar:universal_time() 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 case mnesia:transaction(F) of
{atomic, Result} -> Result; {atomic, Result} -> Result;
{aborted, Reason} -> {error, Reason} {aborted, Reason} -> {error, Reason}
end. end.
%% Материализация вхождения повторяющегося события %%%-------------------------------------------------------------------
%%% @doc Материализация вхождения повторяющегося события.
%%% @end
%%%-------------------------------------------------------------------
-spec materialize_occurrence(MasterId :: binary(),
OccurrenceStart :: calendar:datetime(),
SpecialistId :: binary()) ->
{ok, #event{}} | {error, term()}.
materialize_occurrence(MasterId, OccurrenceStart, SpecialistId) -> materialize_occurrence(MasterId, OccurrenceStart, SpecialistId) ->
case mnesia:dirty_read(event, MasterId) of case mnesia:dirty_read(event, MasterId) of
[] -> [] ->
{error, master_not_found}; {error, master_not_found};
[Master] when Master#event.event_type =:= recurring -> [Master] when Master#event.event_type =:= recurring ->
% Проверяем, не существует ли уже материализованное вхождение
Existing = mnesia:dirty_match_object( 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 case Existing of
[] -> [] ->
% Создаём новый экземпляр
InstanceId = infra_utils:generate_id(16), InstanceId = infra_utils:generate_id(16),
Now = calendar:universal_time(),
Instance = #event{ Instance = #event{
id = InstanceId, id = InstanceId,
calendar_id = Master#event.calendar_id, calendar_id = Master#event.calendar_id,
title = Master#event.title, title = Master#event.title,
description = Master#event.description, description = Master#event.description,
event_type = single, event_type = single,
start_time = OccurrenceStart, start_time = OccurrenceStart,
duration = Master#event.duration, duration = Master#event.duration,
recurrence_rule = undefined, recurrence_rule = ?DEFAULT_RECURRENCE_RULE,
master_id = MasterId, master_id = MasterId,
is_instance = true, is_instance = true,
specialist_id = SpecialistId, specialist_id = SpecialistId,
location = Master#event.location, location = Master#event.location,
tags = Master#event.tags, tags = Master#event.tags,
capacity = Master#event.capacity, capacity = Master#event.capacity,
online_link = Master#event.online_link, online_link = Master#event.online_link,
status = active, status = active,
rating_avg = 0.0, reason = ?DEFAULT_REASON,
rating_count = 0, rating_avg = 0.0,
created_at = calendar:universal_time(), rating_count = 0,
updated_at = calendar:universal_time() 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 case mnesia:transaction(F) of
{atomic, Result} -> Result; {atomic, Result} -> Result;
{aborted, Reason} -> {error, Reason} {aborted, Reason} -> {error, Reason}
end; end;
[Instance] -> [Instance] ->
% Уже существует, возвращаем его
{ok, Instance} {ok, Instance}
end; end;
[_] -> [_] ->
{error, not_recurring} {error, not_recurring}
end. end.
%% Получение события по ID %%%-------------------------------------------------------------------
%%% @doc Получить событие по идентификатору.
%%% @end
%%%-------------------------------------------------------------------
-spec get_by_id(Id :: binary()) -> {ok, #event{}} | {error, not_found}.
get_by_id(Id) -> get_by_id(Id) ->
case mnesia:dirty_read(event, Id) of case mnesia:dirty_read(event, Id) of
[] -> {error, not_found}; [] -> {error, not_found};
[Event] -> {ok, Event} [Event] -> {ok, Event}
end. end.
%% Список событий календаря (только мастер-записи и одиночные) %%%-------------------------------------------------------------------
%%% @doc Список активных событий календаря.
%%% @end
%%%-------------------------------------------------------------------
-spec list_by_calendar(CalendarId :: binary()) -> {ok, [#event{}]}.
list_by_calendar(CalendarId) -> list_by_calendar(CalendarId) ->
Match = #event{calendar_id = CalendarId, status = active, is_instance = false, _ = '_'}, Match = #event{calendar_id = CalendarId, status = active, is_instance = false, _ = '_'},
Events = mnesia:dirty_match_object(Match), Events = mnesia:dirty_match_object(Match),
{ok, Events}. {ok, Events}.
%% Обновление события %%%-------------------------------------------------------------------
%%% @doc Обновить поля события.
%%% `Updates` список пар `[{atom(), term()}]`.
%%% Поля, не указанные в обновлении, сохраняют прежние значения.
%%% @end
%%%-------------------------------------------------------------------
-spec update(Id :: binary(), Updates :: [{atom(), term()}]) ->
{ok, #event{}} | {error, not_found | term()}.
update(Id, Updates) -> update(Id, Updates) ->
F = fun() -> F = fun() ->
case mnesia:read(event, Id) of case mnesia:read(event, Id) of
[] -> [] -> {error, not_found};
{error, not_found};
[Event] -> [Event] ->
UpdatedEvent = apply_updates(Event, Updates), UpdatedEvent = apply_updates(Event, Updates),
mnesia:write(UpdatedEvent), mnesia:write(UpdatedEvent),
{ok, UpdatedEvent} {ok, UpdatedEvent}
end end
end, end,
case mnesia:transaction(F) of case mnesia:transaction(F) of
{atomic, Result} -> Result; {atomic, Result} -> Result;
{aborted, Reason} -> {error, Reason} {aborted, Reason} -> {error, Reason}
end. end.
%% Удаление события (soft delete) %%%-------------------------------------------------------------------
%%% @doc Мягкое удаление события (установка статуса `deleted`).
%%% @end
%%%-------------------------------------------------------------------
-spec delete(Id :: binary()) -> {ok, #event{}} | {error, not_found | term()}.
delete(Id) -> delete(Id) ->
update(Id, [{status, deleted}]). update(Id, [{status, deleted}]).
%%%-------------------------------------------------------------------
%%% @doc Количество записей в таблице `event`.
%%% @end
%%%-------------------------------------------------------------------
-spec count_events() -> non_neg_integer().
count_events() -> count_events() ->
mnesia:table_info(event, size). mnesia:table_info(event, size).
%%%-------------------------------------------------------------------
%%% @doc Получить список всех активных мастер-событий.
%%% @end
%%%-------------------------------------------------------------------
-spec list_all() -> [#event{}].
list_all() -> list_all() ->
Match = #event{status = active, is_instance = false, _ = '_'}, Match = #event{status = active, is_instance = false, _ = '_'},
mnesia:dirty_match_object(Match). 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) -> count_events_by_date(From, To) ->
All = mnesia:dirty_match_object(#event{_ = '_'}), All = mnesia:dirty_match_object(#event{_ = '_'}),
Filtered = lists:filter(fun(E) -> Filtered = lists:filter(fun(E) -> E#event.created_at >= From andalso
E#event.created_at >= From andalso E#event.created_at =< To E#event.created_at =< To end, All),
end, All),
Counts = lists:foldl(fun(E, Acc) -> Counts = lists:foldl(fun(E, Acc) ->
Day = date_part(E#event.created_at), Day = date_part(E#event.created_at),
case lists:keyfind(Day, 1, Acc) of case lists:keyfind(Day, 1, Acc) of
@@ -190,32 +251,69 @@ count_events_by_date(From, To) ->
end, [], Filtered), end, [], Filtered),
lists:sort(Counts). 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}. date_part({{Y,M,D}, _}) -> {Y,M,D}.
%%%-------------------------------------------------------------------
%%% @doc Применить список обновлений к записи события.
%%% @end
%%%-------------------------------------------------------------------
-spec apply_updates(#event{}, [{atom(), term()}]) -> #event{}.
apply_updates(Event, Updates) -> apply_updates(Event, Updates) ->
Updated = lists:foldl(fun({Field, Value}, E) -> Updated = lists:foldl(fun({Field, Value}, E) -> set_field(Field, Value, E) end,
set_field(Field, Value, E) Event, Updates),
end, Event, Updates),
Updated#event{updated_at = calendar:universal_time()}. Updated#event{updated_at = calendar:universal_time()}.
%%%-------------------------------------------------------------------
%%% @doc Заморозить событие с указанием причины.
%%% @end
%%%-------------------------------------------------------------------
-spec freeze(Id :: binary(), Reason :: binary()) ->
{ok, #event{}} | {error, not_found | term()}.
freeze(Id, Reason) -> freeze(Id, Reason) ->
update(Id, [{status, frozen}, {reason, Reason}]). update(Id, [{status, frozen}, {reason, Reason}]).
%%%-------------------------------------------------------------------
%%% @doc Разморозить событие с указанием причины.
%%% @end
%%%-------------------------------------------------------------------
-spec unfreeze(Id :: binary(), Reason :: binary()) ->
{ok, #event{}} | {error, not_found | term()}.
unfreeze(Id, Reason) -> unfreeze(Id, Reason) ->
update(Id, [{status, active}, {reason, 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}; %%% @doc Установить одно поле записи события.
set_field(location, Value, E) -> E#event{location = Value}; %%% Неизвестные поля игнорируются.
set_field(tags, Value, E) -> E#event{tags = Value}; %%% @end
set_field(capacity, Value, E) -> E#event{capacity = Value}; %%%-------------------------------------------------------------------
set_field(online_link, Value, E) -> E#event{online_link = Value}; -spec set_field(atom(), term(), #event{}) -> #event{}.
set_field(status, Value, E) -> E#event{status = Value}; set_field(title, Value, E) -> E#event{title = Value};
set_field(reason, Value, E) -> E#event{reason = Value}; set_field(description, Value, E) -> E#event{description = Value};
set_field(rating_avg, Value, E) -> E#event{rating_avg = Value}; set_field(start_time, Value, E) -> E#event{start_time = Value};
set_field(rating_count, Value, E) -> E#event{rating_count = 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. set_field(_, _, E) -> E.
+106 -97
View File
@@ -1,150 +1,159 @@
-module(core_report). -module(core_report).
-include("records.hrl"). -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]). -define(DEFAULT_RESOLVED_AT, {{1970,1,1},{0,0,0}}).
-export([count_reports_resolved_by_admin/2, avg_resolution_time/1]). -define(DEFAULT_RESOLVED_BY, <<>>).
-export([delete/1, update/2]). % <-- добавлено
%% Создание жалобы %%%-------------------------------------------------------------------
%%% @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) -> create(ReporterId, TargetType, TargetId, Reason) ->
Id = infra_utils:generate_id(16), Id = infra_utils:generate_id(16),
Now = calendar:universal_time(),
Report = #report{ Report = #report{
id = Id, id = Id,
reporter_id = ReporterId, reporter_id = ReporterId,
target_type = TargetType, target_type = TargetType,
target_id = TargetId, target_id = TargetId,
reason = Reason, reason = Reason,
status = pending, status = pending,
created_at = calendar:universal_time(), created_at = Now,
resolved_at = undefined, resolved_at = ?DEFAULT_RESOLVED_AT,
resolved_by = undefined 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 case mnesia:transaction(F) of
{atomic, Result} -> Result; {atomic, Result} -> Result;
{aborted, Reason} -> {error, Reason} {aborted, Reason} -> {error, Reason}
end. end.
%% Получение жалобы по ID %%%-------------------------------------------------------------------
%%% @doc Получить жалобу по идентификатору.
%%% @end
%%%-------------------------------------------------------------------
-spec get_by_id(Id :: binary()) -> {ok, #report{}} | {error, not_found}.
get_by_id(Id) -> get_by_id(Id) ->
case mnesia:dirty_read(report, Id) of case mnesia:dirty_read(report, Id) of
[] -> {error, not_found}; [] -> {error, not_found};
[Report] -> {ok, Report} [Report] -> {ok, Report}
end. end.
%% Список жалоб на цель %%%-------------------------------------------------------------------
list_by_target(TargetType, TargetId) -> %%% @doc Список всех жалоб.
Match = #report{target_type = TargetType, target_id = TargetId, _ = '_'}, %%% @end
Reports = mnesia:dirty_match_object(Match), %%%-------------------------------------------------------------------
{ok, Reports}. -spec list_all() -> [#report{}].
list_all() ->
mnesia:dirty_match_object(#report{_ = '_'}).
%% Список жалоб от пользователя %%%-------------------------------------------------------------------
%%% @doc Список жалоб, поданных определённым пользователем.
%%% @end
%%%-------------------------------------------------------------------
-spec list_by_reporter(ReporterId :: binary()) -> [#report{}].
list_by_reporter(ReporterId) -> list_by_reporter(ReporterId) ->
Match = #report{reporter_id = ReporterId, _ = '_'}, Match = #report{reporter_id = ReporterId, _ = '_'},
Reports = mnesia:dirty_match_object(Match), mnesia:dirty_match_object(Match).
{ok, Reports}.
%% Список всех жалоб (для админов) %%%-------------------------------------------------------------------
list_all() -> %%% @doc Список жалоб на конкретную цель.
Match = #report{_ = '_'}, %%% @end
Reports = mnesia:dirty_match_object(Match), %%%-------------------------------------------------------------------
{ok, Reports}. -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() -> F = fun() ->
case mnesia:read(report, Id) of case mnesia:read(report, Id) of
[] -> [] -> {error, not_found};
{error, not_found};
[Report] -> [Report] ->
Updated = Report#report{ Updated = Report#report{
status = Status, status = NewStatus,
resolved_at = calendar:universal_time(), resolved_by = AdminId,
resolved_by = ResolvedBy resolved_at = calendar:universal_time()
}, },
mnesia:write(Updated), mnesia:write(Updated),
{ok, Updated} {ok, Updated}
end end
end, end,
case mnesia:transaction(F) of case mnesia:transaction(F) of
{atomic, Result} -> Result; {atomic, Result} -> Result;
{aborted, Reason} -> {error, Reason} {aborted, Reason} -> {error, Reason}
end. end.
%% Получить количество жалоб на цель %%%-------------------------------------------------------------------
get_count_by_target(TargetType, TargetId) -> %%% @doc Количество жалоб.
Match = #report{target_type = TargetType, target_id = TargetId, status = pending, _ = '_'}, %%% @end
Reports = mnesia:dirty_match_object(Match), %%%-------------------------------------------------------------------
length(Reports). -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) -> count_reports_by_status(Status) ->
Match = #report{status = Status, _ = '_'}, Match = #report{status = Status, _ = '_'},
length(mnesia:dirty_match_object(Match)). length(mnesia:dirty_match_object(Match)).
count_reports_by_admin(AdminId, Status) -> %%%-------------------------------------------------------------------
Match = #report{resolved_by = AdminId, status = Status, _ = '_'}, %%% @doc Количество жалоб, разрешённых конкретным администратором.
length(mnesia:dirty_match_object(Match)). %%% @end
%%%-------------------------------------------------------------------
-spec count_reports_resolved_by_admin(AdminId :: binary(), Status :: reviewed | dismissed) ->
non_neg_integer().
count_reports_resolved_by_admin(AdminId, Status) -> count_reports_resolved_by_admin(AdminId, Status) ->
Match = #report{resolved_by = AdminId, status = Status, _ = '_'}, Match = #report{resolved_by = AdminId, status = Status, _ = '_'},
length(mnesia:dirty_match_object(Match)). length(mnesia:dirty_match_object(Match)).
%%%-------------------------------------------------------------------
%%% @doc Среднее время разрешения жалоб заданного статуса (в часах).
%%% @end
%%%-------------------------------------------------------------------
-spec avg_resolution_time(Status :: reviewed | dismissed) -> float().
avg_resolution_time(Status) -> avg_resolution_time(Status) ->
Match = #report{status = Status, _ = '_'}, Match = #report{status = Status, _ = '_'},
Reports = mnesia:dirty_match_object(Match), Reports = mnesia:dirty_match_object(Match),
Resolved = lists:filter(fun(R) -> R#report.resolved_at =/= undefined end, Reports), case Reports of
case Resolved of [] -> 0.0;
[] -> 0;
_ -> _ ->
TotalSeconds = lists:sum([calendar:datetime_to_gregorian_seconds(R#report.resolved_at) - Total = lists:sum([calendar:datetime_to_gregorian_seconds(R#report.resolved_at) -
calendar:datetime_to_gregorian_seconds(R#report.created_at) || R <- Resolved]), calendar:datetime_to_gregorian_seconds(R#report.created_at)
TotalSeconds / length(Resolved) / 3600.0 || R <- Reports, R#report.resolved_at /= undefined]),
end. Total / length(Reports) / 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).
+142 -54
View File
@@ -1,129 +1,217 @@
-module(core_review). -module(core_review).
-include("records.hrl"). -include("records.hrl").
-export([create/5, get_by_id/1, list_by_target/2, list_by_user/1, update/2, delete/1,
-export([create/5, get_by_id/1, list_by_target/2, list_by_user/1, hide/2, unhide/2]).
update/2, delete/1, hide/2, unhide/2]).
-export([get_average_rating/2, has_user_reviewed/3]). -export([get_average_rating/2, has_user_reviewed/3]).
-export([count_reviews/0, list_all/0]). -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) -> create(UserId, TargetType, TargetId, Rating, Comment) ->
Id = infra_utils:generate_id(16), Id = infra_utils:generate_id(16),
Now = calendar:universal_time(),
Review = #review{ Review = #review{
id = Id, id = Id,
user_id = UserId, user_id = UserId,
target_type = TargetType, target_type = TargetType,
target_id = TargetId, target_id = TargetId,
rating = Rating, rating = Rating,
comment = Comment, comment = Comment,
status = visible, status = visible,
created_at = calendar:universal_time(), reason = ?DEFAULT_REASON,
updated_at = calendar:universal_time() 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 case mnesia:transaction(F) of
{atomic, Result} -> Result; {atomic, Result} -> Result;
{aborted, Reason} -> {error, Reason} {aborted, Reason} -> {error, Reason}
end. end.
%% Получение отзыва по ID %%%-------------------------------------------------------------------
%%% @doc Получение отзыва по идентификатору.
%%% @end
%%%-------------------------------------------------------------------
-spec get_by_id(Id :: binary()) -> {ok, #review{}} | {error, not_found}.
get_by_id(Id) -> get_by_id(Id) ->
case mnesia:dirty_read(review, Id) of case mnesia:dirty_read(review, Id) of
[] -> {error, not_found}; [] -> {error, not_found};
[Review] -> {ok, Review} [Review] -> {ok, Review}
end. end.
%% Список отзывов для цели (событие или календарь) %%%-------------------------------------------------------------------
%%% @doc Список видимых отзывов для заданной цели.
%%% @end
%%%-------------------------------------------------------------------
-spec list_by_target(TargetType :: calendar | event, TargetId :: binary()) ->
{ok, [#review{}]}.
list_by_target(TargetType, TargetId) -> 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), 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) -> list_by_user(UserId) ->
Match = #review{user_id = UserId, _ = '_'}, Match = #review{user_id = UserId, _ = '_'},
Reviews = mnesia:dirty_match_object(Match), Reviews = mnesia:dirty_match_object(Match),
{ok, Reviews}. {ok, Reviews}.
%% Обновление отзыва %%%-------------------------------------------------------------------
%%% @doc Обновление отзыва.
%%% `Updates` список пар `[{atom(), term()}]`.
%%% Поля, не указанные в обновлении, сохраняют прежние значения.
%%% @end
%%%-------------------------------------------------------------------
-spec update(Id :: binary(), Updates :: [{atom(), term()}]) ->
{ok, #review{}} | {error, not_found | term()}.
update(Id, Updates) -> update(Id, Updates) ->
F = fun() -> F = fun() ->
case mnesia:read(review, Id) of case mnesia:read(review, Id) of
[] -> [] -> {error, not_found};
{error, not_found};
[Review] -> [Review] ->
UpdatedReview = apply_updates(Review, Updates), UpdatedReview = apply_updates(Review, Updates),
mnesia:write(UpdatedReview), mnesia:write(UpdatedReview),
{ok, UpdatedReview} {ok, UpdatedReview}
end end
end, end,
case mnesia:transaction(F) of case mnesia:transaction(F) of
{atomic, Result} -> Result; {atomic, Result} -> Result;
{aborted, Reason} -> {error, Reason} {aborted, Reason} -> {error, Reason}
end. end.
%% Удаление отзыва (hard delete) %%%-------------------------------------------------------------------
%%% @doc Удаление отзыва (физическое).
%%% @end
%%%-------------------------------------------------------------------
-spec delete(Id :: binary()) -> {ok, deleted} | {error, not_found | term()}.
delete(Id) -> delete(Id) ->
F = fun() -> F = fun() ->
case mnesia:read(review, Id) of case mnesia:read(review, Id) of
[] -> [] -> {error, not_found};
{error, not_found}; [Review] -> mnesia:delete_object(Review), {ok, deleted}
[Review] ->
mnesia:delete_object(Review),
{ok, deleted}
end end
end, end,
case mnesia:transaction(F) of case mnesia:transaction(F) of
{atomic, Result} -> Result; {atomic, Result} -> Result;
{aborted, Reason} -> {error, Reason} {aborted, Reason} -> {error, Reason}
end. end.
%% Скрытие отзыва (модерация) %%%-------------------------------------------------------------------
%%% @doc Скрытие отзыва с указанием причины.
%%% @end
%%%-------------------------------------------------------------------
-spec hide(Id :: binary(), Reason :: binary()) ->
{ok, #review{}} | {error, not_found | term()}.
hide(Id, Reason) -> hide(Id, Reason) ->
update(Id, [{status, hidden}, {reason, Reason}]). update(Id, [{status, hidden}, {reason, Reason}]).
%%%-------------------------------------------------------------------
%%% @doc Восстановление видимости отзыва с указанием причины.
%%% @end
%%%-------------------------------------------------------------------
-spec unhide(Id :: binary(), Reason :: binary()) ->
{ok, #review{}} | {error, not_found | term()}.
unhide(Id, Reason) -> unhide(Id, Reason) ->
update(Id, [{status, visible}, {reason, 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) -> 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), Reviews = mnesia:dirty_match_object(Match),
case length(Reviews) of case length(Reviews) of
0 -> {0.0, 0}; 0 -> {0.0, 0};
Count -> Count ->
Total = lists:sum([R#review.rating || R <- Reviews]), Total = lists:sum([R#review.rating || R <- Reviews]),
{Total / Count, Count} {Total / Count, Count}
end. end.
%% Проверка, оставлял ли пользователь отзыв %%%-------------------------------------------------------------------
%%% @doc Проверить, оставлял ли пользователь отзыв на цель.
%%% @end
%%%-------------------------------------------------------------------
-spec has_user_reviewed(UserId :: binary(), TargetType :: calendar | event,
TargetId :: binary()) -> boolean().
has_user_reviewed(UserId, TargetType, TargetId) -> 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 case mnesia:dirty_match_object(Match) of
[] -> false; [] -> false;
_ -> true _ -> true
end. 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) -> apply_updates(Review, Updates) ->
Updated = lists:foldl(fun({Field, Value}, R) -> Updated = lists:foldl(fun({Field, Value}, R) -> set_field(Field, Value, R) end,
set_field(Field, Value, R) Review, Updates),
end, Review, Updates),
Updated#review{updated_at = calendar:universal_time()}. 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}; %%% @doc Установить одно поле записи отзыва.
set_field(status, Value, R) when Value =:= visible; Value =:= hidden -> R#review{status = Value}; %%% Неизвестные поля игнорируются.
set_field(reason, Value, R) -> R#review{reason = Value}; %%% @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. set_field(_, _, R) -> R.
+34 -11
View File
@@ -1,20 +1,42 @@
%%%-------------------------------------------------------------------
%%% @doc Управление сессиями пользователей (refresh-токены).
%%%
%%% Запись `session` хранит refresh-токен, идентификатор пользователя,
%%% время истечения и тип токена. Все поля обязательны и инициализируются
%%% при создании, поэтому `undefined` в базе не возникает.
%%% @end
%%%-------------------------------------------------------------------
-module(core_session). -module(core_session).
-include("records.hrl"). -include("records.hrl").
-export([create/2, validate/1, delete/1]). -export([create/2, validate/1, delete/1]).
%%%-------------------------------------------------------------------
%%% @doc Создать новую сессию.
%%% Токен действителен 30 дней с момента создания.
%%% @end
%%%-------------------------------------------------------------------
-spec create(UserId :: binary(), RefreshToken :: binary()) -> ok. -spec create(UserId :: binary(), RefreshToken :: binary()) -> ok.
create(UserId, RefreshToken) -> create(UserId, RefreshToken) ->
Session = #session{ Session = #session{
token = RefreshToken, token = RefreshToken,
user_id = UserId, user_id = UserId,
expires_at = calendar:gregorian_seconds_to_datetime( expires_at = calendar:gregorian_seconds_to_datetime(
calendar:datetime_to_gregorian_seconds(calendar:universal_time()) + 30 * 24 * 3600), calendar:datetime_to_gregorian_seconds(
type = refresh calendar:universal_time()) + 30 * 24 * 3600),
type = refresh
}, },
mnesia:dirty_write(Session), mnesia:dirty_write(Session),
ok. 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) -> validate(Token) ->
case mnesia:dirty_read({session, Token}) of case mnesia:dirty_read({session, Token}) of
[Session] -> [Session] ->
@@ -22,19 +44,20 @@ validate(Token) ->
true -> true ->
% Получаем пользователя по ID из сессии % Получаем пользователя по ID из сессии
case mnesia:dirty_read({user, Session#session.user_id}) of case mnesia:dirty_read({user, Session#session.user_id}) of
[User] -> [User] -> {ok, Session#session.user_id, User};
{ok, Session#session.user_id, User}; [] -> {error, not_found}
[] ->
{error, not_found}
end; end;
false -> false ->
mnesia:dirty_delete({session, Token}), mnesia:dirty_delete({session, Token}),
{error, expired} {error, expired}
end; end;
[] -> [] -> {error, not_found}
{error, not_found}
end. end.
%%%-------------------------------------------------------------------
%%% @doc Удалить сессию (отзыв refresh-токена).
%%% @end
%%%-------------------------------------------------------------------
-spec delete(Token :: binary()) -> ok. -spec delete(Token :: binary()) -> ok.
delete(Token) -> delete(Token) ->
mnesia:dirty_delete({session, Token}), mnesia:dirty_delete({session, Token}),
+128 -74
View File
@@ -1,65 +1,65 @@
-module(core_subscription). -module(core_subscription).
-include("records.hrl"). -include("records.hrl").
-export([create/3, get_by_id/1, get_active_by_user/1, list_by_user/1, list_all/0]). -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([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]). -export([count_subscription/0]).
-define(TRIAL_DAYS, 30). -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) -> create(UserId, Plan, TrialUsed) ->
Id = infra_utils:generate_id(16), Id = infra_utils:generate_id(16),
Now = calendar:universal_time(), Now = calendar:universal_time(),
{StartDate, EndDate} = case TrialUsed of {StartDate, EndDate} = case TrialUsed of
true -> true ->
% Платная подписка
DurationMonths = plan_to_months(Plan), DurationMonths = plan_to_months(Plan),
End = add_months(Now, DurationMonths), End = add_months(Now, DurationMonths),
{Now, End}; {Now, End};
false -> false ->
% Пробный период
End = add_days(Now, ?TRIAL_DAYS), End = add_days(Now, ?TRIAL_DAYS),
{Now, End} {Now, End}
end, end,
Subscription = #subscription{ Subscription = #subscription{
id = Id, id = Id,
user_id = UserId, user_id = UserId,
plan = Plan, plan = Plan,
status = active, status = active,
trial_used = TrialUsed, trial_used = TrialUsed,
started_at = StartDate, started_at = StartDate,
expires_at = EndDate, expires_at = EndDate,
created_at = Now, created_at = Now,
updated_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 case mnesia:transaction(F) of
{atomic, Result} -> Result; {atomic, Result} -> Result;
{aborted, Reason} -> {error, Reason} {aborted, Reason} -> {error, Reason}
end. end.
%% Получение подписки по ID %%%-------------------------------------------------------------------
%%% @doc Получить подписку по идентификатору.
%%% @end
%%%-------------------------------------------------------------------
-spec get_by_id(Id :: binary()) -> {ok, #subscription{}} | {error, not_found}.
get_by_id(Id) -> get_by_id(Id) ->
case mnesia:dirty_read(subscription, Id) of case mnesia:dirty_read(subscription, Id) of
[] -> {error, not_found}; [] -> {error, not_found};
[Subscription] -> {ok, Subscription} [Subscription] -> {ok, Subscription}
end. end.
%% Получение активной подписки пользователя %%%-------------------------------------------------------------------
%%% @doc Получить активную подписку пользователя.
%%% @end
%%%-------------------------------------------------------------------
-spec get_active_by_user(UserId :: binary()) -> {ok, #subscription{}} | {error, not_found}.
get_active_by_user(UserId) -> get_active_by_user(UserId) ->
Match = #subscription{user_id = UserId, status = active, _ = '_'}, Match = #subscription{user_id = UserId, status = active, _ = '_'},
case mnesia:dirty_match_object(Match) of case mnesia:dirty_match_object(Match) of
@@ -67,50 +67,64 @@ get_active_by_user(UserId) ->
[Subscription] -> {ok, Subscription} [Subscription] -> {ok, Subscription}
end. end.
%% Список всех подписок пользователя %%%-------------------------------------------------------------------
%%% @doc Список всех подписок пользователя.
%%% @end
%%%-------------------------------------------------------------------
-spec list_by_user(UserId :: binary()) -> {ok, [#subscription{}]}.
list_by_user(UserId) -> list_by_user(UserId) ->
Match = #subscription{user_id = UserId, _ = '_'}, Match = #subscription{user_id = UserId, _ = '_'},
Subscriptions = mnesia:dirty_match_object(Match), 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() -> list_all() ->
Match = #subscription{_ = '_'}, Match = #subscription{_ = '_'},
Subscriptions = mnesia:dirty_match_object(Match), Subscriptions = mnesia:dirty_match_object(Match),
{ok, Subscriptions}. {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 -> update_status(Id, Status) when Status =:= active; Status =:= expired; Status =:= cancelled ->
F = fun() -> F = fun() ->
case mnesia:read(subscription, Id) of case mnesia:read(subscription, Id) of
[] -> [] -> {error, not_found};
{error, not_found};
[Subscription] -> [Subscription] ->
Updated = Subscription#subscription{ Updated = Subscription#subscription{
status = Status, status = Status,
updated_at = calendar:universal_time() updated_at = calendar:universal_time()
}, },
mnesia:write(Updated), mnesia:write(Updated),
{ok, Updated} {ok, Updated}
end end
end, end,
case mnesia:transaction(F) of case mnesia:transaction(F) of
{atomic, Result} -> Result; {atomic, Result} -> Result;
{aborted, Reason} -> {error, Reason} {aborted, Reason} -> {error, Reason}
end. end.
%% Проверка истёкших подписок (вызывается периодически) %%%-------------------------------------------------------------------
%%% @doc Проверка истёкших подписок (вызывается периодически).
%%% @end
%%%-------------------------------------------------------------------
-spec check_expired() -> ok.
check_expired() -> check_expired() ->
Now = calendar:universal_time(), Now = calendar:universal_time(),
Match = #subscription{status = active, _ = '_'}, Match = #subscription{status = active, _ = '_'},
ActiveSubscriptions = mnesia:dirty_match_object(Match), ActiveSubscriptions = mnesia:dirty_match_object(Match),
lists:foreach(fun(Sub) -> lists:foreach(fun(Sub) ->
case Sub#subscription.expires_at < Now of case Sub#subscription.expires_at < Now of
true -> true ->
update_status(Sub#subscription.id, expired), update_status(Sub#subscription.id, expired),
% Если у пользователя нет другой активной подписки, понижаем календари
case get_active_by_user(Sub#subscription.user_id) of case get_active_by_user(Sub#subscription.user_id) of
{error, not_found} -> {error, not_found} ->
downgrade_user_calendars(Sub#subscription.user_id); downgrade_user_calendars(Sub#subscription.user_id);
@@ -120,7 +134,11 @@ check_expired() ->
end end
end, ActiveSubscriptions). end, ActiveSubscriptions).
%% Понижение календарей пользователя до personal при истечении подписки %%%-------------------------------------------------------------------
%%% @doc Понижение календарей пользователя до personal при истечении подписки.
%%% @end
%%%-------------------------------------------------------------------
-spec downgrade_user_calendars(UserId :: binary()) -> ok.
downgrade_user_calendars(UserId) -> downgrade_user_calendars(UserId) ->
Match = #calendar{owner_id = UserId, type = commercial, _ = '_'}, Match = #calendar{owner_id = UserId, type = commercial, _ = '_'},
Calendars = mnesia:dirty_match_object(Match), Calendars = mnesia:dirty_match_object(Match),
@@ -128,43 +146,66 @@ downgrade_user_calendars(UserId) ->
core_calendar:update(Cal#calendar.id, [{type, personal}]) core_calendar:update(Cal#calendar.id, [{type, personal}])
end, Calendars). 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) -> add_months(DateTime, Months) ->
Seconds = calendar:datetime_to_gregorian_seconds(DateTime), Seconds = calendar:datetime_to_gregorian_seconds(DateTime),
Days = Seconds div 86400, Days = Seconds div 86400,
NewDays = Days + (Months * 30), NewDays = Days + (Months * 30),
calendar:gregorian_seconds_to_datetime(NewDays * 86400). calendar:gregorian_seconds_to_datetime(NewDays * 86400).
-spec add_days(calendar:datetime(), pos_integer()) -> calendar:datetime().
add_days(DateTime, Days) -> add_days(DateTime, Days) ->
Seconds = calendar:datetime_to_gregorian_seconds(DateTime), Seconds = calendar:datetime_to_gregorian_seconds(DateTime),
calendar:gregorian_seconds_to_datetime(Seconds + (Days * 86400)). calendar:gregorian_seconds_to_datetime(Seconds + (Days * 86400)).
% ================================================================ %%%===================================================================
% Новые обёртки для совместимости с admin_handler_subscriptions %%% Новые обёртки для админки
% ================================================================ %%%===================================================================
%%%-------------------------------------------------------------------
%%% @doc Список подписок в виде списка записей.
%%% @end
%%%-------------------------------------------------------------------
-spec list_subscriptions() -> [#subscription{}].
list_subscriptions() -> list_subscriptions() ->
{ok, Subs} = list_all(), {ok, Subs} = list_all(),
Subs. Subs.
%%%-------------------------------------------------------------------
%%% @doc Создание подписки из map-параметров.
%%% @end
%%%-------------------------------------------------------------------
-spec create_subscription(Data :: map()) -> {ok, #subscription{}} | {error, term()}.
create_subscription(Data) -> create_subscription(Data) ->
UserId = maps:get(<<"user_id">>, Data), UserId = maps:get(<<"user_id">>, Data),
Plan = case maps:get(<<"plan">>, Data, <<"monthly">>) of Plan = case maps:get(<<"plan">>, Data, <<"monthly">>) of
<<"monthly">> -> monthly; <<"monthly">> -> monthly;
<<"yearly">> -> yearly; <<"yearly">> -> yearly;
<<"quarterly">>-> quarterly; <<"quarterly">> -> quarterly;
<<"biannual">> -> biannual; <<"biannual">> -> biannual;
<<"annual">> -> annual; <<"annual">> -> annual;
Other -> Other Other -> Other
end, end,
TrialUsed = maps:get(<<"trial_used">>, Data, false), TrialUsed = maps:get(<<"trial_used">>, Data, false),
create(UserId, Plan, TrialUsed). create(UserId, Plan, TrialUsed).
%%%-------------------------------------------------------------------
%%% @doc Обновление полей подписки (административная операция).
%%% @end
%%%-------------------------------------------------------------------
-spec update_subscription(Id :: binary(), Updates :: map()) ->
{ok, #subscription{}} | {error, not_found}.
update_subscription(Id, Updates) -> update_subscription(Id, Updates) ->
case get_by_id(Id) of case get_by_id(Id) of
{ok, Sub} -> {ok, Sub} ->
@@ -174,6 +215,11 @@ update_subscription(Id, Updates) ->
Error -> Error Error -> Error
end. end.
%%%-------------------------------------------------------------------
%%% @doc Удаление подписки (физическое).
%%% @end
%%%-------------------------------------------------------------------
-spec delete_subscription(Id :: binary()) -> {ok, deleted} | {error, not_found}.
delete_subscription(Id) -> delete_subscription(Id) ->
case get_by_id(Id) of case get_by_id(Id) of
{ok, _Sub} -> {ok, _Sub} ->
@@ -182,7 +228,11 @@ delete_subscription(Id) ->
Error -> Error Error -> Error
end. end.
%% Применение обновлений к записи подписки %%%-------------------------------------------------------------------
%%% @doc Применение обновлений к записи подписки.
%%% @end
%%%-------------------------------------------------------------------
-spec apply_updates(#subscription{}, map()) -> #subscription{}.
apply_updates(Sub, Updates) -> apply_updates(Sub, Updates) ->
lists:foldl(fun({Key, Value}, Acc) -> lists:foldl(fun({Key, Value}, Acc) ->
case Key of case Key of
@@ -193,8 +243,7 @@ apply_updates(Sub, Updates) ->
<<"expired">> -> expired; <<"expired">> -> expired;
Other -> Other Other -> Other
end, end,
Acc#subscription{status = NewStatus, Acc#subscription{status = NewStatus, updated_at = calendar:universal_time()};
updated_at = calendar:universal_time()};
<<"plan">> -> <<"plan">> ->
NewPlan = case Value of NewPlan = case Value of
<<"monthly">> -> monthly; <<"monthly">> -> monthly;
@@ -204,22 +253,22 @@ apply_updates(Sub, Updates) ->
<<"annual">> -> annual; <<"annual">> -> annual;
Other -> Other Other -> Other
end, end,
Acc#subscription{plan = NewPlan, Acc#subscription{plan = NewPlan, updated_at = calendar:universal_time()};
updated_at = calendar:universal_time()};
<<"trial_used">> -> <<"trial_used">> ->
Acc#subscription{trial_used = Value, Acc#subscription{trial_used = Value, updated_at = calendar:universal_time()};
updated_at = calendar:universal_time()};
<<"expires_at">> -> <<"expires_at">> ->
Acc#subscription{expires_at = parse_iso_datetime(Value), Acc#subscription{expires_at = parse_iso_datetime(Value), updated_at = calendar:universal_time()};
updated_at = calendar:universal_time()};
<<"started_at">> -> <<"started_at">> ->
Acc#subscription{started_at = parse_iso_datetime(Value), Acc#subscription{started_at = parse_iso_datetime(Value), updated_at = calendar:universal_time()};
updated_at = calendar:universal_time()}; _ -> Acc
_ ->
Acc
end 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) -> parse_iso_datetime(Bin) when is_binary(Bin) ->
try try
[DateStr, TimeStr] = string:split(Bin, "T"), [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)), Minute = binary_to_integer(list_to_binary(MinuteStr)),
Second = binary_to_integer(list_to_binary(SecondStr)), Second = binary_to_integer(list_to_binary(SecondStr)),
{{Year, Month, Day}, {Hour, Minute, Second}} {{Year, Month, Day}, {Hour, Minute, Second}}
catch catch _:_ -> Bin
_:_ -> Bin % если не получилось разобрать, оставляем как есть
end; end;
parse_iso_datetime(Other) -> Other. parse_iso_datetime(Other) -> Other.
count_subscription() -> mnesia:table_info(subscription, size). %%%-------------------------------------------------------------------
%%% @doc Количество подписок.
%%% @end
%%%-------------------------------------------------------------------
-spec count_subscription() -> non_neg_integer().
count_subscription() ->
mnesia:table_info(subscription, size).
+257 -127
View File
@@ -1,138 +1,268 @@
-module(core_ticket). -module(core_ticket).
-include("records.hrl"). -include("records.hrl").
-export([list_all/0, -export([create/4, get_by_id/1, list_all/0, update/2, update_status/2, delete/1,
get_by_id/1, assign/2, resolve/3, close/2]).
update_ticket/2, -export([count_tickets/0]).
delete_ticket/1, -export([create_ticket/1]).
stats/0, -export([list_by_user/1]).
create_ticket/1, -export([count_tickets_by_status/1, avg_resolution_time/0, count_tickets_by_admin/2]).
list_by_user/1]). -export([update_ticket/2]).
-export([count_tickets_by_status/1, count_tickets_by_admin/2]). -export([delete_ticket/1]). % ← для admin_handler_ticket_by_id
-export([avg_resolution_time/0]).
%% ─────────────────────────────────────────────────────────────────
%% Значения по умолчанию для необязательных полей
%% ─────────────────────────────────────────────────────────────────
-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() -> list_all() ->
mnesia:dirty_match_object(#ticket{_ = '_'}). mnesia:dirty_match_object(#ticket{_ = '_'}).
get_by_id(Id) -> %%%-------------------------------------------------------------------
case mnesia:dirty_read({ticket, Id}) of %%% @doc Список тикетов, созданных пользователем.
[Ticket] -> {ok, Ticket}; %%% @end
[] -> {error, not_found} %%%-------------------------------------------------------------------
end. -spec list_by_user(UserId :: binary()) -> [#ticket{}].
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}.
list_by_user(UserId) -> list_by_user(UserId) ->
mnesia:dirty_match_object(#ticket{reporter_id = UserId, _ = '_'}). Match = #ticket{reporter_id = UserId, _ = '_'},
mnesia:dirty_match_object(Match).
%% ── функции подсчёта с нормализацией статуса ───────────── %%%-------------------------------------------------------------------
%%% @doc Обновить поля тикета (список кортежей).
%% @private Подсчитывает тикеты с заданным статусом (атом или бинарный) %%% @end
count_by_status(Status, Tickets) -> %%%-------------------------------------------------------------------
length([T || T <- Tickets, normalize_status(T#ticket.status) =:= Status]). -spec update(Id :: binary(), Updates :: [{atom(), term()}]) ->
{ok, #ticket{}} | {error, not_found | term()}.
%% @doc Количество тикетов по статусу (атом или бинарный) update(Id, Updates) ->
count_tickets_by_status(Status) -> F = fun() ->
Tickets = list_all(), case mnesia:read(ticket, Id) of
count_by_status(Status, Tickets). [] -> {error, not_found};
[Ticket] ->
%% @doc Количество тикетов, назначенных администратору, с заданным статусом UpdatedTicket = apply_updates(Ticket, Updates),
count_tickets_by_admin(AdminId, Status) -> mnesia:write(UpdatedTicket),
Tickets = list_all(), {ok, UpdatedTicket}
length([T || T <- Tickets, end
T#ticket.assigned_to =:= AdminId andalso end,
normalize_status(T#ticket.status) =:= Status]). case mnesia:transaction(F) of
{atomic, Result} -> Result;
avg_resolution_time() -> {aborted, Reason} -> {error, Reason}
% Загружаем все тикеты (или можно только закрытые, если их мало – решите по нагрузке)
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
end. end.
%% ── внутренние ───────────────────────────────────────── %%%-------------------------------------------------------------------
apply_updates(Ticket, Updates) -> %%% @doc Изменить статус тикета.
lists:foldl(fun({Key, Value}, Acc) -> %%% @end
case Key of %%%-------------------------------------------------------------------
<<"status">> -> -spec update_status(Id :: binary(), NewStatus :: open | in_progress | resolved | closed) ->
NewStatus = normalize_status(Value), {ok, #ticket{}} | {error, not_found | term()}.
Acc1 = Acc#ticket{status = NewStatus}, update_status(Id, NewStatus) ->
case NewStatus of update(Id, [{status, NewStatus}]).
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)).
%% @private Преобразует бинарный статус в атом, если нужно. %%%-------------------------------------------------------------------
%% Атомы возвращает без изменений. %%% @doc Физическое удаление тикета.
normalize_status(Status) when is_atom(Status) -> Status; %%% Возвращает `{ok, deleted}` при успехе.
normalize_status(Status) when is_binary(Status) -> %%% @end
try binary_to_existing_atom(Status, utf8) %%%-------------------------------------------------------------------
catch error:badarg -> Status -spec delete(Id :: binary()) -> {ok, deleted} | {error, not_found}.
end. 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.
+203 -61
View File
@@ -1,52 +1,83 @@
-module(core_user). -module(core_user).
-include("records.hrl"). -include("records.hrl").
-export([create/2, get_by_id/1, get_by_email/1, update/2, update_status/3,
-export([create/2, get_by_id/1, get_by_email/1, update/2, update_status/3, delete/1, update_last_login/1]). delete/1, update_last_login/1]).
-export([email_exists/1]). -export([email_exists/1]).
-export([list_users/0]). -export([list_users/0]).
-export([block/2, unblock/2]). -export([block/2, unblock/2]).
-export([count_users/0, count_users_by_date/2, list_all/0]). -export([count_users/0, count_users_by_date/2, list_all/0]).
-export([create_bot/2, delete_bot/1]). -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) -> create(Email, Password) ->
% Проверяем, существует ли email
case email_exists(Email) of case email_exists(Email) of
true -> true ->
{error, email_exists}; {error, email_exists};
false -> false ->
Id = infra_utils:generate_id(16), Id = infra_utils:generate_id(16),
{ok, PasswordHash} = logic_auth:hash_password(Password), {ok, PasswordHash} = logic_auth:hash_password(Password),
Now = calendar:universal_time(),
User = #user{ User = #user{
id = Id, id = Id,
email = Email, email = Email,
password_hash = PasswordHash, password_hash = PasswordHash,
role = user, role = user,
status = active, status = active,
created_at = calendar:universal_time(), reason = ?DEFAULT_REASON,
updated_at = calendar:universal_time() 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 case mnesia:transaction(F) of
{atomic, Result} -> Result; {atomic, Result} -> Result;
{aborted, Reason} -> {error, Reason} {aborted, Reason} -> {error, Reason}
end end
end. end.
%% Получение пользователя по ID %%%-------------------------------------------------------------------
%%% @doc Получить пользователя по идентификатору.
%%% @end
%%%-------------------------------------------------------------------
-spec get_by_id(Id :: binary()) -> {ok, #user{}} | {error, not_found}.
get_by_id(Id) -> get_by_id(Id) ->
case mnesia:dirty_read(user, Id) of case mnesia:dirty_read(user, Id) of
[] -> {error, not_found}; [] -> {error, not_found};
[User] -> {ok, User} [User] -> {ok, User}
end. end.
%% Получение пользователя по email %%%-------------------------------------------------------------------
%%% @doc Получить пользователя по email.
%%% @end
%%%-------------------------------------------------------------------
-spec get_by_email(Email :: binary()) -> {ok, #user{}} | {error, not_found}.
get_by_email(Email) -> get_by_email(Email) ->
Match = #user{email = Email, _ = '_'}, Match = #user{email = Email, _ = '_'},
case mnesia:dirty_match_object(Match) of case mnesia:dirty_match_object(Match) of
@@ -54,31 +85,46 @@ get_by_email(Email) ->
[User] -> {ok, User} [User] -> {ok, User}
end. end.
%% Проверка существования email %%%-------------------------------------------------------------------
%%% @doc Проверить, существует ли email в базе.
%%% @end
%%%-------------------------------------------------------------------
-spec email_exists(Email :: binary()) -> boolean().
email_exists(Email) -> email_exists(Email) ->
case get_by_email(Email) of case get_by_email(Email) of
{ok, _} -> true; {ok, _} -> true;
{error, _} -> false {error, _} -> false
end. end.
%% Обновление пользователя %%%-------------------------------------------------------------------
%%% @doc Обновить поля пользователя.
%%% `Updates` список пар `{atom(), term()}`.
%%% Поля, не указанные в обновлении, сохраняют прежние значения.
%%% @end
%%%-------------------------------------------------------------------
-spec update(Id :: binary(), Updates :: [{atom(), term()}]) ->
{ok, #user{}} | {error, not_found | term()}.
update(Id, Updates) -> update(Id, Updates) ->
F = fun() -> F = fun() ->
case mnesia:read(user, Id) of case mnesia:read(user, Id) of
[] -> [] -> {error, not_found};
{error, not_found};
[User] -> [User] ->
UpdatedUser = apply_updates(User, Updates), UpdatedUser = apply_updates(User, Updates),
mnesia:write(UpdatedUser), mnesia:write(UpdatedUser),
{ok, UpdatedUser} {ok, UpdatedUser}
end end
end, end,
case mnesia:transaction(F) of case mnesia:transaction(F) of
{atomic, Result} -> Result; {atomic, Result} -> Result;
{aborted, Reason} -> {error, Reason} {aborted, Reason} -> {error, Reason}
end. end.
%%%-------------------------------------------------------------------
%%% @doc Обновить время последнего входа пользователя.
%%% Устанавливает `last_login` в текущее UTC-время.
%%% @end
%%%-------------------------------------------------------------------
-spec update_last_login(Id :: binary()) -> {ok, #user{}} | {error, not_found}.
update_last_login(Id) -> update_last_login(Id) ->
case get_by_id(Id) of case get_by_id(Id) of
{ok, User} -> {ok, User} ->
@@ -88,76 +134,140 @@ update_last_login(Id) ->
Error -> Error Error -> Error
end. end.
%%%-------------------------------------------------------------------
%%% @doc Изменить статус и причину пользователя.
%%% @end
%%%-------------------------------------------------------------------
-spec update_status(Id :: binary(), Status :: atom(), Reason :: binary()) ->
{ok, #user{}} | {error, not_found}.
update_status(Id, Status, Reason) -> update_status(Id, Status, Reason) ->
case get_by_id(Id) of case get_by_id(Id) of
{ok, User} -> {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), mnesia:dirty_write(Updated),
{ok, Updated}; {ok, Updated};
Error -> Error Error -> Error
end. end.
%% Удаление пользователя (soft delete) %%%-------------------------------------------------------------------
%%% @doc Мягкое удаление пользователя (установка статуса `deleted`).
%%% @end
%%%-------------------------------------------------------------------
-spec delete(Id :: binary()) -> {ok, #user{}} | {error, not_found | term()}.
delete(Id) -> delete(Id) ->
update(Id, [{status, deleted}]). update(Id, [{status, deleted}]).
%%%-------------------------------------------------------------------
%%% @doc Получить список активных пользователей (без удалённых).
%%% Результат – список карт с ключами `id`, `email`, `role`, `status`, `reason`,
%%% `created_at`, `updated_at`.
%%% @end
%%%-------------------------------------------------------------------
-spec list_users() -> {ok, [map()]}.
list_users() -> list_users() ->
Users = mnesia:dirty_match_object(#user{_ = '_'}), Users = mnesia:dirty_match_object(#user{_ = '_'}),
ActiveUsers = [U || U <- Users, U#user.status =/= deleted], ActiveUsers = [U || U <- Users, U#user.status =/= deleted],
{ok, [user_to_map(U) || U <- ActiveUsers]}. {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) -> user_to_map(User) when is_map(User) ->
#{ #{
id => maps:get(id, User), id => maps:get(id, User),
email => maps:get(email, User), email => maps:get(email, User),
role => maps:get(role, User, <<"user">>), role => maps:get(role, User, <<"user">>),
status => maps:get(status, User, <<"active">>), status => maps:get(status, User, <<"active">>),
reason => maps:get(reason, User, undefined), reason => maps:get(reason, User, ?DEFAULT_REASON),
created_at => maps:get(created_at, User), created_at => maps:get(created_at, User),
updated_at => maps:get(updated_at, User) updated_at => maps:get(updated_at, User)
}; };
user_to_map(User) -> user_to_map(User) ->
#{ #{
id => User#user.id, id => User#user.id,
email => User#user.email, email => User#user.email,
role => atom_to_binary(User#user.role, utf8), role => atom_to_binary(User#user.role, utf8),
status => atom_to_binary(User#user.status, utf8), status => atom_to_binary(User#user.status, utf8),
reason => User#user.reason, reason => User#user.reason,
created_at => User#user.created_at, created_at => User#user.created_at,
updated_at => User#user.updated_at updated_at => User#user.updated_at
}. }.
%%%-------------------------------------------------------------------
%%% @doc Заблокировать пользователя с указанием причины.
%%% @end
%%%-------------------------------------------------------------------
-spec block(Id :: binary(), Reason :: binary()) ->
{ok, #user{}} | {error, not_found}.
block(Id, Reason) -> block(Id, Reason) ->
case get_by_id(Id) of case get_by_id(Id) of
{ok, User} -> {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), mnesia:dirty_write(Updated),
{ok, Updated}; {ok, Updated};
Error -> Error Error -> Error
end. end.
%%%-------------------------------------------------------------------
%%% @doc Разблокировать пользователя с указанием причины.
%%% @end
%%%-------------------------------------------------------------------
-spec unblock(Id :: binary(), Reason :: binary()) ->
{ok, #user{}} | {error, not_found}.
unblock(Id, Reason) -> unblock(Id, Reason) ->
case get_by_id(Id) of case get_by_id(Id) of
{ok, User} -> {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), mnesia:dirty_write(Updated),
{ok, Updated}; {ok, Updated};
Error -> Error Error -> Error
end. 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() -> count_users() ->
mnesia:table_info(user, size). mnesia:table_info(user, size).
%% Административный список (все пользователи, без фильтрации) %%%-------------------------------------------------------------------
%%% @doc Получить всех пользователей (включая удалённых).
%%% @end
%%%-------------------------------------------------------------------
-spec list_all() -> [#user{}].
list_all() -> list_all() ->
mnesia:dirty_match_object(#user{_ = '_'}). 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) -> count_users_by_date(From, To) ->
All = mnesia:dirty_match_object(#user{_ = '_'}), All = mnesia:dirty_match_object(#user{_ = '_'}),
Filtered = lists:filter(fun(U) -> Filtered = lists:filter(fun(U) -> U#user.created_at >= From andalso
U#user.created_at >= From andalso U#user.created_at =< To U#user.created_at =< To end, All),
end, All),
Counts = lists:foldl(fun(U, Acc) -> Counts = lists:foldl(fun(U, Acc) ->
Day = date_part(U#user.created_at), Day = date_part(U#user.created_at),
case lists:keyfind(Day, 1, Acc) of case lists:keyfind(Day, 1, Acc) of
@@ -167,18 +277,35 @@ count_users_by_date(From, To) ->
end, [], Filtered), end, [], Filtered),
lists:sort(Counts). 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}. date_part({{Y,M,D}, _}) -> {Y,M,D}.
%%%-------------------------------------------------------------------
%%% @doc Применить список обновлений к записи пользователя.
%%% @end
%%%-------------------------------------------------------------------
-spec apply_updates(#user{}, [{atom(), term()}]) -> #user{}.
apply_updates(User, Updates) -> apply_updates(User, Updates) ->
Updated = lists:foldl(fun({Field, Value}, U) -> Updated = lists:foldl(fun({Field, Value}, U) ->
set_field(Field, Value, U) set_field(Field, Value, U)
end, User, Updates), end, User, Updates),
Updated#user{updated_at = calendar:universal_time()}. 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(email, Value, U) -> U#user{email = Value};
set_field(password_hash, Value, U) -> U#user{password_hash = 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(role, Value, U) when Value =:= user; Value =:= admin; Value =:= bot ->
set_field(status, Value, U) when Value =:= active; Value =:= frozen; Value =:= deleted -> U#user{status = Value}; 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(reason, Value, U) -> U#user{reason = Value};
set_field(nickname, Value, U) -> U#user{nickname = Value}; set_field(nickname, Value, U) -> U#user{nickname = Value};
set_field(avatar_url, Value, U) -> U#user{avatar_url = 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(preferences, Value, U) -> U#user{preferences = Value};
set_field(_, _, U) -> U. set_field(_, _, U) -> U.
%% ------------------------------------------------------------------ %%%-------------------------------------------------------------------
%% API для ботов %%% @doc Создание бота (аналогично пользователю, но с ролью `bot`).
%% ------------------------------------------------------------------ %%% @end
%%%-------------------------------------------------------------------
-spec create_bot(Email :: binary(), Password :: binary()) -> -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) -> create_bot(Email, Password) ->
case email_exists(Email) of case email_exists(Email) of
true -> true ->
@@ -204,14 +331,24 @@ create_bot(Email, Password) ->
[] -> [] ->
{ok, PasswordHash} = logic_auth:hash_password(Password), {ok, PasswordHash} = logic_auth:hash_password(Password),
Id = infra_utils:generate_id(16), Id = infra_utils:generate_id(16),
Now = calendar:universal_time(),
User = #user{ User = #user{
id = Id, id = Id,
email = Email, email = Email,
password_hash = PasswordHash, password_hash = PasswordHash,
role = bot, role = bot,
status = active, status = active,
created_at = calendar:universal_time(), reason = ?DEFAULT_REASON,
updated_at = calendar:universal_time() 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 = mnesia:dirty_write(User),
{ok, User}; {ok, User};
@@ -220,11 +357,16 @@ create_bot(Email, Password) ->
end end
end. end.
%%%-------------------------------------------------------------------
%%% @doc Удаление бота (физическое удаление записи, не мягкое).
%%% @end
%%%-------------------------------------------------------------------
-spec delete_bot(Id :: binary()) -> ok | {error, not_found}. -spec delete_bot(Id :: binary()) -> ok | {error, not_found}.
delete_bot(Id) -> delete_bot(Id) ->
case mnesia:dirty_read({user, Id}) of case mnesia:dirty_read({user, Id}) of
[#user{role = bot}] -> [#user{role = bot}] ->
mnesia:dirty_delete({user, Id}), mnesia:dirty_delete({user, Id}),
ok; ok;
[] -> {error, not_found} [] ->
{error, not_found}
end. end.
@@ -101,8 +101,12 @@ update_report(Req) ->
ReportId = cowboy_req:binding(id, Req1), ReportId = cowboy_req:binding(id, Req1),
{ok, Body, Req2} = cowboy_req:read_body(Req1), {ok, Body, Req2} = cowboy_req:read_body(Req1),
try jsx:decode(Body, [return_maps]) of try jsx:decode(Body, [return_maps]) of
#{<<"status">> := Status} -> #{<<"status">> := StatusBin} ->
case logic_report:update_report_status(AdminId, ReportId, Status) of % Преобразуем бинарный статус в атом
StatusAtom = try binary_to_existing_atom(StatusBin, utf8)
catch _:_ -> StatusBin
end,
case logic_report:update_report_status(AdminId, ReportId, StatusAtom) of
{ok, Report} -> {ok, Report} ->
% Аудит изменения статуса жалобы % Аудит изменения статуса жалобы
admin_utils:log_admin_action(AdminId, <<"update_report_status">>, <<"report">>, ReportId, Req2), admin_utils:log_admin_action(AdminId, <<"update_report_status">>, <<"report">>, ReportId, Req2),
@@ -1,8 +1,3 @@
%%%-------------------------------------------------------------------
%%% @doc Административный обработчик списка жалоб.
%%% GET – список с пагинацией, фильтрацией и сортировкой.
%%% @end
%%%-------------------------------------------------------------------
-module(admin_handler_reports). -module(admin_handler_reports).
-behaviour(cowboy_handler). -behaviour(cowboy_handler).
-export([init/2]). -export([init/2]).
+6 -6
View File
@@ -5,7 +5,6 @@
-include("records.hrl"). -include("records.hrl").
%%% cowboy_handler callback
-spec init(cowboy_req:req(), any()) -> {ok, cowboy_req:req(), any()}. -spec init(cowboy_req:req(), any()) -> {ok, cowboy_req:req(), any()}.
init(Req, _Opts) -> init(Req, _Opts) ->
case cowboy_req:method(Req) of case cowboy_req:method(Req) of
@@ -84,12 +83,13 @@ ticket_update_schema() ->
list_tickets(Req) -> list_tickets(Req) ->
case handler_utils:auth_admin(Req) of case handler_utils:auth_admin(Req) of
{ok, AdminId, Req1} -> {ok, AdminId, Req1} ->
Qs = cowboy_req:parse_qs(Req1),
Filters = parse_ticket_filters(Req1), Filters = parse_ticket_filters(Req1),
Pagination0 = handler_utils:parse_pagination_params(Req1), Pagination0 = handler_utils:parse_pagination_params(Req1),
Pagination = Pagination0#{ % Явно извлекаем sort и order с нормализацией регистра
sort => maps:get(sort, Pagination0, <<"first_seen">>), SortBy = proplists:get_value(<<"sort">>, Qs, <<"first_seen">>),
order => maps:get(order, Pagination0, <<"desc">>) SortOrder = proplists:get_value(<<"order">>, Qs, <<"desc">>),
}, Pagination = Pagination0#{sort => SortBy, order => SortOrder},
TicketsResult = case maps:get(status, Filters, undefined) of TicketsResult = case maps:get(status, Filters, undefined) of
undefined -> logic_ticket:list_tickets(AdminId); undefined -> logic_ticket:list_tickets(AdminId);
StatusBin -> StatusBin ->
@@ -173,7 +173,7 @@ sort_tickets(Tickets, #{sort := Sort, order := Order}) ->
ticket_field(#ticket{first_seen = V}, first_seen) -> V; ticket_field(#ticket{first_seen = V}, first_seen) -> V;
ticket_field(#ticket{last_seen = V}, last_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. ticket_field(_, _) -> undefined.
apply_ticket_changes(AdminId, TicketId, Data) -> apply_ticket_changes(AdminId, TicketId, Data) ->
+1 -1
View File
@@ -162,7 +162,7 @@ cancel_booking(Req) ->
case handler_utils:auth_user(Req) of case handler_utils:auth_user(Req) of
{ok, UserId, Req1} -> {ok, UserId, Req1} ->
BookingId = cowboy_req:binding(id, 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} -> {ok, Booking} ->
handler_utils:send_json(Req1, 200, booking_to_json(Booking)); handler_utils:send_json(Req1, 200, booking_to_json(Booking));
{error, access_denied} -> {error, access_denied} ->
+153 -156
View File
@@ -1,191 +1,188 @@
-module(logic_booking). -module(logic_booking).
-include("records.hrl"). -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]). %%% @doc Создание бронирования со статусом `pending`.
-export([auto_confirm/1, check_timeout_confirmations/0]). %%% @end
%%%-------------------------------------------------------------------
%% Создание бронирования (запись на событие) -spec create_booking(UserId :: binary(), EventId :: binary()) ->
{ok, #booking{}} | {error, full | already_booked | not_found}.
create_booking(UserId, EventId) -> create_booking(UserId, EventId) ->
% Получаем событие напрямую, без проверки доступа к календарю
case core_event:get_by_id(EventId) of case core_event:get_by_id(EventId) of
{ok, Event} -> {ok, Event} ->
% Проверяем, что событие активно case check_capacity(EventId, Event#event.capacity) of
case Event#event.status of {ok, _} ->
active -> case core_booking:get_by_event_and_user(EventId, UserId) of
% Проверяем календарь для политики подтверждения {error, not_found} ->
case core_calendar:get_by_id(Event#event.calendar_id) of core_booking:create(EventId, UserId, pending);
{ok, Calendar} -> {ok, _} ->
case Calendar#calendar.status of {error, already_booked}
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}
end; end;
_ -> {error, full} ->
{error, event_not_active} {error, full}
end; end;
Error -> {error, not_found} ->
Error {error, not_found}
end. 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 case core_booking:get_by_id(BookingId) of
{ok, Booking} -> {ok, Booking} ->
% Проверяем права на событие case Booking#booking.status of
case logic_event:get_event(UserId, Booking#booking.event_id) of pending ->
{ok, Event} -> Now = calendar:universal_time(),
% Проверяем, что пользователь может редактировать календарь core_booking:update(BookingId, [{status, confirmed}, {confirmed_at, Now}]);
case logic_calendar:get_calendar(UserId, Event#event.calendar_id) of _ ->
{ok, Calendar} -> {error, access_denied}
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
end; end;
Error -> Error Error -> Error
end. 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 case core_booking:get_by_id(BookingId) of
{ok, Booking} -> {ok, Booking} ->
% Проверяем, что это бронирование пользователя
case Booking#booking.user_id =:= UserId of case Booking#booking.user_id =:= UserId of
true -> true -> {ok, Booking};
core_booking:update_status(BookingId, cancelled); false -> {error, access_denied}
false ->
{error, access_denied}
end; end;
Error -> Error -> Error
Error
end. end.
%% Получение бронирования %%%-------------------------------------------------------------------
get_booking(UserId, BookingId) -> %%% @doc Список бронирований события.
case core_booking:get_by_id(BookingId) of %%% @end
{ok, Booking} -> %%%-------------------------------------------------------------------
% Проверяем доступ к событию -spec list_bookings(EventId :: binary(), UserId :: binary()) ->
case logic_event:get_event(UserId, Booking#booking.event_id) of {ok, [#booking{}]}.
{ok, _} -> {ok, Booking}; list_bookings(EventId, UserId) ->
Error -> Error {ok, Bookings} = core_booking:list_by_event(EventId),
end; Filtered = case admin_utils:is_admin(UserId) of
Error -> true -> Bookings;
Error false -> [B || B <- Bookings, B#booking.user_id =:= UserId]
end. end,
{ok, Filtered}.
%% Список бронирований события (для владельца) %%%-------------------------------------------------------------------
list_event_bookings(UserId, EventId) -> %%% @doc Список бронирований пользователя.
case logic_event:get_event(UserId, EventId) of %%% @end
{ok, Event} -> %%%-------------------------------------------------------------------
% Проверяем права на календарь -spec list_user_bookings(UserId :: binary()) -> {ok, [#booking{}]}.
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.
%% Список бронирований пользователя
list_user_bookings(UserId) -> list_user_bookings(UserId) ->
core_booking:list_by_user(UserId). core_booking:list_by_user(UserId).
%% Автоматическое подтверждение (для политики auto) %%%-------------------------------------------------------------------
auto_confirm(BookingId) -> %%% @doc Удаление бронирования (только владелец).
core_booking:update_status(BookingId, confirmed). %%% @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() -> %%% @doc Административное получение бронирования.
% Получаем все pending бронирования для календарей с timeout %%% @end
% В реальной реализации нужно периодически вызывать эту функцию %%%-------------------------------------------------------------------
ok. -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) -> %%% @doc Список бронирований события (административный).
{ok, unlimited}; %%% @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) -> check_capacity(EventId, Capacity) ->
{ok, Bookings} = core_booking:list_by_event(EventId), {ok, Bookings} = core_booking:list_by_event(EventId),
ConfirmedCount = length([B || B <- Bookings, B#booking.status =:= confirmed]), ConfirmedCount = length([B || B <- Bookings, B#booking.status =:= confirmed]),
case ConfirmedCount < Capacity of case ConfirmedCount < Capacity of
true -> {ok, Capacity - ConfirmedCount}; true -> {ok, Capacity - ConfirmedCount};
false -> {error, full} 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. end.
+46 -36
View File
@@ -1,47 +1,57 @@
-module(logic_report). -module(logic_report).
-include("records.hrl"). -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) -> list_reports(AdminId) ->
case admin_utils:is_admin(AdminId) of case admin_utils:is_admin(AdminId) of
true -> true ->
{ok, Reports} = core_report:list_all(), Reports = core_report:list_all(), % возвращает список
{ok, Reports}; {ok, Reports}; % упаковываем в кортеж
false -> {error, access_denied} false ->
{error, access_denied}
end. end.
%% Получить конкретную жалобу по ID (только для админов) %%%-------------------------------------------------------------------
-spec get_report(binary(), binary()) -> {ok, #report{}} | {error, not_found | access_denied}. %%% @doc Обновить статус жалобы.
get_report(AdminId, ReportId) -> %%% @end
case admin_utils:is_admin(AdminId) of %%%-------------------------------------------------------------------
true -> core_report:get_by_id(ReportId); -spec update_report_status(AdminId :: binary(), ReportId :: binary(), NewStatus :: atom()) ->
false -> {error, access_denied} {ok, #report{}} | {error, not_found | access_denied}.
end.
%% Обновить статус жалобы (только для админов)
-spec update_report_status(binary(), binary(), binary()) -> {ok, #report{}} | {error, not_found | access_denied | invalid_status}.
update_report_status(AdminId, ReportId, NewStatus) -> update_report_status(AdminId, ReportId, NewStatus) ->
case admin_utils:is_admin(AdminId) of case admin_utils:is_admin(AdminId) of
true -> true ->
StatusAtom = case NewStatus of core_report:update_status(ReportId, NewStatus, AdminId);
<<"reviewed">> -> reviewed; false ->
<<"dismissed">> -> dismissed; {error, access_denied}
_ -> 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}
end. end.
+104 -44
View File
@@ -1,62 +1,103 @@
%%%-------------------------------------------------------------------
%%% @doc Модуль поиска событий и календарей.
%%%
%%% Предоставляет публичную функцию `search/4`, которая выполняет
%%% фильтрацию, сортировку и пагинацию доступных пользователю сущностей.
%%% Поддерживается полнотекстовый поиск, фильтры по тегам, датам и
%%% географическому положению.
%%% @end
%%%-------------------------------------------------------------------
-module(logic_search). -module(logic_search).
-include("records.hrl"). -include("records.hrl").
-export([search/4]). -export([search/4]).
%% ─────────────────────────────────────────────────────────────────
%% Константы
%% ─────────────────────────────────────────────────────────────────
-define(DEFAULT_LIMIT, 20). -define(DEFAULT_LIMIT, 20).
-define(MAX_LIMIT, 100). -define(MAX_LIMIT, 100).
-define(EARTH_RADIUS_KM, 6371.0). -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) -> search(Type, Query, UserId, Params) ->
Limit = min(maps:get(limit, Params, ?DEFAULT_LIMIT), ?MAX_LIMIT), Limit = min(maps:get(limit, Params, ?DEFAULT_LIMIT), ?MAX_LIMIT),
Offset = maps:get(offset, Params, 0), Offset = maps:get(offset, Params, 0),
case Type of case Type of
<<"event">> -> <<"event">> ->
{ok, Total, Events} = search_events(Query, UserId, Params, Limit, Offset), {ok, Total, Events} = search_events(Query, UserId, Params, Limit, Offset),
{ok, Total, #{<<"events">> => Events}}; {ok, Total, #{<<"events">> => Events}};
<<"calendar">> -> <<"calendar">> ->
{ok, Total, Calendars} = search_calendars(Query, UserId, Params, Limit, Offset), {ok, Total, Calendars} = search_calendars(Query, UserId, Params, Limit, Offset),
{ok, Total, #{<<"calendars">> => Calendars}}; {ok, Total, #{<<"calendars">> => Calendars}};
_ -> _ ->
{ok, EventsTotal, Events} = search_events(Query, UserId, Params, Limit, Offset), {ok, EventsTotal, Events} = search_events(Query, UserId, Params, Limit, Offset),
{ok, CalendarsTotal, Calendars} = search_calendars(Query, UserId, Params, Limit, Offset), {ok, CalendarsTotal, Calendars} = search_calendars(Query, UserId, Params, Limit, Offset),
{ok, EventsTotal + CalendarsTotal, #{ {ok, EventsTotal + CalendarsTotal, #{
<<"events">> => Events, <<"events">> => Events,
<<"calendars">> => Calendars <<"calendars">> => Calendars
}} }}
end. 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) -> search_events(Query, UserId, Params, Limit, Offset) ->
AllEvents = get_all_events(), AllEvents = get_all_events(),
AccessibleEvents = filter_accessible_events(AllEvents, UserId), AccessibleEvents = filter_accessible_events(AllEvents, UserId),
Filtered = apply_event_filters(AccessibleEvents, Query, Params), Filtered = apply_event_filters(AccessibleEvents, Query, Params),
Sorted = sort_events(Filtered, Params), Sorted = sort_events(Filtered, Params),
Paginated = paginate(Sorted, Limit, Offset), Paginated = paginate(Sorted, Limit, Offset),
{ok, length(Filtered), format_events(Paginated)}. {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) -> search_calendars(Query, UserId, Params, Limit, Offset) ->
AllCalendars = get_all_calendars(), AllCalendars = get_all_calendars(),
AccessibleCalendars = filter_accessible_calendars(AllCalendars, UserId), AccessibleCalendars = filter_accessible_calendars(AllCalendars, UserId),
Filtered = apply_calendar_filters(AccessibleCalendars, Query, Params), Filtered = apply_calendar_filters(AccessibleCalendars, Query, Params),
Paginated = paginate(Filtered, Limit, Offset), Paginated = paginate(Filtered, Limit, Offset),
{ok, length(Filtered), format_calendars(Paginated)}. {ok, length(Filtered), format_calendars(Paginated)}.
%% ============ Получение данных ============ %% ============ Получение данных ============
-spec get_all_events() -> [#event{}].
get_all_events() -> get_all_events() ->
Match = #event{status = active, is_instance = false, _ = '_'}, Match = #event{status = active, is_instance = false, _ = '_'},
mnesia:dirty_match_object(Match). mnesia:dirty_match_object(Match).
-spec get_all_calendars() -> [#calendar{}].
get_all_calendars() -> get_all_calendars() ->
Match = #calendar{status = active, _ = '_'}, Match = #calendar{status = active, _ = '_'},
mnesia:dirty_match_object(Match). mnesia:dirty_match_object(Match).
%% ============ Фильтрация по доступности ============ %% ============ Фильтрация по доступности ============
-spec filter_accessible_events([#event{}], binary()) -> [#event{}].
filter_accessible_events(Events, UserId) -> filter_accessible_events(Events, UserId) ->
lists:filter(fun(Event) -> lists:filter(fun(Event) ->
case core_calendar:get_by_id(Event#event.calendar_id) of case core_calendar:get_by_id(Event#event.calendar_id) of
@@ -74,33 +115,39 @@ filter_accessible_events(Events, UserId) ->
end end
end, Events). end, Events).
-spec filter_accessible_calendars([#calendar{}], binary()) -> [#calendar{}].
filter_accessible_calendars(Calendars, UserId) -> filter_accessible_calendars(Calendars, UserId) ->
lists:filter(fun(Calendar) -> lists:filter(fun(Calendar) -> logic_calendar:can_access(UserId, Calendar) end, Calendars).
logic_calendar:can_access(UserId, Calendar)
end, Calendars).
%% ============ Применение фильтров ============ %% ============ Применение фильтров ============
-spec apply_event_filters([#event{}], binary() | undefined, map()) -> [#event{}].
apply_event_filters(Events, Query, Params) -> apply_event_filters(Events, Query, Params) ->
Events1 = filter_by_text(Events, Query), Events1 = filter_by_text(Events, Query),
Events2 = filter_by_tags(Events1, Params), Events2 = filter_by_tags(Events1, Params),
Events3 = filter_by_date_range(Events2, Params), Events3 = filter_by_date_range(Events2, Params),
filter_by_location(Events3, Params). filter_by_location(Events3, Params).
-spec apply_calendar_filters([#calendar{}], binary() | undefined, map()) -> [#calendar{}].
apply_calendar_filters(Calendars, Query, Params) -> apply_calendar_filters(Calendars, Query, Params) ->
Calendars1 = filter_by_text(Calendars, Query), Calendars1 = filter_by_text(Calendars, Query),
filter_by_tags(Calendars1, Params). 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, undefined) -> Items;
filter_by_text(Items, <<>>) -> Items; filter_by_text(Items, <<>>) -> Items;
filter_by_text(Items, Query) -> filter_by_text(Items, Query) ->
QueryLower = string:lowercase(Query), QueryLower = string:lowercase(binary_to_list(Query)),
lists:filter(fun(Item) -> lists:filter(fun(Item) ->
Title = get_title(Item), Title = binary_to_list(get_title(Item)),
Description = get_description(Item), Description = binary_to_list(get_description(Item)),
string:find(string:lowercase(Title), QueryLower) =/= nomatch orelse string:find(string:lowercase(Title), QueryLower) =/= nomatch orelse
string:find(string:lowercase(Description), QueryLower) =/= nomatch string:find(string:lowercase(Description), QueryLower) =/= nomatch
end, Items). end, Items).
%% --- Фильтр по тегам ---
-spec filter_by_tags([#event{} | #calendar{}], map()) -> [#event{} | #calendar{}].
filter_by_tags(Items, Params) -> filter_by_tags(Items, Params) ->
case maps:get(tags, Params, undefined) of case maps:get(tags, Params, undefined) of
undefined -> Items; undefined -> Items;
@@ -112,10 +159,11 @@ filter_by_tags(Items, Params) ->
end, Items) end, Items)
end. end.
%% --- Фильтр по дате ---
-spec filter_by_date_range([#event{}], map()) -> [#event{}].
filter_by_date_range(Events, Params) -> filter_by_date_range(Events, Params) ->
From = maps:get(from, Params, undefined), From = maps:get(from, Params, undefined),
To = maps:get(to, Params, undefined), To = maps:get(to, Params, undefined),
case {From, To} of case {From, To} of
{undefined, undefined} -> Events; {undefined, undefined} -> Events;
_ -> _ ->
@@ -126,6 +174,8 @@ filter_by_date_range(Events, Params) ->
end, Events) end, Events)
end. end.
%% --- Гео-фильтр ---
-spec filter_by_location([#event{}], map()) -> [#event{}].
filter_by_location(Events, Params) -> filter_by_location(Events, Params) ->
case {maps:get(lat, Params, undefined), maps:get(lon, Params, undefined)} of case {maps:get(lat, Params, undefined), maps:get(lon, Params, undefined)} of
{undefined, _} -> Events; {undefined, _} -> Events;
@@ -134,76 +184,84 @@ filter_by_location(Events, Params) ->
Radius = maps:get(radius, Params, 10), Radius = maps:get(radius, Params, 10),
lists:filter(fun(Event) -> lists:filter(fun(Event) ->
case Event#event.location of case Event#event.location of
undefined -> false; #location{lat = EventLat, lon = EventLon}
#location{lat = EventLat, lon = EventLon} -> when is_number(EventLat), is_number(EventLon) ->
distance(Lat, Lon, EventLat, EventLon) =< Radius distance(Lat, Lon, EventLat, EventLon) =< Radius;
_ -> false
end end
end, Events) end, Events)
end. end.
%% ============ Вспомогательные функции ============ %% ============ Вспомогательные функции ============
-spec get_title(#event{} | #calendar{}) -> binary().
get_title(#event{title = Title}) -> Title; get_title(#event{title = Title}) -> Title;
get_title(#calendar{title = Title}) -> Title. get_title(#calendar{title = Title}) -> Title.
-spec get_description(#event{} | #calendar{}) -> binary().
get_description(#event{description = Desc}) -> Desc; get_description(#event{description = Desc}) -> Desc;
get_description(#calendar{description = Desc}) -> Desc. get_description(#calendar{description = Desc}) -> Desc.
-spec get_tags(#event{} | #calendar{}) -> [binary()].
get_tags(#event{tags = Tags}) -> Tags; get_tags(#event{tags = Tags}) -> Tags;
get_tags(#calendar{tags = Tags}) -> Tags. get_tags(#calendar{tags = Tags}) -> Tags.
-spec has_any_tag([binary()], [string()]) -> boolean().
has_any_tag(ItemTags, SearchTags) -> has_any_tag(ItemTags, SearchTags) ->
lists:any(fun(Tag) -> lists:member(Tag, ItemTags) end, SearchTags). lists:any(fun(Tag) -> lists:member(Tag, ItemTags) end, SearchTags).
%% ============ Гео-вычисления ============ %% ============ Гео-вычисления ============
%% @doc Вычисляет расстояние между двумя географическими точками по формуле Хаверсина.
-spec distance(number(), number(), number(), number()) -> float().
distance(Lat1, Lon1, Lat2, Lon2) -> distance(Lat1, Lon1, Lat2, Lon2) ->
DLat = deg_to_rad(Lat2 - Lat1), DLat = deg_to_rad(Lat2 - Lat1),
DLon = deg_to_rad(Lon2 - Lon1), DLon = deg_to_rad(Lon2 - Lon1),
A = math:sin(DLat / 2) * math:sin(DLat / 2) + A = math:sin(DLat / 2) * math:sin(DLat / 2) +
math:cos(deg_to_rad(Lat1)) * math:cos(deg_to_rad(Lat2)) * math:cos(deg_to_rad(Lat1)) * math:cos(deg_to_rad(Lat2)) *
math:sin(DLon / 2) * math:sin(DLon / 2), math:sin(DLon / 2) * math:sin(DLon / 2),
C = 2 * math:atan2(math:sqrt(A), math:sqrt(1 - A)), C = 2 * math:atan2(math:sqrt(A), math:sqrt(1 - A)),
?EARTH_RADIUS_KM * C. ?EARTH_RADIUS_KM * C.
-spec deg_to_rad(number()) -> float().
deg_to_rad(Deg) -> Deg * math:pi() / 180. deg_to_rad(Deg) -> Deg * math:pi() / 180.
%% ============ Сортировка ============ %% ============ Сортировка ============
-spec sort_events([#event{}], map()) -> [#event{}].
sort_events(Events, Params) -> sort_events(Events, Params) ->
SortBy = maps:get(sort, Params, <<"start_time">>), SortBy = maps:get(sort, Params, <<"start_time">>),
Order = maps:get(order, Params, <<"asc">>), Order = maps:get(order, Params, <<"asc">>),
Sorted = case SortBy of Sorted = case SortBy of
<<"start_time">> -> <<"start_time">> -> lists:sort(fun(A, B) -> A#event.start_time =< B#event.start_time end, Events);
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);
<<"rating">> -> <<"created_at">> -> lists:sort(fun(A, B) -> A#event.created_at =< B#event.created_at end, Events);
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 _ -> Events
end, end,
case Order of case Order of
<<"desc">> -> lists:reverse(Sorted); <<"desc">> -> lists:reverse(Sorted);
_ -> Sorted _ -> Sorted
end. end.
%% ============ Пагинация ============ %% ============ Пагинация ============
-spec paginate([term()], non_neg_integer(), non_neg_integer()) -> [term()].
paginate(List, Limit, Offset) -> paginate(List, Limit, Offset) ->
lists:sublist(List, Offset + 1, Limit). lists:sublist(List, Offset + 1, Limit).
%% ============ Форматирование ============ %% ============ Форматирование ответа ============
-spec format_events([#event{}]) -> [map()].
format_events(Events) -> format_events(Events) ->
lists:map(fun format_event/1, Events). lists:map(fun format_event/1, Events).
-spec format_event(#event{}) -> map().
format_event(Event) -> format_event(Event) ->
Location = case Event#event.location of Location = case Event#event.location of
undefined -> null; undefined -> null;
#location{address = Addr, lat = Lat, lon = Lon} -> #location{address = Addr, lat = Lat, lon = Lon} ->
#{address => Addr, lat => Lat, lon => Lon} #{address => Addr, lat => Lat, lon => Lon}
end, end,
#{ #{
id => Event#event.id, id => Event#event.id,
calendar_id => Event#event.calendar_id, calendar_id => Event#event.calendar_id,
@@ -220,9 +278,11 @@ format_event(Event) ->
status => Event#event.status status => Event#event.status
}. }.
-spec format_calendars([#calendar{}]) -> [map()].
format_calendars(Calendars) -> format_calendars(Calendars) ->
lists:map(fun format_calendar/1, Calendars). lists:map(fun format_calendar/1, Calendars).
-spec format_calendar(#calendar{}) -> map().
format_calendar(Calendar) -> format_calendar(Calendar) ->
#{ #{
id => Calendar#calendar.id, id => Calendar#calendar.id,
@@ -236,6 +296,6 @@ format_calendar(Calendar) ->
status => Calendar#calendar.status status => Calendar#calendar.status
}. }.
-spec datetime_to_iso8601(calendar:datetime()) -> binary().
datetime_to_iso8601({{Y, M, D}, {H, Min, S}}) -> 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", iolist_to_binary(io_lib:format("~4..0B-~2..0B-~2..0BT~2..0B:~2..0B:~2..0BZ", [Y, M, D, H, Min, S])).
[Y, M, D, H, Min, S])).
+97 -60
View File
@@ -1,17 +1,29 @@
%%%-------------------------------------------------------------------
%%% @doc Модуль сбора статистики для административной панели.
%%%
%%% Предоставляет функции `get_stats/2` и `get_stats/4`, возвращающие
%%% агрегированные показатели в зависимости от роли администратора.
%%% @end
%%%-------------------------------------------------------------------
-module(logic_stats). -module(logic_stats).
-export([get_stats/2, get_stats/4]). -export([get_stats/2, get_stats/4]).
-include("records.hrl"). -include("records.hrl").
%% ========== Точка входа (без дат) ============================= %%%-------------------------------------------------------------------
%%% @doc Возвращает статистику за текущий год.
%%% @end
%%%-------------------------------------------------------------------
-spec get_stats(Role :: atom(), AdminId :: binary()) -> map(). -spec get_stats(Role :: atom(), AdminId :: binary()) -> map().
get_stats(Role, AdminId) -> get_stats(Role, AdminId) ->
{{Y, _, _}, _} = calendar:universal_time(), {{Y, _, _}, _} = calendar:universal_time(),
From = {{Y, 1, 1}, {0, 0, 0}}, % начало текущего года From = {{Y, 1, 1}, {0, 0, 0}},
To = calendar:universal_time(), To = calendar:universal_time(),
get_stats(Role, AdminId, From, To). get_stats(Role, AdminId, From, To).
%% ========== Точка входа (с фильтром по датам) ================= %%%-------------------------------------------------------------------
%%% @doc Возвращает статистику за указанный период.
%%% @end
%%%-------------------------------------------------------------------
-spec get_stats(Role :: atom(), AdminId :: binary(), -spec get_stats(Role :: atom(), AdminId :: binary(),
From :: calendar:datetime(), To :: calendar:datetime()) -> map(). From :: calendar:datetime(), To :: calendar:datetime()) -> map().
get_stats(superadmin, _AdminId, From, To) -> get_stats(superadmin, _AdminId, From, To) ->
@@ -25,78 +37,103 @@ get_stats(support, AdminId, From, To) ->
get_stats(_, _, _, _) -> get_stats(_, _, _, _) ->
#{}. #{}.
%% ========== Суперадмин ========================================= %%%===================================================================
%%% Внутренние функции построения статистики
%%%===================================================================
%%%-------------------------------------------------------------------
%%% @doc Статистика для суперадмина.
%%% @end
%%%-------------------------------------------------------------------
-spec build_superadmin_stats(From :: calendar:datetime(), To :: calendar:datetime()) -> map().
build_superadmin_stats(From, To) -> build_superadmin_stats(From, To) ->
#{ #{
users_total => core_user:count_users(), users_total => core_user:count_users(),
events_total => core_event:count_events(), events_total => core_event:count_events(),
calendars_total => core_calendar:count_calendars(), calendars_total => core_calendar:count_calendars(),
reviews_total => core_review:count_reviews(), reviews_total => core_review:count_reviews(),
reports_total => core_report:count_reports_by_status(pending), reports_total => core_report:count_reports_by_status(pending),
tickets_open => core_ticket:count_tickets_by_status(open), tickets_open => core_ticket:count_tickets_by_status(open),
tickets_total => length(core_ticket:list_all()), tickets_total => length(core_ticket:list_all()),
avg_ticket_resolution_h => trunc_hours(core_ticket:avg_resolution_time()), 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)), 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)), events_by_day => date_list_to_json(core_event:count_events_by_date(From, To)),
admin_activity => collect_admin_activity() admin_activity => collect_admin_activity()
}. }.
%% ========== Админ ========================================= %%%-------------------------------------------------------------------
%%% @doc Статистика для администратора.
%%% @end
%%%-------------------------------------------------------------------
-spec build_admin_stats(From :: calendar:datetime(), To :: calendar:datetime()) -> map().
build_admin_stats(From, To) -> build_admin_stats(From, To) ->
#{ #{
users_total => core_user:count_users(), users_total => core_user:count_users(),
events_total => core_event:count_events(), events_total => core_event:count_events(),
calendars_total => core_calendar:count_calendars(), calendars_total => core_calendar:count_calendars(),
reviews_total => core_review:count_reviews(), reviews_total => core_review:count_reviews(),
reports_total => core_report:count_reports_by_status(pending), reports_total => core_report:count_reports_by_status(pending),
tickets_open => core_ticket:count_tickets_by_status(open), tickets_open => core_ticket:count_tickets_by_status(open),
tickets_total => length(core_ticket:list_all()), tickets_total => length(core_ticket:list_all()),
avg_ticket_resolution_h => trunc_hours(core_ticket:avg_resolution_time()), 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)), 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)) 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) -> build_moderator_stats(AdminId, _From, _To) ->
#{ #{
reports_reviewed => core_report:count_reports_resolved_by_admin(AdminId, reviewed), reports_reviewed => core_report:count_reports_resolved_by_admin(AdminId, reviewed),
reports_dismissed => core_report:count_reports_resolved_by_admin(AdminId, dismissed), reports_dismissed => core_report:count_reports_resolved_by_admin(AdminId, dismissed),
avg_report_resolution_h => trunc_hours(core_report:avg_resolution_time(reviewed)), avg_report_resolution_h => trunc_hours(core_report:avg_resolution_time(reviewed)),
events_moderated => 0 % заглушка, можно доработать events_moderated => 0 % заглушка, можно доработать
}. }.
%% ========== Поддержка ========================================== %%%-------------------------------------------------------------------
%%% @doc Статистика для поддержки.
%%% @end
%%%-------------------------------------------------------------------
-spec build_support_stats(AdminId :: binary(), From :: calendar:datetime(), To :: calendar:datetime()) -> map().
build_support_stats(AdminId, _From, _To) -> build_support_stats(AdminId, _From, _To) ->
#{ #{
tickets_assigned_open => core_ticket:count_tickets_by_admin(AdminId, open), tickets_assigned_open => core_ticket:count_tickets_by_admin(AdminId, open),
tickets_assigned_total => core_ticket:count_tickets_by_admin(AdminId, closed) + tickets_assigned_total => core_ticket:count_tickets_by_admin(AdminId, all),
core_ticket:count_tickets_by_admin(AdminId, in_progress), avg_ticket_resolution_h => trunc_hours(core_ticket:avg_resolution_time())
reports_pending => core_report:count_reports_by_status(pending)
}. }.
%% ========== Вспомогательные функции ============================ %%%===================================================================
%%% Вспомогательные функции
%%%===================================================================
%%%-------------------------------------------------------------------
%%% @doc Преобразует список дат в JSON-подобный формат.
%%% @end
%%%-------------------------------------------------------------------
-spec date_list_to_json([{{pos_integer(), pos_integer(), pos_integer()}, non_neg_integer()}]) -> [map()].
date_list_to_json(List) -> date_list_to_json(List) ->
[ #{<<"date">> => iso8601_date(Date), <<"count">> => Count} || {Date, Count} <- List ]. lists:map(fun({{Y, M, D}, Count}) ->
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}])),
#{ #{
admin_id => A#admin.id, <<"date">> => iolist_to_binary(io_lib:format("~4..0B-~2..0B-~2..0B", [Y, M, D])),
nickname => A#admin.nickname, <<"count">> => Count
email => A#admin.email,
role => A#admin.role,
last_login => A#admin.last_login,
actions => Actions
} }
end, Admins). 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() ->
[].