feat(archive): month snapshots on the same node, drop extra-node and HTML /view.
Fixes EventHub/EventHubBack#76 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -404,6 +404,16 @@
|
|||||||
cpu_utilization :: float()
|
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, {
|
-record(schema_migration, {
|
||||||
version :: string(),
|
version :: string(),
|
||||||
applied_at :: calendar:datetime()
|
applied_at :: calendar:datetime()
|
||||||
|
|||||||
@@ -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.
|
|
||||||
@@ -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}}],
|
|
||||||
['$_']}]).
|
|
||||||
@@ -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.
|
|
||||||
@@ -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.
|
|
||||||
@@ -57,7 +57,6 @@ start_application() ->
|
|||||||
ok = migration_engine:ensure_applied(),
|
ok = migration_engine:ensure_applied(),
|
||||||
%% После wait + migrations: иначе backfill ловит {no_exists, user} на ещё не загруженных таблицах.
|
%% После wait + migrations: иначе backfill ловит {no_exists, user} на ещё не загруженных таблицах.
|
||||||
ok = stats_collector:subscribe(),
|
ok = stats_collector:subscribe(),
|
||||||
calendar_html_renderer:init_cache(),
|
|
||||||
application:ensure_all_started(cowboy),
|
application:ensure_all_started(cowboy),
|
||||||
start_http(), % Пользовательский API (8080)
|
start_http(), % Пользовательский API (8080)
|
||||||
start_admin_http(), % Административный API (8445)
|
start_admin_http(), % Административный API (8445)
|
||||||
@@ -127,7 +126,6 @@ start_http() ->
|
|||||||
{"/v1/share-invites/accept", handler_share_invites, []},
|
{"/v1/share-invites/accept", handler_share_invites, []},
|
||||||
{"/v1/share-invites/:id/accept", handler_share_invites, []},
|
{"/v1/share-invites/:id/accept", handler_share_invites, []},
|
||||||
{"/v1/share-invites/:id/decline", 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/calendars/:calendar_id/events", handler_events, []},
|
||||||
{"/v1/events/:id", handler_event_by_id, []},
|
{"/v1/events/:id", handler_event_by_id, []},
|
||||||
{"/v1/events/:id/occurrences", handler_event_occurrences, []},
|
{"/v1/events/:id/occurrences", handler_event_occurrences, []},
|
||||||
|
|||||||
@@ -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.
|
|
||||||
@@ -186,6 +186,8 @@ update_event(Req) ->
|
|||||||
handler_utils:send_error(Req2, 404, <<"Event not found">>);
|
handler_utils:send_error(Req2, 404, <<"Event not found">>);
|
||||||
{error, event_in_past} ->
|
{error, event_in_past} ->
|
||||||
handler_utils:send_error(Req2, 400, <<"Event cannot be in the 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, _}} ->
|
{error, {content_banned, _}} ->
|
||||||
handler_utils:send_error(Req2, 400, <<"Content contains banned words">>);
|
handler_utils:send_error(Req2, 400, <<"Content contains banned words">>);
|
||||||
{error, invalid_specialist} ->
|
{error, invalid_specialist} ->
|
||||||
@@ -215,6 +217,8 @@ delete_event(Req) ->
|
|||||||
handler_utils:send_error(Req1, 403, <<"Access denied">>);
|
handler_utils:send_error(Req1, 403, <<"Access denied">>);
|
||||||
{error, not_found} ->
|
{error, not_found} ->
|
||||||
handler_utils:send_error(Req1, 404, <<"Event not found">>);
|
handler_utils:send_error(Req1, 404, <<"Event not found">>);
|
||||||
|
{error, archived} ->
|
||||||
|
handler_utils:send_error(Req1, 409, <<"Month is archived">>);
|
||||||
{error, _} ->
|
{error, _} ->
|
||||||
handler_utils:send_error(Req1, 500, <<"Internal server error">>)
|
handler_utils:send_error(Req1, 500, <<"Internal server error">>)
|
||||||
end;
|
end;
|
||||||
|
|||||||
@@ -184,6 +184,8 @@ create_event(Req) ->
|
|||||||
handler_utils:send_error(Req2, 404, <<"Calendar not found">>);
|
handler_utils:send_error(Req2, 404, <<"Calendar not found">>);
|
||||||
{error, event_in_past} ->
|
{error, event_in_past} ->
|
||||||
handler_utils:send_error(Req2, 400, <<"Event cannot be in the 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, _}} ->
|
{error, {content_banned, _}} ->
|
||||||
handler_utils:send_error(Req2, 400, <<"Content contains banned words">>);
|
handler_utils:send_error(Req2, 400, <<"Content contains banned words">>);
|
||||||
{error, _} ->
|
{error, _} ->
|
||||||
@@ -201,6 +203,8 @@ create_event(Req) ->
|
|||||||
handler_utils:send_error(Req2, 404, <<"Calendar not found">>);
|
handler_utils:send_error(Req2, 404, <<"Calendar not found">>);
|
||||||
{error, event_in_past} ->
|
{error, event_in_past} ->
|
||||||
handler_utils:send_error(Req2, 400, <<"Event cannot be in the 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, _}} ->
|
{error, {content_banned, _}} ->
|
||||||
handler_utils:send_error(Req2, 400, <<"Content contains banned words">>);
|
handler_utils:send_error(Req2, 400, <<"Content contains banned words">>);
|
||||||
{error, _} ->
|
{error, _} ->
|
||||||
@@ -239,7 +243,10 @@ list_events(Req) ->
|
|||||||
{FromStr, ToStr} ->
|
{FromStr, ToStr} ->
|
||||||
FromDt = parse_datetime_binary(FromStr),
|
FromDt = parse_datetime_binary(FromStr),
|
||||||
ToDt = parse_datetime_binary(ToStr),
|
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,
|
end,
|
||||||
handler_utils:send_json(Req1, 200, Response);
|
handler_utils:send_json(Req1, 200, Response);
|
||||||
{error, access_denied} ->
|
{error, access_denied} ->
|
||||||
@@ -308,24 +315,36 @@ expand_recurring_events(UserId, Events, From, To) ->
|
|||||||
lists:flatmap(fun(Event) ->
|
lists:flatmap(fun(Event) ->
|
||||||
case Event#event.event_type of
|
case Event#event.event_type of
|
||||||
single ->
|
single ->
|
||||||
case is_in_range(Event#event.start_time, From, To) of
|
case logic_month_archive:month_is_closed(Event#event.start_time) of
|
||||||
true -> [handler_utils:event_to_json(Event)];
|
true -> [];
|
||||||
false -> []
|
false ->
|
||||||
|
case is_in_range(Event#event.start_time, From, To) of
|
||||||
|
true -> [handler_utils:event_to_json(Event)];
|
||||||
|
false -> []
|
||||||
|
end
|
||||||
end;
|
end;
|
||||||
recurring ->
|
recurring ->
|
||||||
case logic_event:get_occurrences(UserId, Event#event.id, To) of
|
case logic_event:get_occurrences(UserId, Event#event.id, To) of
|
||||||
{ok, Occurrences} ->
|
{ok, Occurrences} ->
|
||||||
lists:filtermap(
|
lists:filtermap(
|
||||||
fun({virtual, Occ}) ->
|
fun({virtual, Occ}) ->
|
||||||
case is_in_range(Occ, From, To) of
|
case logic_month_archive:month_is_closed(Occ) of
|
||||||
true -> {true, occurrence_to_json(Event, Occ)};
|
true -> false;
|
||||||
false -> false
|
false ->
|
||||||
|
case is_in_range(Occ, From, To) of
|
||||||
|
true -> {true, occurrence_to_json(Event, Occ)};
|
||||||
|
false -> false
|
||||||
|
end
|
||||||
end;
|
end;
|
||||||
({materialized, Instance}) ->
|
({materialized, Instance}) ->
|
||||||
case is_in_range(Instance#event.start_time, From, To) of
|
case logic_month_archive:month_is_closed(Instance#event.start_time) of
|
||||||
true -> {true, handler_utils:event_to_json(Instance)};
|
true -> false;
|
||||||
false -> false
|
false ->
|
||||||
end
|
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, Occurrences);
|
||||||
_ -> []
|
_ -> []
|
||||||
end
|
end
|
||||||
@@ -334,6 +353,16 @@ expand_recurring_events(UserId, Events, From, To) ->
|
|||||||
|
|
||||||
is_in_range(Time, From, To) -> Time >= From andalso Time =< 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) ->
|
parse_datetime_binary(Str) ->
|
||||||
{ok, Dt} = handler_utils:parse_datetime(Str),
|
{ok, Dt} = handler_utils:parse_datetime(Str),
|
||||||
Dt.
|
Dt.
|
||||||
|
|||||||
@@ -21,7 +21,8 @@
|
|||||||
review, review_vote, report, banned_word, automod_settings, automod_hit,
|
review, review_vote, report, banned_word, automod_settings, automod_hit,
|
||||||
ticket, subscription,
|
ticket, subscription,
|
||||||
admin_audit, notification, push_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]).
|
-define(DISC_TABLES, ?TABLES -- [session, verification, password_reset, admin_session, node_metric]).
|
||||||
@@ -211,6 +212,18 @@ ensure_schema_disc() ->
|
|||||||
true -> ok
|
true -> ok
|
||||||
end.
|
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) ->
|
add_local_disc_copy(Tab) ->
|
||||||
case lists:member(node(), mnesia:table_info(Tab, disc_copies)) of
|
case lists:member(node(), mnesia:table_info(Tab, disc_copies)) of
|
||||||
false ->
|
false ->
|
||||||
@@ -284,7 +297,9 @@ prune_dead_nodes() ->
|
|||||||
lists:foreach(fun(Node) ->
|
lists:foreach(fun(Node) ->
|
||||||
io:format("Removing dead node ~p from Mnesia schema...~n", [Node]),
|
io:format("Removing dead node ~p from Mnesia schema...~n", [Node]),
|
||||||
lists:foreach(fun(Tab) ->
|
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);
|
true -> catch mnesia:del_table_copy(Tab, Node);
|
||||||
false -> ok
|
false -> ok
|
||||||
end
|
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_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(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(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(session) -> [{ram_copies, [node()]}, {attributes, record_info(fields, session)}];
|
||||||
table_opts(verification) -> [{ram_copies, [node()]}, {attributes, record_info(fields, verification)}];
|
table_opts(verification) -> [{ram_copies, [node()]}, {attributes, record_info(fields, verification)}];
|
||||||
table_opts(password_reset) -> [{ram_copies, [node()]}, {attributes, record_info(fields, password_reset)}];
|
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(auth_session, subject_id),
|
||||||
mnesia:add_table_index(report, resolved_by),
|
mnesia:add_table_index(report, resolved_by),
|
||||||
mnesia:add_table_index(ticket, assigned_to),
|
mnesia:add_table_index(ticket, assigned_to),
|
||||||
|
mnesia:add_table_index(month_snapshot, calendar_id),
|
||||||
|
mnesia:add_table_index(month_snapshot, year_month),
|
||||||
ok.
|
ok.
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
%% ===================================================================
|
%% ===================================================================
|
||||||
%% EventHub – инфраструктурный супервизор (с archive_manager)
|
%% EventHub – инфраструктурный супервизор
|
||||||
%% ===================================================================
|
%% ===================================================================
|
||||||
-module(infra_sup).
|
-module(infra_sup).
|
||||||
-behaviour(supervisor).
|
-behaviour(supervisor).
|
||||||
@@ -39,12 +39,12 @@ init([]) ->
|
|||||||
start => {node_monitor, start_link, []},
|
start => {node_monitor, start_link, []},
|
||||||
restart => permanent,
|
restart => permanent,
|
||||||
type => worker},
|
type => worker},
|
||||||
#{id => archive_manager,
|
#{id => month_archive_worker,
|
||||||
start => {archive_manager, start_link, []},
|
start => {month_archive_worker, start_link, []},
|
||||||
restart => permanent,
|
restart => permanent,
|
||||||
shutdown => 5000,
|
shutdown => 5000,
|
||||||
type => worker,
|
type => worker,
|
||||||
modules => [archive_manager]},
|
modules => [month_archive_worker]},
|
||||||
#{id => migration_engine,
|
#{id => migration_engine,
|
||||||
start => {migration_engine, start_link, []},
|
start => {migration_engine, start_link, []},
|
||||||
restart => permanent,
|
restart => permanent,
|
||||||
|
|||||||
@@ -32,7 +32,8 @@
|
|||||||
'20260814193000_waitlist_entry',
|
'20260814193000_waitlist_entry',
|
||||||
'20260815200000_push_subscription',
|
'20260815200000_push_subscription',
|
||||||
'20260815220000_calendar_share_invite',
|
'20260815220000_calendar_share_invite',
|
||||||
'20260816180000_auth_session_device'
|
'20260816180000_auth_session_device',
|
||||||
|
'20260817140000_month_snapshot'
|
||||||
]).
|
]).
|
||||||
|
|
||||||
%% ------------------------------
|
%% ------------------------------
|
||||||
|
|||||||
@@ -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}.
|
||||||
@@ -19,6 +19,9 @@ create_event(UserId, CalendarId, Title, StartTime, Duration, Description) ->
|
|||||||
{ok, Calendar} ->
|
{ok, Calendar} ->
|
||||||
case logic_calendar:can_edit(UserId, Calendar) of
|
case logic_calendar:can_edit(UserId, Calendar) of
|
||||||
true ->
|
true ->
|
||||||
|
case logic_month_archive:assert_writable(StartTime) of
|
||||||
|
{error, archived} -> {error, archived};
|
||||||
|
ok ->
|
||||||
case validate_event_time(StartTime, UserId) of
|
case validate_event_time(StartTime, UserId) of
|
||||||
ok ->
|
ok ->
|
||||||
case logic_automoderation:evaluate_texts([Title, Description]) of
|
case logic_automoderation:evaluate_texts([Title, Description]) of
|
||||||
@@ -39,6 +42,7 @@ create_event(UserId, CalendarId, Title, StartTime, Duration, Description) ->
|
|||||||
end;
|
end;
|
||||||
{error, _} = Error ->
|
{error, _} = Error ->
|
||||||
Error
|
Error
|
||||||
|
end
|
||||||
end;
|
end;
|
||||||
false ->
|
false ->
|
||||||
{error, access_denied}
|
{error, access_denied}
|
||||||
@@ -56,6 +60,9 @@ create_recurring_event(UserId, CalendarId, Title, StartTime, Duration, RRule, De
|
|||||||
{ok, Calendar} ->
|
{ok, Calendar} ->
|
||||||
case logic_calendar:can_edit(UserId, Calendar) of
|
case logic_calendar:can_edit(UserId, Calendar) of
|
||||||
true ->
|
true ->
|
||||||
|
case logic_month_archive:assert_writable(StartTime) of
|
||||||
|
{error, archived} -> {error, archived};
|
||||||
|
ok ->
|
||||||
case validate_event_time(StartTime, UserId) of
|
case validate_event_time(StartTime, UserId) of
|
||||||
ok ->
|
ok ->
|
||||||
case logic_recurrence:validate_rrule(RRule) of
|
case logic_recurrence:validate_rrule(RRule) of
|
||||||
@@ -81,6 +88,7 @@ create_recurring_event(UserId, CalendarId, Title, StartTime, Duration, RRule, De
|
|||||||
end;
|
end;
|
||||||
{error, _} = Error ->
|
{error, _} = Error ->
|
||||||
Error
|
Error
|
||||||
|
end
|
||||||
end;
|
end;
|
||||||
false ->
|
false ->
|
||||||
{error, access_denied}
|
{error, access_denied}
|
||||||
@@ -202,6 +210,13 @@ update_event(UserId, EventId, Updates) ->
|
|||||||
{error, _} = E ->
|
{error, _} = E ->
|
||||||
E;
|
E;
|
||||||
ok ->
|
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),
|
ValidUpdates = validate_updates(Updates, UserId),
|
||||||
Title = proplists:get_value(title, ValidUpdates, Event#event.title),
|
Title = proplists:get_value(title, ValidUpdates, Event#event.title),
|
||||||
Desc = proplists:get_value(description, ValidUpdates, Event#event.description),
|
Desc = proplists:get_value(description, ValidUpdates, Event#event.description),
|
||||||
@@ -231,6 +246,8 @@ update_event(UserId, EventId, Updates) ->
|
|||||||
Error
|
Error
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
end;
|
end;
|
||||||
false ->
|
false ->
|
||||||
{error, access_denied}
|
{error, access_denied}
|
||||||
@@ -250,7 +267,10 @@ delete_event(UserId, EventId) ->
|
|||||||
{ok, Calendar} ->
|
{ok, Calendar} ->
|
||||||
case logic_calendar:can_edit(UserId, Calendar) of
|
case logic_calendar:can_edit(UserId, Calendar) of
|
||||||
true ->
|
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 ->
|
false ->
|
||||||
{error, access_denied}
|
{error, access_denied}
|
||||||
end;
|
end;
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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": {
|
"/v1/calendars/:id": {
|
||||||
"delete": {
|
"delete": {
|
||||||
"description": "Delete calendar",
|
"description": "Delete calendar",
|
||||||
|
|||||||
@@ -83,7 +83,6 @@ user() ->
|
|||||||
handler_calendar_specialist_invites,
|
handler_calendar_specialist_invites,
|
||||||
handler_specialist_invites,
|
handler_specialist_invites,
|
||||||
handler_users_lookup,
|
handler_users_lookup,
|
||||||
handler_calendar_view,
|
|
||||||
handler_calendars,
|
handler_calendars,
|
||||||
handler_event_by_id,
|
handler_event_by_id,
|
||||||
handler_event_occurrences,
|
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").
|
|
||||||
@@ -32,7 +32,6 @@ all() ->
|
|||||||
user_test_user_me,
|
user_test_user_me,
|
||||||
user_test_calendars,
|
user_test_calendars,
|
||||||
user_test_calendar_by_id,
|
user_test_calendar_by_id,
|
||||||
user_test_calendar_view,
|
|
||||||
user_test_event_by_id,
|
user_test_event_by_id,
|
||||||
user_test_events,
|
user_test_events,
|
||||||
user_test_occurrence_cancel,
|
user_test_occurrence_cancel,
|
||||||
@@ -122,9 +121,6 @@ user_test_calendars(_Config) ->
|
|||||||
user_test_calendar_by_id(_Config) ->
|
user_test_calendar_by_id(_Config) ->
|
||||||
user_calendar_by_id_tests:test().
|
user_calendar_by_id_tests:test().
|
||||||
|
|
||||||
user_test_calendar_view(_Config) ->
|
|
||||||
user_calendar_view_tests:test().
|
|
||||||
|
|
||||||
user_test_events(_Config) ->
|
user_test_events(_Config) ->
|
||||||
user_events_tests:test().
|
user_events_tests:test().
|
||||||
|
|
||||||
|
|||||||
@@ -224,7 +224,11 @@ def do_random_action(bot):
|
|||||||
resp_cal = request("GET", f"{base}/v1/calendars", headers=headers)
|
resp_cal = request("GET", f"{base}/v1/calendars", headers=headers)
|
||||||
if resp_cal and resp_cal.status_code == 200 and resp_cal.json():
|
if resp_cal and resp_cal.status_code == 200 and resp_cal.json():
|
||||||
cal = random.choice(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:
|
elif action == 14:
|
||||||
request("POST", f"{base}/v1/refresh", json={"refresh_token": "dummy"}, headers=headers)
|
request("POST", f"{base}/v1/refresh", json={"refresh_token": "dummy"}, headers=headers)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|||||||
@@ -55,9 +55,9 @@
|
|||||||
</request>
|
</request>
|
||||||
<thinktime min="500" max="2000" random="true"/>
|
<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">
|
<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&to=2026-06-30T23:59:59Z" method="GET">
|
||||||
<http_header name="Authorization" value="Bearer %%_token%%"/>
|
<http_header name="Authorization" value="Bearer %%_token%%"/>
|
||||||
</http>
|
</http>
|
||||||
</request>
|
</request>
|
||||||
|
|||||||
@@ -99,7 +99,8 @@ ensure_indexes(Tables) when is_list(Tables) ->
|
|||||||
{push_subscription, [user_id, endpoint]},
|
{push_subscription, [user_id, endpoint]},
|
||||||
{auth_session, [family_id, subject_id]},
|
{auth_session, [family_id, subject_id]},
|
||||||
{report, [resolved_by]},
|
{report, [resolved_by]},
|
||||||
{ticket, [assigned_to]}
|
{ticket, [assigned_to]},
|
||||||
|
{month_snapshot, [calendar_id, year_month]}
|
||||||
]),
|
]),
|
||||||
ok.
|
ok.
|
||||||
|
|
||||||
@@ -204,7 +205,9 @@ table_opts(node_metric) ->
|
|||||||
[{ram_copies, [node()]}, {local_content, true},
|
[{ram_copies, [node()]}, {local_content, true},
|
||||||
{attributes, record_info(fields, node_metric)}];
|
{attributes, record_info(fields, node_metric)}];
|
||||||
table_opts(schema_migration) ->
|
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
|
%%% Domain seeds
|
||||||
|
|||||||
@@ -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).
|
||||||
@@ -18,7 +18,8 @@
|
|||||||
"20260814193000_waitlist_entry",
|
"20260814193000_waitlist_entry",
|
||||||
"20260815200000_push_subscription",
|
"20260815200000_push_subscription",
|
||||||
"20260815220000_calendar_share_invite",
|
"20260815220000_calendar_share_invite",
|
||||||
"20260816180000_auth_session_device"
|
"20260816180000_auth_session_device",
|
||||||
|
"20260817140000_month_snapshot"
|
||||||
]).
|
]).
|
||||||
|
|
||||||
setup() ->
|
setup() ->
|
||||||
|
|||||||
Reference in New Issue
Block a user