From e3f5a7ca13684059d7941f7e428693d7c0f53dee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=90=D0=BB=D0=B5=D0=BA=D1=81=D0=B5=D0=B9=20=D0=A1=D0=B0?= =?UTF-8?q?=D0=B1=D0=B8=D0=BB=D0=B8=D0=BD?= Date: Mon, 17 Aug 2026 14:59:30 +0300 Subject: [PATCH] feat(archive): month snapshots on the same node, drop extra-node and HTML /view. Fixes EventHub/EventHubBack#76 Co-authored-by: Cursor --- include/records.hrl | 10 + src/archive/archive_controller.erl | 101 ------- src/archive/archive_fetcher.erl | 12 - src/archive/archive_manager.erl | 190 ------------- src/archive/calendar_html_renderer.erl | 57 ---- src/eventhub_app.erl | 2 - src/handlers/handler_calendar_view.erl | 164 ----------- src/handlers/handler_event_by_id.erl | 4 + src/handlers/handler_events.erl | 51 +++- src/infra/infra_mnesia.erl | 23 +- src/infra/infra_sup.erl | 8 +- src/infra/migration_engine.erl | 3 +- src/infra/month_archive_worker.erl | 47 ++++ src/logic/logic_event.erl | 22 +- src/logic/logic_month_archive.erl | 254 ++++++++++++++++++ .../20260817140000_month_snapshot.erl | 41 +++ src/swagger/client-swagger.json | 50 ---- src/swagger/eventhub_trails.erl | 1 - test/api/users/user_calendar_view_tests.erl | 61 ----- test/api_users_SUITE.erl | 4 - test/emulate_users/emulate_users.py | 6 +- test/tsung/eventhub_tsung.xml | 4 +- test/unit/eh_test_support.erl | 7 +- test/unit/logic_month_archive_tests.erl | 43 +++ test/unit/migration_engine_tests.erl | 3 +- 25 files changed, 501 insertions(+), 667 deletions(-) delete mode 100644 src/archive/archive_controller.erl delete mode 100644 src/archive/archive_fetcher.erl delete mode 100644 src/archive/archive_manager.erl delete mode 100644 src/archive/calendar_html_renderer.erl delete mode 100644 src/handlers/handler_calendar_view.erl create mode 100644 src/infra/month_archive_worker.erl create mode 100644 src/logic/logic_month_archive.erl create mode 100644 src/migrations/20260817140000_month_snapshot.erl delete mode 100644 test/api/users/user_calendar_view_tests.erl create mode 100644 test/unit/logic_month_archive_tests.erl diff --git a/include/records.hrl b/include/records.hrl index 99865d2..3a3e32f 100755 --- a/include/records.hrl +++ b/include/records.hrl @@ -404,6 +404,16 @@ cpu_utilization :: float() }). +-record(month_snapshot, { + id :: {binary(), binary()}, + calendar_id :: binary(), + year_month :: binary(), + payload :: binary(), + storage :: mnesia | file, + file_rel :: binary(), + created_at :: calendar:datetime() +}). + -record(schema_migration, { version :: string(), applied_at :: calendar:datetime() diff --git a/src/archive/archive_controller.erl b/src/archive/archive_controller.erl deleted file mode 100644 index 83d0be4..0000000 --- a/src/archive/archive_controller.erl +++ /dev/null @@ -1,101 +0,0 @@ --module(archive_controller). --compile([{nowarn_deprecated_function, [{slave, start, 3}, {slave, stop, 1}]}]). --include("records.hrl"). --export([archive_day/1]). - -archive_day(Day) -> - ArchiveNode = list_to_atom("eventhub_archive_" ++ Day ++ "@" ++ host()), - case start_archive_node(ArchiveNode) of - {ok, PeerOrSlave} -> - try - rpc:call(ArchiveNode, mnesia, create_schema, [[ArchiveNode]]), - rpc:call(ArchiveNode, mnesia, start, []), - rpc:call(ArchiveNode, code, ensure_loaded, [archive_fetcher]), - create_archive_table(ArchiveNode, event, - [id, calendar_id, start_time, end_time, event_type, - master_id, specialist_id, title, description, - attachments, edit_history, status, created_at, updated_at], - [calendar_id, start_time, event_type, master_id, - specialist_id, status]), - create_archive_table(ArchiveNode, booking, - [id, event_id, user_id, status, confirmed_at, - created_at, updated_at, notes, reminder_sent], - [event_id, user_id, status]), - create_archive_table(ArchiveNode, review, - [id, user_id, target_type, target_id, rating, comment, - status, reason, created_at, updated_at, likes, - dislikes, edited_at], []), - create_archive_table(ArchiveNode, report, - [id, reporter_id, target_type, target_id, reason, - status, created_at, resolved_at, resolved_by], []), - ok = transfer_data(ArchiveNode, Day), - io:format("Archived day ~s successfully.~n", [Day]) - after - stop_archive_node(PeerOrSlave, ArchiveNode) - end; - {error, Reason} -> - io:format("Failed to start archive node: ~p~n", [Reason]), - {error, Reason} - end. - -start_archive_node(Node) -> - case os:getenv("CLUSTER_MODE") of - "true" -> - peer:start_link(#{name => Node, host => host()}); - _ -> - CookieStr = atom_to_list(erlang:get_cookie()), - case slave:start(host(), Node, "-setcookie " ++ CookieStr) of - {ok, Slave} -> {ok, Slave}; - Error -> Error - end - end. - -stop_archive_node(PeerOrSlave, Node) -> - case os:getenv("CLUSTER_MODE") of - "true" -> peer:stop(PeerOrSlave); - _ -> slave:stop(Node) - end. - -create_archive_table(Node, Tab, Attributes, Indices) -> - Opts = [{disc_only_copies, [Node]}, - {attributes, Attributes}, - {type, set}] ++ case Indices of - [] -> []; - _ -> [{index, Indices}] - end, - rpc:call(Node, mnesia, create_table, [Tab, Opts]). - -transfer_data(ArchiveNode, Day) -> - Tables = [event, booking, review, report], - lists:foreach(fun(Tab) -> - Records = fetch_records(Tab, Day), - rpc:call(ArchiveNode, mnesia, transaction, [ - fun() -> [mnesia:write(Rec) || Rec <- Records] end - ]), - mnesia:transaction(fun() -> - [mnesia:delete({Tab, element(2, Rec)}) || Rec <- Records] - end) - end, Tables). - -fetch_records(event, Day) -> - Start = list_to_binary(Day ++ " 00:00:00"), - End = list_to_binary(Day ++ " 23:59:59"), - mnesia:dirty_select(event, [{#event{start_time = '$1', _ = '_'}, - [{'>=','$1', Start},{'=<','$1', End}], - ['$_']}]); -fetch_records(booking, Day) -> - mnesia:dirty_select(booking, [{#booking{created_at = '$1', _ = '_'}, - [{'>=','$1', Day},{'=<','$1', Day ++ " 23:59:59"}], - ['$_']}]); -fetch_records(review, Day) -> - mnesia:dirty_select(review, [{#review{created_at = '$1', _ = '_'}, - [{'>=','$1', Day},{'=<','$1', Day ++ " 23:59:59"}], - ['$_']}]); -fetch_records(report, Day) -> - mnesia:dirty_select(report, [{#report{created_at = '$1', _ = '_'}, - [{'>=','$1', Day},{'=<','$1', Day ++ " 23:59:59"}], - ['$_']}]). - -host() -> - {ok, Name} = inet:gethostname(), - Name. \ No newline at end of file diff --git a/src/archive/archive_fetcher.erl b/src/archive/archive_fetcher.erl deleted file mode 100644 index ba3adc4..0000000 --- a/src/archive/archive_fetcher.erl +++ /dev/null @@ -1,12 +0,0 @@ --module(archive_fetcher). --include("records.hrl"). - --export([fetch/3]). - -fetch(CalendarId, Year, Month) -> - Start = {{Year, Month, 1}, {0, 0, 0}}, - End = {{Year, Month, calendar:last_day_of_the_month(Year, Month)}, {23, 59, 59}}, - mnesia:dirty_select(event, [{#event{calendar_id = CalendarId, - start_time = '$1', _ = '_'}, - [{'>=', '$1', {const, Start}}, {'=<', '$1', {const, End}}], - ['$_']}]). \ No newline at end of file diff --git a/src/archive/archive_manager.erl b/src/archive/archive_manager.erl deleted file mode 100644 index f554ddf..0000000 --- a/src/archive/archive_manager.erl +++ /dev/null @@ -1,190 +0,0 @@ --module(archive_manager). --behaviour(gen_server). --compile([{nowarn_deprecated_function, [{slave, start, 3}]}]). - -%% Peer start must not run inside handle_call: peer:start_it timeout -%% exits the gen_server and cascades via infra_sup (seen under IFT load). -%% Starts are async single-flight; callers wait or get {error, _}. - --export([start_link/0, get_archive_node/1]). --export([init/1, handle_call/3, handle_cast/2, handle_info/2, - terminate/2, code_change/3]). - --define(IDLE_MS, 30000). --define(PEER_CONN_MS, 15000). - -start_link() -> - gen_server:start_link({local, ?MODULE}, ?MODULE, [], []). - -get_archive_node(Day) -> - gen_server:call(?MODULE, {get_node, Day}). - -init([]) -> - process_flag(trap_exit, true), - {ok, #{nodes => #{}, starting => #{}}}. - -handle_call({get_node, Day}, From, State) -> - Node = archive_node_name(Day), - Nodes = maps:get(nodes, State), - Starting = maps:get(starting, State), - case maps:find(Node, Nodes) of - {ok, _} -> - {reply, {ok, Node}, State#{nodes := touch_node(Nodes, Node)}}; - error -> - case is_node_alive(Node) of - true -> - {reply, {ok, Node}, State#{nodes := register_node(Nodes, Node)}}; - false -> - case maps:find(Day, Starting) of - {ok, Waiters} -> - {noreply, State#{starting := Starting#{Day => [From | Waiters]}}}; - error -> - spawn_starter(Day), - {noreply, State#{starting := Starting#{Day => [From]}}} - end - end - end; -handle_call(_Req, _From, State) -> - {reply, {error, unknown_call}, State}. - -handle_cast(_, State) -> - {noreply, State}. - -handle_info({start_done, Day, Result}, State) -> - Starting = maps:get(starting, State), - Waiters = maps:get(Day, Starting, []), - NewStarting = maps:remove(Day, Starting), - Nodes = maps:get(nodes, State), - case Result of - {ok, Node} -> - reply_all(Waiters, {ok, Node}), - {noreply, State#{ - nodes := register_node(Nodes, Node), - starting := NewStarting - }}; - {error, Reason} -> - reply_all(Waiters, {error, Reason}), - {noreply, State#{starting := NewStarting}} - end; -handle_info({release, Node}, State) -> - Nodes = maps:get(nodes, State), - case maps:find(Node, Nodes) of - {ok, #{last_access := Last, timer := _Old}} -> - Idle = erlang:convert_time_unit( - erlang:monotonic_time() - Last, native, millisecond), - if Idle >= ?IDLE_MS -> - stop_archive_node(Node), - {noreply, State#{nodes := maps:remove(Node, Nodes)}}; - true -> - {noreply, State#{nodes := touch_node(Nodes, Node)}} - end; - error -> - {noreply, State} - end; -handle_info({'EXIT', _Pid, _Reason}, State) -> - {noreply, State}; -handle_info(_, State) -> - {noreply, State}. - -terminate(_Reason, _State) -> - ok. - -code_change(_OldVsn, State, _Extra) -> - {ok, State}. - -%%%------------------------------------------------------------------- -%%% Internal -%%%------------------------------------------------------------------- - -spawn_starter(Day) -> - Parent = self(), - spawn(fun() -> - Result = - try start_archive_node(Day) of - Ok -> Ok - catch - exit:{timeout, _} -> {error, peer_timeout}; - exit:Reason -> {error, {exit, Reason}}; - error:Reason -> {error, {error, Reason}}; - throw:Reason -> {error, {throw, Reason}} - end, - Parent ! {start_done, Day, Result} - end). - -reply_all(Waiters, Reply) -> - lists:foreach(fun(From) -> gen_server:reply(From, Reply) end, Waiters). - -register_node(Nodes, Node) -> - case maps:find(Node, Nodes) of - {ok, #{timer := OldRef}} -> - _ = erlang:cancel_timer(OldRef), - ok; - error -> - ok - end, - Ref = erlang:send_after(?IDLE_MS, self(), {release, Node}), - Nodes#{Node => #{timer => Ref, last_access => erlang:monotonic_time()}}. - -touch_node(Nodes, Node) -> - register_node(Nodes, Node). - -is_node_alive(Node) -> - case net_adm:ping(Node) of - pong -> true; - pang -> false - end. - -start_archive_node(Day) -> - Node = archive_node_name(Day), - case start_archive_peer(Node) of - {ok, _} -> - case ensure_archive_node_ready(Node) of - ok -> {ok, Node}; - {error, Reason} -> {error, Reason} - end; - {error, Reason} -> - {error, Reason} - end. - -start_archive_peer(Node) -> - case os:getenv("CLUSTER_MODE") of - "true" -> - case peer:start_link(#{ - name => Node, - host => host(), - connection_timeout => ?PEER_CONN_MS - }) of - {ok, _} -> {ok, Node}; - {error, {already_started, _}} -> {ok, Node}; - Error -> Error - end; - _ -> - CookieStr = atom_to_list(erlang:get_cookie()), - case slave:start(host(), Node, "-setcookie " ++ CookieStr) of - {ok, _} -> {ok, Node}; - {error, {already_running, _}} -> {ok, Node}; - Error -> Error - end - end. - -ensure_archive_node_ready(Node) -> - case rpc:call(Node, mnesia, start, [], 5000) of - ok -> - case rpc:call(Node, code, ensure_loaded, [archive_fetcher], 5000) of - {module, archive_fetcher} -> ok; - {error, Reason} -> {error, Reason}; - {badrpc, Reason} -> {error, Reason} - end; - {badrpc, Reason} -> {error, Reason}; - Other -> {error, Other} - end. - -archive_node_name(Day) -> - list_to_atom("eventhub_archive_" ++ Day ++ "@" ++ host()). - -stop_archive_node(Node) -> - rpc:cast(Node, init, stop, []). - -host() -> - {ok, Name} = inet:gethostname(), - Name. diff --git a/src/archive/calendar_html_renderer.erl b/src/archive/calendar_html_renderer.erl deleted file mode 100644 index e3f0cb4..0000000 --- a/src/archive/calendar_html_renderer.erl +++ /dev/null @@ -1,57 +0,0 @@ --module(calendar_html_renderer). --include("records.hrl"). - --export([render_month/3, init_cache/0]). - -init_cache() -> - case ets:info(archive_html_cache) of - undefined -> - ets:new(archive_html_cache, [set, public, named_table, {keypos, 1}]); - _ -> ok - end. - -render_month(Year, Month, Events) -> - Key = {Year, Month}, - try ets:lookup(archive_html_cache, Key) of - [{Key, Html}] -> Html; - [] -> - Html = generate_html(Year, Month, Events), - ets:insert(archive_html_cache, {Key, Html}), - Html - catch - _:_ -> - generate_html(Year, Month, Events) - end. - -generate_html(Year, Month, Events) -> - DaysInMonth = calendar:last_day_of_the_month(Year, Month), - EventsByDay = group_events_by_day(Events), - DayList = lists:seq(1, DaysInMonth), - DayCells = lists:map(fun(D) -> - DayEvents = maps:get(D, EventsByDay, []), - ["", integer_to_list(D), format_events(DayEvents), ""] - end, DayList), - ["", - "", DayCells, "", - "
"]. - -group_events_by_day(Events) -> - lists:foldl(fun(Evt, Acc) -> - case Evt of - #event{start_time = {{_, _, Day}, {_, _, _}}} -> - maps:update_with(Day, fun(List) -> [Evt | List] end, [Evt], Acc); - _ -> Acc - end - end, #{}, Events). - -format_events(Events) -> - case Events of - [] -> []; - _ -> - [""] - end. \ No newline at end of file diff --git a/src/eventhub_app.erl b/src/eventhub_app.erl index c14c3e2..0f8df81 100755 --- a/src/eventhub_app.erl +++ b/src/eventhub_app.erl @@ -57,7 +57,6 @@ start_application() -> ok = migration_engine:ensure_applied(), %% После wait + migrations: иначе backfill ловит {no_exists, user} на ещё не загруженных таблицах. ok = stats_collector:subscribe(), - calendar_html_renderer:init_cache(), application:ensure_all_started(cowboy), start_http(), % Пользовательский API (8080) start_admin_http(), % Административный API (8445) @@ -127,7 +126,6 @@ start_http() -> {"/v1/share-invites/accept", handler_share_invites, []}, {"/v1/share-invites/:id/accept", handler_share_invites, []}, {"/v1/share-invites/:id/decline", handler_share_invites, []}, - {"/v1/calendars/:calendar_id/view", handler_calendar_view, []}, {"/v1/calendars/:calendar_id/events", handler_events, []}, {"/v1/events/:id", handler_event_by_id, []}, {"/v1/events/:id/occurrences", handler_event_occurrences, []}, diff --git a/src/handlers/handler_calendar_view.erl b/src/handlers/handler_calendar_view.erl deleted file mode 100644 index 7f667e9..0000000 --- a/src/handlers/handler_calendar_view.erl +++ /dev/null @@ -1,164 +0,0 @@ -%%%------------------------------------------------------------------- -%%% @doc Обработчик календарного представления (HTML-календарь). -%%% -%%% GET – возвращает HTML-страницу с календарём на указанный месяц. -%%% Требует параметр `month` в формате YYYY-MM. -%%% Доступно только владельцу календаря. -%%% @end -%%%------------------------------------------------------------------- --module(handler_calendar_view). --behaviour(cowboy_handler). - --export([init/2]). --export([trails/0]). - --include("records.hrl"). - --define(ARCHIVE_CALL_TIMEOUT, 8000). - -%%% cowboy_handler callback --spec init(cowboy_req:req(), any()) -> {ok, cowboy_req:req(), any()}. -init(Req, Opts) -> - handle(Req, Opts). - -%%% Swagger metadata --spec trails() -> [map()]. -trails() -> - [ - #{ - path => <<"/v1/calendars/:calendar_id/view">>, - method => <<"GET">>, - description => <<"Get calendar HTML view for a specific month">>, - tags => [<<"Calendars">>], - parameters => [ - #{ - name => <<"calendar_id">>, - in => <<"path">>, - description => <<"Calendar ID">>, - required => true, - schema => #{type => string} - }, - #{ - name => <<"month">>, - in => <<"query">>, - description => <<"Month in YYYY-MM format">>, - required => true, - schema => #{type => string, pattern => <<"^\\d{4}-\\d{2}$">>} - } - ], - responses => #{ - 200 => #{ - description => <<"HTML calendar page">>, - content => #{<<"text/html">> => #{schema => #{type => string}}} - }, - 400 => #{description => <<"Missing or invalid 'month' parameter">>}, - 401 => #{description => <<"Unauthorized">>}, - 403 => #{description => <<"Access denied">>} - } - } - ]. - -%%%=================================================================== -%%% Внутренние функции -%%%=================================================================== - -%% @private Основной обработчик запроса. --spec handle(cowboy_req:req(), any()) -> {ok, cowboy_req:req(), any()}. -handle(Req, _Opts) -> - CalendarId = cowboy_req:binding(calendar_id, Req), - case handler_utils:auth_user(Req) of - {ok, UserId, Req1} -> - case is_owner(UserId, CalendarId) of - true -> process_view(Req1, CalendarId); - false -> handler_utils:send_error(Req1, 403, <<"Access denied">>) - end; - {error, _Code, _Msg, Req1} -> - handler_utils:send_error(Req1, 401, <<"Unauthorized">>) - end. - -%% @private Проверяет, является ли пользователь владельцем календаря. --spec is_owner(binary(), binary()) -> boolean(). -is_owner(UserId, CalendarId) -> - case mnesia:dirty_read({calendar, CalendarId}) of - [#calendar{owner_id = UserId}] -> true; - _ -> false - end. - -%% @private Обрабатывает запрос на отображение календаря. --spec process_view(cowboy_req:req(), binary()) -> {ok, cowboy_req:req(), any()}. -process_view(Req, CalendarId) -> - Qs = cowboy_req:parse_qs(Req), - case lists:keyfind(<<"month">>, 1, Qs) of - {<<"month">>, MonthBin} -> - case binary:split(MonthBin, <<"-">>) of - [YearStr, MonthStr] -> - Year = binary_to_integer(YearStr), - Month = binary_to_integer(MonthStr), - Events = fetch_events(CalendarId, Year, Month), - Html = calendar_html_renderer:render_month(Year, Month, Events), - Headers = #{ - <<"content-type">> => <<"text/html">>, - <<"cache-control">> => <<"public, max-age=86400">> - }, - cowboy_req:reply(200, Headers, Html, Req), - {ok, Req, undefined}; - _ -> - handler_utils:send_error(Req, 400, <<"Invalid 'month' format. Use YYYY-MM">>) - end; - false -> - handler_utils:send_error(Req, 400, <<"Missing 'month' parameter">>) - end. - -%% @private Извлекает события для указанного месяца календаря. --spec fetch_events(binary(), integer(), integer()) -> list(#event{}). -fetch_events(CalendarId, Year, Month) -> - case is_hot(Year, Month) of - true -> fetch_hot_events(CalendarId, Year, Month); - false -> fetch_archive_events(CalendarId, Year, Month) - end. - -%% @private Определяет, является ли месяц "горячим" (в пределах 30 дней от текущей даты). --spec is_hot(integer(), integer()) -> boolean(). -is_hot(Year, Month) -> - Current = calendar:local_time(), - Target = {{Year, Month, 1}, {0, 0, 0}}, - calendar:datetime_to_gregorian_seconds(Current) - - calendar:datetime_to_gregorian_seconds(Target) < 30 * 86400. - -%% @private Извлекает "горячие" события из Mnesia. --spec fetch_hot_events(binary(), integer(), integer()) -> list(#event{}). -fetch_hot_events(CalendarId, Year, Month) -> - Start = {{Year, Month, 1}, {0, 0, 0}}, - End = {{Year, Month, calendar:last_day_of_the_month(Year, Month)}, {23, 59, 59}}, - mnesia:dirty_select(event, [ - {#event{calendar_id = CalendarId, start_time = '$1', _ = '_'}, - [{'>=', '$1', {const, Start}}, {'=<', '$1', {const, End}}], - ['$_']} - ]). - -%% @private Извлекает архивные события через RPC на архивный узел. -%% При недоступности архива возвращает события из основной Mnesia. --spec fetch_archive_events(binary(), integer(), integer()) -> list(#event{}). -fetch_archive_events(CalendarId, Year, Month) -> - DayStr = lists:flatten(io_lib:format("~4..0B~2..0B", [Year, Month])), - case safe_get_archive_node(DayStr) of - {ok, Node} -> - case rpc:call(Node, archive_fetcher, fetch, [CalendarId, Year, Month], - ?ARCHIVE_CALL_TIMEOUT) of - Events when is_list(Events) -> Events; - _ -> fetch_hot_events(CalendarId, Year, Month) - end; - {error, _} -> - fetch_hot_events(CalendarId, Year, Month) - end. - --spec safe_get_archive_node(string()) -> {ok, node()} | {error, term()}. -safe_get_archive_node(Day) -> - try gen_server:call(archive_manager, {get_node, Day}, ?ARCHIVE_CALL_TIMEOUT) of - {ok, Node} when is_atom(Node) -> {ok, Node}; - {error, Reason} -> {error, Reason}; - Other -> {error, Other} - catch - exit:{timeout, _} -> {error, timeout}; - exit:{noproc, _} -> {error, noproc} - end. \ No newline at end of file diff --git a/src/handlers/handler_event_by_id.erl b/src/handlers/handler_event_by_id.erl index 44bdaa9..0cee659 100644 --- a/src/handlers/handler_event_by_id.erl +++ b/src/handlers/handler_event_by_id.erl @@ -186,6 +186,8 @@ update_event(Req) -> handler_utils:send_error(Req2, 404, <<"Event not found">>); {error, event_in_past} -> handler_utils:send_error(Req2, 400, <<"Event cannot be in the past">>); + {error, archived} -> + handler_utils:send_error(Req2, 409, <<"Month is archived">>); {error, {content_banned, _}} -> handler_utils:send_error(Req2, 400, <<"Content contains banned words">>); {error, invalid_specialist} -> @@ -215,6 +217,8 @@ delete_event(Req) -> handler_utils:send_error(Req1, 403, <<"Access denied">>); {error, not_found} -> handler_utils:send_error(Req1, 404, <<"Event not found">>); + {error, archived} -> + handler_utils:send_error(Req1, 409, <<"Month is archived">>); {error, _} -> handler_utils:send_error(Req1, 500, <<"Internal server error">>) end; diff --git a/src/handlers/handler_events.erl b/src/handlers/handler_events.erl index 4d84dda..123cf38 100644 --- a/src/handlers/handler_events.erl +++ b/src/handlers/handler_events.erl @@ -184,6 +184,8 @@ create_event(Req) -> handler_utils:send_error(Req2, 404, <<"Calendar not found">>); {error, event_in_past} -> handler_utils:send_error(Req2, 400, <<"Event cannot be in the past">>); + {error, archived} -> + handler_utils:send_error(Req2, 409, <<"Month is archived">>); {error, {content_banned, _}} -> handler_utils:send_error(Req2, 400, <<"Content contains banned words">>); {error, _} -> @@ -201,6 +203,8 @@ create_event(Req) -> handler_utils:send_error(Req2, 404, <<"Calendar not found">>); {error, event_in_past} -> handler_utils:send_error(Req2, 400, <<"Event cannot be in the past">>); + {error, archived} -> + handler_utils:send_error(Req2, 409, <<"Month is archived">>); {error, {content_banned, _}} -> handler_utils:send_error(Req2, 400, <<"Content contains banned words">>); {error, _} -> @@ -239,7 +243,10 @@ list_events(Req) -> {FromStr, ToStr} -> FromDt = parse_datetime_binary(FromStr), ToDt = parse_datetime_binary(ToStr), - expand_recurring_events(UserId, Events, FromDt, ToDt) + HotJson = expand_recurring_events(UserId, Events, FromDt, ToDt), + SnapJson = logic_month_archive:events_json_in_range( + CalendarId, FromDt, ToDt), + merge_event_json(HotJson, SnapJson) end, handler_utils:send_json(Req1, 200, Response); {error, access_denied} -> @@ -308,24 +315,36 @@ expand_recurring_events(UserId, Events, From, To) -> lists:flatmap(fun(Event) -> case Event#event.event_type of single -> - case is_in_range(Event#event.start_time, From, To) of - true -> [handler_utils:event_to_json(Event)]; - false -> [] + case logic_month_archive:month_is_closed(Event#event.start_time) of + true -> []; + false -> + case is_in_range(Event#event.start_time, From, To) of + true -> [handler_utils:event_to_json(Event)]; + false -> [] + end end; recurring -> case logic_event:get_occurrences(UserId, Event#event.id, To) of {ok, Occurrences} -> lists:filtermap( fun({virtual, Occ}) -> - case is_in_range(Occ, From, To) of - true -> {true, occurrence_to_json(Event, Occ)}; - false -> false + case logic_month_archive:month_is_closed(Occ) of + true -> false; + false -> + case is_in_range(Occ, From, To) of + true -> {true, occurrence_to_json(Event, Occ)}; + false -> false + end end; ({materialized, Instance}) -> - case is_in_range(Instance#event.start_time, From, To) of - true -> {true, handler_utils:event_to_json(Instance)}; - false -> false - end + case logic_month_archive:month_is_closed(Instance#event.start_time) of + true -> false; + false -> + case is_in_range(Instance#event.start_time, From, To) of + true -> {true, handler_utils:event_to_json(Instance)}; + false -> false + end + end end, Occurrences); _ -> [] end @@ -334,6 +353,16 @@ expand_recurring_events(UserId, Events, From, To) -> is_in_range(Time, From, To) -> Time >= From andalso Time =< To. +merge_event_json(Hot, Snap) -> + Ids = maps:from_list( + [{maps:get(<<"id">>, M, maps:get(id, M, undefined)), true} || M <- Hot]), + Extra = [M || M <- Snap, + begin + Id = maps:get(<<"id">>, M, maps:get(id, M, undefined)), + Id =:= undefined orelse not maps:is_key(Id, Ids) + end], + Hot ++ Extra. + parse_datetime_binary(Str) -> {ok, Dt} = handler_utils:parse_datetime(Str), Dt. diff --git a/src/infra/infra_mnesia.erl b/src/infra/infra_mnesia.erl index 2458018..18ce6d5 100755 --- a/src/infra/infra_mnesia.erl +++ b/src/infra/infra_mnesia.erl @@ -21,7 +21,8 @@ review, review_vote, report, banned_word, automod_settings, automod_hit, ticket, subscription, admin_audit, notification, push_subscription, - stats_counter, stats_daily, node_metric, schema_migration + stats_counter, stats_daily, node_metric, schema_migration, + month_snapshot ]). -define(DISC_TABLES, ?TABLES -- [session, verification, password_reset, admin_session, node_metric]). @@ -211,6 +212,18 @@ ensure_schema_disc() -> true -> ok end. +add_local_disc_copy(month_snapshot) -> + case lists:member(node(), mnesia:table_info(month_snapshot, disc_only_copies) ++ + mnesia:table_info(month_snapshot, disc_copies)) of + true -> ok; + false -> + case mnesia:add_table_copy(month_snapshot, node(), disc_only_copies) of + {atomic, ok} -> ok; + {aborted, {already_exists, _}} -> ok; + {aborted, Reason} -> + io:format("Could not add disc_only copy for month_snapshot: ~p~n", [Reason]) + end + end; add_local_disc_copy(Tab) -> case lists:member(node(), mnesia:table_info(Tab, disc_copies)) of false -> @@ -284,7 +297,9 @@ prune_dead_nodes() -> lists:foreach(fun(Node) -> io:format("Removing dead node ~p from Mnesia schema...~n", [Node]), lists:foreach(fun(Tab) -> - case lists:member(Node, mnesia:table_info(Tab, disc_copies)) of + HasDisc = lists:member(Node, mnesia:table_info(Tab, disc_copies)), + HasOnly = lists:member(Node, mnesia:table_info(Tab, disc_only_copies)), + case HasDisc orelse HasOnly of true -> catch mnesia:del_table_copy(Tab, Node); false -> ok end @@ -347,6 +362,8 @@ table_opts(push_subscription) -> [{disc_copies, [node()]}, {attributes, record_i table_opts(stats_counter) -> [{disc_copies, [node()]}, {attributes, record_info(fields, stats_counter)}]; table_opts(stats_daily) -> [{disc_copies, [node()]}, {attributes, record_info(fields, stats_daily)}]; table_opts(schema_migration) -> [{disc_copies, [node()]}, {attributes, record_info(fields, schema_migration)}]; +table_opts(month_snapshot) -> + [{disc_only_copies, [node()]}, {attributes, record_info(fields, month_snapshot)}]; table_opts(session) -> [{ram_copies, [node()]}, {attributes, record_info(fields, session)}]; table_opts(verification) -> [{ram_copies, [node()]}, {attributes, record_info(fields, verification)}]; table_opts(password_reset) -> [{ram_copies, [node()]}, {attributes, record_info(fields, password_reset)}]; @@ -398,4 +415,6 @@ create_indices() -> mnesia:add_table_index(auth_session, subject_id), mnesia:add_table_index(report, resolved_by), mnesia:add_table_index(ticket, assigned_to), + mnesia:add_table_index(month_snapshot, calendar_id), + mnesia:add_table_index(month_snapshot, year_month), ok. \ No newline at end of file diff --git a/src/infra/infra_sup.erl b/src/infra/infra_sup.erl index b9f1dfa..92d4aa8 100755 --- a/src/infra/infra_sup.erl +++ b/src/infra/infra_sup.erl @@ -1,5 +1,5 @@ %% =================================================================== -%% EventHub – инфраструктурный супервизор (с archive_manager) +%% EventHub – инфраструктурный супервизор %% =================================================================== -module(infra_sup). -behaviour(supervisor). @@ -39,12 +39,12 @@ init([]) -> start => {node_monitor, start_link, []}, restart => permanent, type => worker}, - #{id => archive_manager, - start => {archive_manager, start_link, []}, + #{id => month_archive_worker, + start => {month_archive_worker, start_link, []}, restart => permanent, shutdown => 5000, type => worker, - modules => [archive_manager]}, + modules => [month_archive_worker]}, #{id => migration_engine, start => {migration_engine, start_link, []}, restart => permanent, diff --git a/src/infra/migration_engine.erl b/src/infra/migration_engine.erl index 3b80305..4b03ef7 100755 --- a/src/infra/migration_engine.erl +++ b/src/infra/migration_engine.erl @@ -32,7 +32,8 @@ '20260814193000_waitlist_entry', '20260815200000_push_subscription', '20260815220000_calendar_share_invite', - '20260816180000_auth_session_device' + '20260816180000_auth_session_device', + '20260817140000_month_snapshot' ]). %% ------------------------------ diff --git a/src/infra/month_archive_worker.erl b/src/infra/month_archive_worker.erl new file mode 100644 index 0000000..ff7ae6c --- /dev/null +++ b/src/infra/month_archive_worker.erl @@ -0,0 +1,47 @@ +%% Periodic month snapshots (Back#76). Replaces extra-node archive_manager. +-module(month_archive_worker). +-behaviour(gen_server). + +-export([start_link/0, run_once/0]). +-export([init/1, handle_call/3, handle_cast/2, handle_info/2, + terminate/2, code_change/3]). + +-define(DEFAULT_INTERVAL_MS, 3600000). + +-record(state, {interval_ms :: pos_integer(), timer_ref :: reference() | undefined}). + +start_link() -> + gen_server:start_link({local, ?MODULE}, ?MODULE, [], []). + +run_once() -> + logic_month_archive:run_once(). + +init([]) -> + Interval = application:get_env(eventhub, archive_interval_ms, ?DEFAULT_INTERVAL_MS), + Ref = erlang:send_after(15000, self(), run), + logger:info(#{what => month_archive_worker_started, interval_ms => Interval}), + {ok, #state{interval_ms = Interval, timer_ref = Ref}}. + +handle_call(run_once, _From, State) -> + {reply, logic_month_archive:run_once(), State}; +handle_call(_Msg, _From, State) -> + {reply, {error, unknown_call}, State}. + +handle_cast(_Msg, State) -> + {noreply, State}. + +handle_info(run, State) -> + _ = catch logic_month_archive:run_once(), + Ref = erlang:send_after(State#state.interval_ms, self(), run), + {noreply, State#state{timer_ref = Ref}}; +handle_info(_Info, State) -> + {noreply, State}. + +terminate(_Reason, #state{timer_ref = Ref}) -> + case Ref of + undefined -> ok; + _ -> erlang:cancel_timer(Ref), ok + end. + +code_change(_O, State, _E) -> + {ok, State}. diff --git a/src/logic/logic_event.erl b/src/logic/logic_event.erl index 113ef05..b2c3ef1 100755 --- a/src/logic/logic_event.erl +++ b/src/logic/logic_event.erl @@ -19,6 +19,9 @@ create_event(UserId, CalendarId, Title, StartTime, Duration, Description) -> {ok, Calendar} -> case logic_calendar:can_edit(UserId, Calendar) of true -> + case logic_month_archive:assert_writable(StartTime) of + {error, archived} -> {error, archived}; + ok -> case validate_event_time(StartTime, UserId) of ok -> case logic_automoderation:evaluate_texts([Title, Description]) of @@ -39,6 +42,7 @@ create_event(UserId, CalendarId, Title, StartTime, Duration, Description) -> end; {error, _} = Error -> Error + end end; false -> {error, access_denied} @@ -56,6 +60,9 @@ create_recurring_event(UserId, CalendarId, Title, StartTime, Duration, RRule, De {ok, Calendar} -> case logic_calendar:can_edit(UserId, Calendar) of true -> + case logic_month_archive:assert_writable(StartTime) of + {error, archived} -> {error, archived}; + ok -> case validate_event_time(StartTime, UserId) of ok -> case logic_recurrence:validate_rrule(RRule) of @@ -81,6 +88,7 @@ create_recurring_event(UserId, CalendarId, Title, StartTime, Duration, RRule, De end; {error, _} = Error -> Error + end end; false -> {error, access_denied} @@ -202,6 +210,13 @@ update_event(UserId, EventId, Updates) -> {error, _} = E -> E; ok -> + NewStart = proplists:get_value(start_time, Updates, Event#event.start_time), + case logic_month_archive:assert_writable(Event#event.start_time) of + {error, archived} -> {error, archived}; + ok -> + case logic_month_archive:assert_writable(NewStart) of + {error, archived} -> {error, archived}; + ok -> ValidUpdates = validate_updates(Updates, UserId), Title = proplists:get_value(title, ValidUpdates, Event#event.title), Desc = proplists:get_value(description, ValidUpdates, Event#event.description), @@ -231,6 +246,8 @@ update_event(UserId, EventId, Updates) -> Error end end + end + end end; false -> {error, access_denied} @@ -250,7 +267,10 @@ delete_event(UserId, EventId) -> {ok, Calendar} -> case logic_calendar:can_edit(UserId, Calendar) of true -> - core_event:delete(EventId); + case logic_month_archive:assert_writable(Event#event.start_time) of + {error, archived} -> {error, archived}; + ok -> core_event:delete(EventId) + end; false -> {error, access_denied} end; diff --git a/src/logic/logic_month_archive.erl b/src/logic/logic_month_archive.erl new file mode 100644 index 0000000..a389504 --- /dev/null +++ b/src/logic/logic_month_archive.erl @@ -0,0 +1,254 @@ +%%%------------------------------------------------------------------- +%%% Calendar month snapshots (Back#76). Same node, no extra BEAM. +%%% Bookings stay in hot (inbox). Events of closed months move to snapshot/file. +%%%------------------------------------------------------------------- +-module(logic_month_archive). +-include("records.hrl"). + +-export([ + grace_days/0, + warm_months/0, + month_key/2, + month_is_closed/1, + month_is_closed/2, + assert_writable/1, + events_json_in_range/3, + snapshot_month/3, + run_once/0, + run_once/1 +]). + +-define(DEFAULT_GRACE, 7). +-define(DEFAULT_WARM, 3). + +grace_days() -> env_int("ARCHIVE_GRACE_DAYS", ?DEFAULT_GRACE). +warm_months() -> env_int("ARCHIVE_WARM_MONTHS", ?DEFAULT_WARM). + +-spec month_key(integer(), integer()) -> binary(). +month_key(Year, Month) -> + iolist_to_binary(io_lib:format("~4..0B-~2..0B", [Year, Month])). + +-spec month_is_closed(calendar:datetime()) -> boolean(). +month_is_closed({{Y, M, _}, _}) -> month_is_closed(Y, M). + +-spec month_is_closed(integer(), integer()) -> boolean(). +month_is_closed(Year, Month) -> + {{CY, CM, CD}, _} = calendar:universal_time(), + {NY, NM} = next_month(Year, Month), + GraceEnd = add_days({NY, NM, 1}, grace_days()), + {CY, CM, CD} >= GraceEnd. + +-spec assert_writable(calendar:datetime()) -> ok | {error, archived}. +assert_writable(StartTime) -> + case month_is_closed(StartTime) of + true -> {error, archived}; + false -> ok + end. + +%% Snapshot JSON maps overlapping [From, To] (inclusive). Missing snapshot → []. +-spec events_json_in_range(binary(), calendar:datetime(), calendar:datetime()) -> [map()]. +events_json_in_range(CalendarId, From, To) -> + Months = months_covering(From, To), + lists:flatmap( + fun({Y, M}) -> + case month_is_closed(Y, M) of + false -> []; + true -> + lists:filter( + fun(Map) -> json_in_range(Map, From, To) end, + load_events(CalendarId, month_key(Y, M))) + end + end, Months). + +-spec snapshot_month(binary(), integer(), integer()) -> ok | {error, term()}. +snapshot_month(CalendarId, Year, Month) -> + Key = month_key(Year, Month), + Id = {CalendarId, Key}, + case mnesia:dirty_read(month_snapshot, Id) of + [#month_snapshot{}] -> + delete_hot_for_month(CalendarId, Year, Month), + maybe_spill(CalendarId, Key), + ok; + [] -> + Events = hot_events_in_month(CalendarId, Year, Month), + Payload = encode_events(Events), + Rec = #month_snapshot{ + id = Id, + calendar_id = CalendarId, + year_month = Key, + payload = Payload, + storage = mnesia, + file_rel = <<>>, + created_at = calendar:universal_time() + }, + WriteDel = fun() -> + ok = mnesia:write(Rec), + lists:foreach(fun(E) -> mnesia:delete({event, E#event.id}) end, + deletable_events(Events)), + ok + end, + case mnesia:transaction(WriteDel) of + {atomic, ok} -> + maybe_spill(CalendarId, Key), + ok; + {aborted, Reason} -> + {error, Reason} + end + end. + +-spec run_once() -> non_neg_integer(). +run_once() -> + run_once(calendar:universal_time()). + +-spec run_once(calendar:datetime()) -> non_neg_integer(). +run_once(_Now) -> + Cals = mnesia:dirty_all_keys(calendar), + lists:foldl(fun(CalId, Acc) -> Acc + snapshot_due(CalId) end, 0, Cals). + +%%%=================================================================== +snapshot_due(CalendarId) -> + Events = case catch mnesia:dirty_index_read(event, CalendarId, #event.calendar_id) of + List when is_list(List) -> List; + _ -> [] + end, + Months = lists:usort([ym(E#event.start_time) || E <- Events]), + lists:foldl( + fun({Y, M}, N) -> + case month_is_closed(Y, M) of + true -> + case snapshot_month(CalendarId, Y, M) of + ok -> N + 1; + _ -> N + end; + false -> N + end + end, 0, Months). + +ym({{Y, M, _}, _}) -> {Y, M}; +ym(_) -> {1970, 1}. + +hot_events_in_month(CalendarId, Year, Month) -> + {Start, End} = month_bounds(Year, Month), + case mnesia:dirty_index_read(event, CalendarId, #event.calendar_id) of + List when is_list(List) -> + [E || E <- List, E#event.start_time >= Start, E#event.start_time =< End]; + _ -> [] + end. + +deletable_events(Events) -> + [E || E <- Events, not keep_in_hot(E)]. + +delete_hot_for_month(CalendarId, Year, Month) -> + Events = deletable_events(hot_events_in_month(CalendarId, Year, Month)), + lists:foreach(fun(E) -> mnesia:dirty_delete({event, E#event.id}) end, Events). + +keep_in_hot(#event{event_type = recurring, is_instance = false}) -> true; +keep_in_hot(_) -> false. + +encode_events(Events) -> + Maps = [handler_utils:event_to_json(E) || E <- Events, not keep_in_hot(E)], + zlib:gzip(jsx:encode(#{<<"v">> => 1, <<"events">> => Maps})). + +load_events(CalendarId, YearMonth) -> + Id = {CalendarId, YearMonth}, + case mnesia:dirty_read(month_snapshot, Id) of + [#month_snapshot{storage = file, file_rel = Rel}] when Rel =/= <<>> -> + decode_file(Rel); + [#month_snapshot{payload = Bin}] when is_binary(Bin), Bin =/= <<>> -> + decode_bin(Bin); + _ -> [] + end. + +decode_file(Rel) -> + Path = filename:join(logic_upload:upload_dir(), binary_to_list(Rel)), + case file:read_file(Path) of + {ok, Bin} -> decode_bin(Bin); + _ -> [] + end. + +decode_bin(Bin) -> + try jsx:decode(zlib:gunzip(Bin), [return_maps]) of + #{<<"events">> := List} when is_list(List) -> List; + _ -> [] + catch + _:_ -> [] + end. + +maybe_spill(CalendarId, YearMonth) -> + Warm = warm_months(), + Id = {CalendarId, YearMonth}, + case mnesia:dirty_index_read(month_snapshot, CalendarId, #month_snapshot.calendar_id) of + List when is_list(List), length(List) > Warm -> + Sorted = lists:sort( + fun(#month_snapshot{year_month = A}, #month_snapshot{year_month = B}) -> + A =< B + end, List), + {Old, Keep} = lists:split(length(Sorted) - Warm, Sorted), + _ = Keep, + lists:foreach(fun(Rec) -> spill_one(Rec) end, Old), + ok; + _ -> + _ = Id, + ok + end. + +spill_one(#month_snapshot{storage = file}) -> ok; +spill_one(#month_snapshot{id = Id, calendar_id = Cal, year_month = YM, + payload = Payload} = Rec) -> + Rel = filename:join(["archive", binary_to_list(Cal), binary_to_list(YM) ++ ".json.gz"]), + Abs = filename:join(logic_upload:upload_dir(), Rel), + ok = filelib:ensure_dir(Abs), + case file:write_file(Abs, Payload) of + ok -> + mnesia:dirty_write(Rec#month_snapshot{ + payload = <<>>, + storage = file, + file_rel = list_to_binary(Rel) + }), + ok; + _ -> + _ = Id, + ok + end. + +json_in_range(Map, From, To) -> + case maps:get(<<"start_time">>, Map, maps:get(start_time, Map, undefined)) of + undefined -> false; + Iso when is_binary(Iso) -> + case handler_utils:parse_datetime(Iso) of + {ok, Dt} -> Dt >= From andalso Dt =< To; + _ -> false + end; + _ -> false + end. + +months_covering({{Y1, M1, _}, _}, {{Y2, M2, _}, _}) -> + months_covering1({Y1, M1}, {Y2, M2}, []). + +months_covering1(Cur, End, Acc) when Cur > End -> lists:reverse(Acc); +months_covering1({Y, M} = Cur, End, Acc) -> + months_covering1(next_month(Y, M), End, [Cur | Acc]). + +next_month(Y, 12) -> {Y + 1, 1}; +next_month(Y, M) -> {Y, M + 1}. + +month_bounds(Year, Month) -> + Last = calendar:last_day_of_the_month(Year, Month), + {{{Year, Month, 1}, {0, 0, 0}}, {{Year, Month, Last}, {23, 59, 59}}}. + +add_days({Y, M, D}, Days) -> + Greg = calendar:date_to_gregorian_days(Y, M, D) + Days, + calendar:gregorian_days_to_date(Greg). + +env_int(Name, Default) -> + case os:getenv(Name) of + false -> Default; + "" -> Default; + S -> + try list_to_integer(S) of + N when N > 0 -> N; + _ -> Default + catch + _:_ -> Default + end + end. diff --git a/src/migrations/20260817140000_month_snapshot.erl b/src/migrations/20260817140000_month_snapshot.erl new file mode 100644 index 0000000..300e713 --- /dev/null +++ b/src/migrations/20260817140000_month_snapshot.erl @@ -0,0 +1,41 @@ +%% @doc month_snapshot table (Back#76). disc_only — blobs not in RAM. +-module('20260817140000_month_snapshot'). + +-export([up/0, down/0]). + +-include("records.hrl"). + +up() -> + ensure_table(), + ensure_index(month_snapshot, calendar_id), + ensure_index(month_snapshot, year_month), + ok. + +down() -> + _ = mnesia:delete_table(month_snapshot), + ok. + +ensure_table() -> + case lists:member(month_snapshot, mnesia:system_info(tables)) of + true -> + ok; + false -> + Attrs = record_info(fields, month_snapshot), + case mnesia:create_table(month_snapshot, [ + {disc_only_copies, [node()]}, + {attributes, Attrs} + ]) of + {atomic, ok} -> ok; + {aborted, {already_exists, month_snapshot}} -> ok; + {aborted, Reason} -> error({create_table_failed, month_snapshot, Reason}) + end + end. + +ensure_index(Table, Attr) -> + case mnesia:add_table_index(Table, Attr) of + {atomic, ok} -> ok; + {aborted, {already_exists, Table, _Pos}} -> ok; + {aborted, {already_exists, Table, Attr}} -> ok; + {aborted, {already_exists, _}} -> ok; + {aborted, Reason} -> error({add_index_failed, Table, Attr, Reason}) + end. diff --git a/src/swagger/client-swagger.json b/src/swagger/client-swagger.json index ae8e412..d0cacc3 100644 --- a/src/swagger/client-swagger.json +++ b/src/swagger/client-swagger.json @@ -646,56 +646,6 @@ } } }, - "/v1/calendars/:calendar_id/view": { - "get": { - "description": "Get calendar HTML view for a specific month", - "tags": [ - "Calendars" - ], - "parameters": [ - { - "in": "path", - "name": "calendar_id", - "description": "Calendar ID", - "schema": { - "type": "string" - }, - "required": true - }, - { - "in": "query", - "name": "month", - "description": "Month in YYYY-MM format", - "schema": { - "type": "string", - "pattern": "^\\d{4}-\\d{2}$" - }, - "required": true - } - ], - "responses": { - "200": { - "description": "HTML calendar page", - "content": { - "text/html": { - "schema": { - "type": "string" - } - } - } - }, - "400": { - "description": "Missing or invalid 'month' parameter" - }, - "401": { - "description": "Unauthorized" - }, - "403": { - "description": "Access denied" - } - } - } - }, "/v1/calendars/:id": { "delete": { "description": "Delete calendar", diff --git a/src/swagger/eventhub_trails.erl b/src/swagger/eventhub_trails.erl index def1d49..bbd0d1a 100755 --- a/src/swagger/eventhub_trails.erl +++ b/src/swagger/eventhub_trails.erl @@ -83,7 +83,6 @@ user() -> handler_calendar_specialist_invites, handler_specialist_invites, handler_users_lookup, - handler_calendar_view, handler_calendars, handler_event_by_id, handler_event_occurrences, diff --git a/test/api/users/user_calendar_view_tests.erl b/test/api/users/user_calendar_view_tests.erl deleted file mode 100644 index cdbed28..0000000 --- a/test/api/users/user_calendar_view_tests.erl +++ /dev/null @@ -1,61 +0,0 @@ -%%%------------------------------------------------------------------- -%%% @doc Тесты клиентского API для HTML-представления календаря. -%%% -%%% Покрывает эндпоинты: -%%% GET /v1/calendars/:calendar_id/view -%%% -%%% Проверяет: -%%% - успешное получение HTML-страницы (200, text/html) -%%% - ошибку 401 без токена -%%% @end -%%%------------------------------------------------------------------- --module(user_calendar_view_tests). --include_lib("eunit/include/eunit.hrl"). - --export([test/0]). - -%%%=================================================================== -%%% Главная тестовая функция -%%%=================================================================== - --spec test() -> ok. -test() -> - ct:pal("=== User Calendar View Tests ==="), - Token = api_test_runner:get_user_token(), - - % Создаём календарь - CalId = api_test_runner:create_calendar(Token, #{title => <<"ViewCal">>}), - - test_get_calendar_view(Token, CalId), - test_get_calendar_view_unauthorized(CalId), - - ct:pal("=== All user calendar view tests passed ==="), - ok. - -%%%=================================================================== -%%% Тестовые функции -%%%=================================================================== - -%% @doc Успешный запрос HTML-представления: 200 OK, тип text/html. --spec test_get_calendar_view(binary(), binary()) -> ok. -test_get_calendar_view(Token, CalId) -> - ct:pal(" TEST: Get calendar HTML view"), - Path = <<"/v1/calendars/", CalId/binary, "/view?month=2026-06">>, - Resp = api_test_runner:client_request(get, Path, Token), - {ok, 200, Headers, Body} = Resp, - ?assert(lists:keymember("content-type", 1, Headers)), - {"content-type", CT} = lists:keyfind("content-type", 1, Headers), - ?assert(string:str(CT, "text/html") > 0), - % Body может быть строкой или binary, приводим к binary и проверяем непустоту - BodyBin = iolist_to_binary(Body), - ?assert(byte_size(BodyBin) > 0), - ct:pal(" OK: got HTML of ~p bytes", [byte_size(BodyBin)]). - -%% @doc Запрос без токена: 401 Unauthorized. --spec test_get_calendar_view_unauthorized(binary()) -> ok. -test_get_calendar_view_unauthorized(CalId) -> - ct:pal(" TEST: Get calendar view without token"), - Path = <<"/v1/calendars/", CalId/binary, "/view?month=2026-06">>, - Resp = api_test_runner:client_request(get, Path, <<>>), - ?assertMatch({ok, 401, _, _}, Resp), - ct:pal(" OK: got 401"). \ No newline at end of file diff --git a/test/api_users_SUITE.erl b/test/api_users_SUITE.erl index 8328c2f..964988b 100755 --- a/test/api_users_SUITE.erl +++ b/test/api_users_SUITE.erl @@ -32,7 +32,6 @@ all() -> user_test_user_me, user_test_calendars, user_test_calendar_by_id, - user_test_calendar_view, user_test_event_by_id, user_test_events, user_test_occurrence_cancel, @@ -122,9 +121,6 @@ user_test_calendars(_Config) -> user_test_calendar_by_id(_Config) -> user_calendar_by_id_tests:test(). -user_test_calendar_view(_Config) -> - user_calendar_view_tests:test(). - user_test_events(_Config) -> user_events_tests:test(). diff --git a/test/emulate_users/emulate_users.py b/test/emulate_users/emulate_users.py index 2b7011e..d71b01b 100644 --- a/test/emulate_users/emulate_users.py +++ b/test/emulate_users/emulate_users.py @@ -224,7 +224,11 @@ def do_random_action(bot): resp_cal = request("GET", f"{base}/v1/calendars", headers=headers) if resp_cal and resp_cal.status_code == 200 and resp_cal.json(): cal = random.choice(resp_cal.json()) - request("GET", f"{base}/v1/calendars/{cal['id']}/view?month=2026-06", headers=headers) + request( + "GET", + f"{base}/v1/calendars/{cal['id']}/events?from=2026-06-01T00:00:00Z&to=2026-06-30T23:59:59Z", + headers=headers, + ) elif action == 14: request("POST", f"{base}/v1/refresh", json={"refresh_token": "dummy"}, headers=headers) except Exception as e: diff --git a/test/tsung/eventhub_tsung.xml b/test/tsung/eventhub_tsung.xml index eefb5ed..f488c35 100644 --- a/test/tsung/eventhub_tsung.xml +++ b/test/tsung/eventhub_tsung.xml @@ -55,9 +55,9 @@ - + - + diff --git a/test/unit/eh_test_support.erl b/test/unit/eh_test_support.erl index ea92520..2ac51e8 100755 --- a/test/unit/eh_test_support.erl +++ b/test/unit/eh_test_support.erl @@ -99,7 +99,8 @@ ensure_indexes(Tables) when is_list(Tables) -> {push_subscription, [user_id, endpoint]}, {auth_session, [family_id, subject_id]}, {report, [resolved_by]}, - {ticket, [assigned_to]} + {ticket, [assigned_to]}, + {month_snapshot, [calendar_id, year_month]} ]), ok. @@ -204,7 +205,9 @@ table_opts(node_metric) -> [{ram_copies, [node()]}, {local_content, true}, {attributes, record_info(fields, node_metric)}]; table_opts(schema_migration) -> - [{ram_copies, [node()]}, {attributes, record_info(fields, schema_migration)}]. + [{ram_copies, [node()]}, {attributes, record_info(fields, schema_migration)}]; +table_opts(month_snapshot) -> + [{ram_copies, [node()]}, {attributes, record_info(fields, month_snapshot)}]. %%%=================================================================== %%% Domain seeds diff --git a/test/unit/logic_month_archive_tests.erl b/test/unit/logic_month_archive_tests.erl new file mode 100644 index 0000000..43aae68 --- /dev/null +++ b/test/unit/logic_month_archive_tests.erl @@ -0,0 +1,43 @@ +-module(logic_month_archive_tests). +-include_lib("eunit/include/eunit.hrl"). +-include("records.hrl"). + +-define(TABLES, [event, month_snapshot, booking, calendar]). +-define(CAL, <<"cal_arch_1">>). + +setup() -> + eh_test_support:start_mnesia(), + eh_test_support:ensure_tables(?TABLES), + Tmp = filename:join("/tmp", "eh_arch_" ++ integer_to_list(erlang:unique_integer([positive]))), + ok = filelib:ensure_dir(filename:join(Tmp, "dummy")), + true = os:putenv("UPLOAD_DIR", Tmp), + ok. + +cleanup(_) -> + eh_test_support:clear_tables(?TABLES), + eh_test_support:delete_tables(?TABLES), + eh_test_support:stop_mnesia(), + ok. + +logic_month_archive_test_() -> + {foreach, fun setup/0, fun cleanup/1, [ + {"closed month is not writable", fun test_assert_writable/0}, + {"snapshot removes hot event and serves json", fun test_snapshot_roundtrip/0} + ]}. + +test_assert_writable() -> + {{Y, M, _}, _} = calendar:universal_time(), + ?assertEqual(ok, logic_month_archive:assert_writable({{Y, M, 15}, {12, 0, 0}})), + ?assertEqual({error, archived}, + logic_month_archive:assert_writable({{2020, 1, 10}, {12, 0, 0}})). + +test_snapshot_roundtrip() -> + {ok, Ev} = core_event:create(?CAL, <<"old">>, {{2020, 6, 10}, {10, 0, 0}}, 60), + ok = logic_month_archive:snapshot_month(?CAL, 2020, 6), + ?assertEqual([], mnesia:dirty_read(event, Ev#event.id)), + Json = logic_month_archive:events_json_in_range( + ?CAL, {{2020, 6, 1}, {0, 0, 0}}, {{2020, 6, 30}, {23, 59, 59}}), + ?assertEqual(1, length(Json)), + [Map] = Json, + Id = maps:get(<<"id">>, Map, maps:get(id, Map, undefined)), + ?assertEqual(Ev#event.id, Id). diff --git a/test/unit/migration_engine_tests.erl b/test/unit/migration_engine_tests.erl index 1cd6f1f..2bd02c9 100755 --- a/test/unit/migration_engine_tests.erl +++ b/test/unit/migration_engine_tests.erl @@ -18,7 +18,8 @@ "20260814193000_waitlist_entry", "20260815200000_push_subscription", "20260815220000_calendar_share_invite", - "20260816180000_auth_session_device" + "20260816180000_auth_session_device", + "20260817140000_month_snapshot" ]). setup() ->