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
- [] -> [];
- _ ->
- ["",
- lists:map(fun(#event{title = Title, start_time = {{_, _, _}, {H, M, _}}}) ->
- ["- ", Title, " (", integer_to_list(H), ":",
- io_lib:format("~2..0B", [M]), ")
"]
- end, Events),
- "
"]
- 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() ->