feat(archive): month snapshots on the same node, drop extra-node and HTML /view.
CI / test (push) Failing after 41m15s
CI / deploy-ift (push) Has been skipped
CI / e2e-ift (push) Has been skipped
CI / deploy-stage (push) Has been skipped
CI / e2e-stage (push) Has been skipped

Fixes EventHub/EventHubBack#76

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-17 14:59:30 +03:00
parent ef31e7fb40
commit e3f5a7ca13
25 changed files with 501 additions and 667 deletions
+10
View File
@@ -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()
-101
View File
@@ -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.
-12
View File
@@ -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}}],
['$_']}]).
-190
View File
@@ -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.
-57
View File
@@ -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, []),
["<td>", integer_to_list(D), format_events(DayEvents), "</td>"]
end, DayList),
["<html><body><table>",
"<tr>", DayCells, "</tr>",
"</table></body></html>"].
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
[] -> [];
_ ->
["<ul>",
lists:map(fun(#event{title = Title, start_time = {{_, _, _}, {H, M, _}}}) ->
["<li>", Title, " (", integer_to_list(H), ":",
io_lib:format("~2..0B", [M]), ")</li>"]
end, Events),
"</ul>"]
end.
-2
View File
@@ -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, []},
-164
View File
@@ -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.
+4
View File
@@ -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;
+40 -11
View File
@@ -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.
+21 -2
View File
@@ -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.
+4 -4
View File
@@ -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,
+2 -1
View File
@@ -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'
]).
%% ------------------------------
+47
View File
@@ -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}.
+21 -1
View File
@@ -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;
+254
View File
@@ -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.
@@ -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.
-50
View File
@@ -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",
-1
View File
@@ -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,
@@ -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").
-4
View File
@@ -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().
+5 -1
View File
@@ -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:
+2 -2
View File
@@ -55,9 +55,9 @@
</request>
<thinktime min="500" max="2000" random="true"/>
<!-- 5. GET /v1/calendars/:id/view?month=2026-06 HTML-представление -->
<!-- 5. GET /v1/calendars/:id/events месяц (hot + archive merge) -->
<request subst="true">
<http url="/v1/calendars/%%_calendar_id%%/view?month=2026-06" method="GET">
<http url="/v1/calendars/%%_calendar_id%%/events?from=2026-06-01T00:00:00Z&amp;to=2026-06-30T23:59:59Z" method="GET">
<http_header name="Authorization" value="Bearer %%_token%%"/>
</http>
</request>
+5 -2
View File
@@ -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
+43
View File
@@ -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).
+2 -1
View File
@@ -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() ->