Implement commercial calendars: booking_open, specialists, expire without type downgrade.
CI / test (push) Successful in 6m44s
CI / deploy-ift (push) Successful in 4m2s
CI / e2e-ift (push) Successful in 1m25s
CI / deploy-stage (push) Successful in 2m25s
CI / e2e-stage (push) Successful in 1m12s

Refs EventHub/EventHubBack#54
This commit is contained in:
2026-07-22 16:12:52 +03:00
parent fdb08eb453
commit af5f506866
35 changed files with 978 additions and 207 deletions
+3
View File
@@ -112,7 +112,10 @@
}). }).
%% ------------------- Специалисты календаря --------------------------- %% ------------------- Специалисты календаря ---------------------------
%% PK = id (несколько специалистов на календарь). Уникальность пары
%% calendar_id+user_id — на уровне logic.
-record(calendar_specialist, { -record(calendar_specialist, {
id :: binary(),
calendar_id :: binary(), calendar_id :: binary(),
user_id :: binary(), % id пользователя-специалиста user_id :: binary(), % id пользователя-специалиста
name :: binary(), % отображаемое имя в этом календаре name :: binary(), % отображаемое имя в этом календаре
+111
View File
@@ -0,0 +1,111 @@
%%%-------------------------------------------------------------------
%%% @doc Хранение специалистов commercial-календаря.
%%% @end
%%%-------------------------------------------------------------------
-module(core_calendar_specialist).
-include("records.hrl").
-export([create/4, get_by_calendar_and_user/2, list_by_calendar/1,
update/3, delete/2, is_active_specialist/2]).
-spec create(CalendarId :: binary(), UserId :: binary(), Name :: binary(),
Specs :: [binary()]) ->
{ok, #calendar_specialist{}} | {error, term()}.
create(CalendarId, UserId, Name, Specs) ->
Now = calendar:universal_time(),
F = fun() ->
case find(CalendarId, UserId) of
[#calendar_specialist{}] ->
{error, already_exists};
[] ->
Rec = #calendar_specialist{
id = infra_utils:generate_id(16),
calendar_id = CalendarId,
user_id = UserId,
name = Name,
specialization = Specs,
status = active,
added_at = Now,
updated_at = Now
},
mnesia:write(Rec),
{ok, Rec}
end
end,
case mnesia:transaction(F) of
{atomic, Result} -> Result;
{aborted, Reason} -> {error, Reason}
end.
-spec get_by_calendar_and_user(CalendarId :: binary(), UserId :: binary()) ->
{ok, #calendar_specialist{}} | {error, not_found}.
get_by_calendar_and_user(CalendarId, UserId) ->
case mnesia:dirty_match_object(
#calendar_specialist{calendar_id = CalendarId, user_id = UserId, _ = '_'}) of
[Rec] -> {ok, Rec};
[] -> {error, not_found};
[Rec | _] -> {ok, Rec}
end.
-spec list_by_calendar(CalendarId :: binary()) -> [#calendar_specialist{}].
list_by_calendar(CalendarId) ->
mnesia:dirty_match_object(#calendar_specialist{calendar_id = CalendarId, _ = '_'}).
-spec update(CalendarId :: binary(), UserId :: binary(), Updates :: [{atom(), term()}]) ->
{ok, #calendar_specialist{}} | {error, not_found | term()}.
update(CalendarId, UserId, Updates) ->
F = fun() ->
case find(CalendarId, UserId) of
[] ->
{error, not_found};
[Rec] ->
Updated = apply_updates(Rec, Updates),
mnesia:write(Updated),
{ok, Updated}
end
end,
case mnesia:transaction(F) of
{atomic, Result} -> Result;
{aborted, Reason} -> {error, Reason}
end.
-spec delete(CalendarId :: binary(), UserId :: binary()) -> ok | {error, not_found | term()}.
delete(CalendarId, UserId) ->
F = fun() ->
case find(CalendarId, UserId) of
[] ->
{error, not_found};
[#calendar_specialist{id = Id}] ->
mnesia:delete({calendar_specialist, Id}),
ok
end
end,
case mnesia:transaction(F) of
{atomic, Result} -> Result;
{aborted, Reason} -> {error, Reason}
end.
-spec is_active_specialist(CalendarId :: binary(), UserId :: binary()) -> boolean().
is_active_specialist(CalendarId, UserId) ->
case get_by_calendar_and_user(CalendarId, UserId) of
{ok, #calendar_specialist{status = active}} -> true;
_ -> false
end.
%%%===================================================================
%%% Internal
%%%===================================================================
find(CalendarId, UserId) ->
mnesia:match_object(
#calendar_specialist{calendar_id = CalendarId, user_id = UserId, _ = '_'}).
apply_updates(Rec, Updates) ->
Updated = lists:foldl(fun set_field/2, Rec, Updates),
Updated#calendar_specialist{updated_at = calendar:universal_time()}.
set_field({name, V}, R) when is_binary(V) -> R#calendar_specialist{name = V};
set_field({specialization, V}, R) when is_list(V) -> R#calendar_specialist{specialization = V};
set_field({status, active}, R) -> R#calendar_specialist{status = active};
set_field({status, inactive}, R) -> R#calendar_specialist{status = inactive};
set_field(_, R) -> R.
Regular → Executable
+12 -18
View File
@@ -57,9 +57,10 @@ get_by_id(Id) ->
-spec get_active_by_user(UserId :: binary()) -> {ok, #subscription{}} | {error, not_found}. -spec get_active_by_user(UserId :: binary()) -> {ok, #subscription{}} | {error, not_found}.
get_active_by_user(UserId) -> get_active_by_user(UserId) ->
Match = #subscription{user_id = UserId, status = active, _ = '_'}, Match = #subscription{user_id = UserId, status = active, _ = '_'},
case mnesia:dirty_match_object(Match) of case catch mnesia:dirty_match_object(Match) of
{'EXIT', _} -> {error, not_found};
[] -> {error, not_found}; [] -> {error, not_found};
[Subscription] -> {ok, Subscription} [Subscription | _] -> {ok, Subscription}
end. end.
%%%------------------------------------------------------------------- %%%-------------------------------------------------------------------
@@ -69,9 +70,13 @@ get_active_by_user(UserId) ->
-spec list_by_user(UserId :: binary()) -> {ok, [#subscription{}]}. -spec list_by_user(UserId :: binary()) -> {ok, [#subscription{}]}.
list_by_user(UserId) -> list_by_user(UserId) ->
Match = #subscription{user_id = UserId, _ = '_'}, Match = #subscription{user_id = UserId, _ = '_'},
Subscriptions = mnesia:dirty_match_object(Match), case catch mnesia:dirty_match_object(Match) of
{ok, lists:sort(fun(A, B) -> A#subscription.created_at >= B#subscription.created_at end, {'EXIT', _} ->
Subscriptions)}. {ok, []};
Subscriptions when is_list(Subscriptions) ->
{ok, lists:sort(fun(A, B) -> A#subscription.created_at >= B#subscription.created_at end,
Subscriptions)}
end.
%%%------------------------------------------------------------------- %%%-------------------------------------------------------------------
%%% @doc Список всех подписок (для администраторов). %%% @doc Список всех подписок (для администраторов).
@@ -122,25 +127,14 @@ check_expired() ->
update_status(Sub#subscription.id, expired), update_status(Sub#subscription.id, expired),
case get_active_by_user(Sub#subscription.user_id) of case get_active_by_user(Sub#subscription.user_id) of
{error, not_found} -> {error, not_found} ->
downgrade_user_calendars(Sub#subscription.user_id); %% type commercial сохраняем; pending гасим (restricted mode)
logic_booking:cancel_pending_for_owner(Sub#subscription.user_id);
_ -> ok _ -> ok
end; end;
false -> ok false -> ok
end end
end, ActiveSubscriptions). end, ActiveSubscriptions).
%%%-------------------------------------------------------------------
%%% @doc Понижение календарей пользователя до personal при истечении подписки.
%%% @end
%%%-------------------------------------------------------------------
-spec downgrade_user_calendars(UserId :: binary()) -> ok.
downgrade_user_calendars(UserId) ->
Match = #calendar{owner_id = UserId, type = commercial, _ = '_'},
Calendars = mnesia:dirty_match_object(Match),
lists:foreach(fun(Cal) ->
core_calendar:update(Cal#calendar.id, [{type, personal}])
end, Calendars).
%%%------------------------------------------------------------------- %%%-------------------------------------------------------------------
%%% Вспомогательные функции %%% Вспомогательные функции
%%%------------------------------------------------------------------- %%%-------------------------------------------------------------------
+2
View File
@@ -95,6 +95,8 @@ start_http() ->
{"/v1/calendars", handler_calendars, []}, {"/v1/calendars", handler_calendars, []},
{"/v1/calendars/:id", handler_calendar_by_id, []}, {"/v1/calendars/:id", handler_calendar_by_id, []},
{"/v1/calendars/:id/follow", handler_calendar_follow, []}, {"/v1/calendars/:id/follow", handler_calendar_follow, []},
{"/v1/calendars/:id/specialists", handler_calendar_specialists, []},
{"/v1/calendars/:id/specialists/:user_id", handler_calendar_specialists, []},
{"/v1/calendars/:calendar_id/view", handler_calendar_view, []}, {"/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, []},
+8
View File
@@ -116,10 +116,18 @@ create_booking(Req) ->
handler_utils:send_json(Req1, 201, booking_to_json(Booking)); handler_utils:send_json(Req1, 201, booking_to_json(Booking));
{error, already_booked} -> {error, already_booked} ->
handler_utils:send_error(Req1, 409, <<"Already booked">>); handler_utils:send_error(Req1, 409, <<"Already booked">>);
{error, full} ->
handler_utils:send_error(Req1, 400, <<"Event is full">>);
{error, event_full} -> {error, event_full} ->
handler_utils:send_error(Req1, 400, <<"Event is full">>); handler_utils:send_error(Req1, 400, <<"Event is full">>);
{error, event_not_active} -> {error, event_not_active} ->
handler_utils:send_error(Req1, 400, <<"Event is not active">>); handler_utils:send_error(Req1, 400, <<"Event is not active">>);
{error, personal_calendar} ->
handler_utils:send_error(Req1, 403, <<"personal_calendar">>);
{error, subscription_inactive} ->
handler_utils:send_error(Req1, 403, <<"subscription_inactive">>);
{error, own_event} ->
handler_utils:send_error(Req1, 403, <<"Cannot book own event">>);
{error, access_denied} -> {error, access_denied} ->
handler_utils:send_error(Req1, 403, <<"Access denied">>); handler_utils:send_error(Req1, 403, <<"Access denied">>);
{error, not_found} -> {error, not_found} ->
+7 -1
View File
@@ -139,9 +139,13 @@ update_calendar(Req) ->
Updates = convert_calendar_fields(Updates0), Updates = convert_calendar_fields(Updates0),
case logic_calendar:update_calendar(UserId, CalendarId, Updates) of case logic_calendar:update_calendar(UserId, CalendarId, Updates) of
{ok, Calendar} -> {ok, Calendar} ->
handler_utils:send_json(Req2, 200, handler_utils:calendar_to_json(Calendar)); Json0 = handler_utils:calendar_to_json(Calendar),
Following = logic_calendar_follow:is_following(UserId, CalendarId),
handler_utils:send_json(Req2, 200, Json0#{following => Following});
{error, access_denied} -> {error, access_denied} ->
handler_utils:send_error(Req2, 403, <<"Access denied">>); handler_utils:send_error(Req2, 403, <<"Access denied">>);
{error, subscription_required} ->
handler_utils:send_error(Req2, 402, <<"Subscription required for commercial calendar">>);
{error, not_found} -> {error, not_found} ->
handler_utils:send_error(Req2, 404, <<"Calendar not found">>); handler_utils:send_error(Req2, 404, <<"Calendar not found">>);
{error, _} -> {error, _} ->
@@ -182,6 +186,8 @@ convert_calendar_fields(Updates) ->
-spec convert_field({binary(), term()}) -> {atom(), term()}. -spec convert_field({binary(), term()}) -> {atom(), term()}.
convert_field({<<"title">>, Val}) -> {title, Val}; convert_field({<<"title">>, Val}) -> {title, Val};
convert_field({<<"description">>, Val}) -> {description, Val}; convert_field({<<"description">>, Val}) -> {description, Val};
convert_field({<<"type">>, <<"personal">>}) -> {type, personal};
convert_field({<<"type">>, <<"commercial">>}) -> {type, commercial};
convert_field({<<"type">>, Val}) -> {type, Val}; convert_field({<<"type">>, Val}) -> {type, Val};
convert_field({<<"confirmation">>, <<"auto">>}) -> {confirmation, auto}; convert_field({<<"confirmation">>, <<"auto">>}) -> {confirmation, auto};
convert_field({<<"confirmation">>, <<"manual">>}) -> {confirmation, manual}; convert_field({<<"confirmation">>, <<"manual">>}) -> {confirmation, manual};
+208
View File
@@ -0,0 +1,208 @@
%%%-------------------------------------------------------------------
%%% @doc CRUD специалистов календаря.
%%%
%%% GET /v1/calendars/:id/specialists
%%% POST /v1/calendars/:id/specialists
%%% PUT /v1/calendars/:id/specialists/:user_id
%%% DELETE /v1/calendars/:id/specialists/:user_id
%%% @end
%%%-------------------------------------------------------------------
-module(handler_calendar_specialists).
-behaviour(cowboy_handler).
-export([init/2]).
-export([trails/0]).
-include("records.hrl").
-spec init(cowboy_req:req(), any()) -> {ok, cowboy_req:req(), any()}.
init(Req, Opts) ->
handle(Req, Opts).
-spec trails() -> [map()].
trails() ->
SpecSchema = #{
type => object,
properties => #{
id => #{type => string},
calendar_id => #{type => string},
user_id => #{type => string},
name => #{type => string},
specialization => #{type => array, items => #{type => string}},
status => #{type => string, enum => [<<"active">>, <<"inactive">>]}
}
},
IdParam = #{
name => <<"id">>, in => <<"path">>, required => true,
schema => #{type => string}
},
UserParam = #{
name => <<"user_id">>, in => <<"path">>, required => true,
schema => #{type => string}
},
[
#{
path => <<"/v1/calendars/:id/specialists">>,
method => <<"GET">>,
description => <<"List calendar specialists">>,
tags => [<<"Calendars">>],
parameters => [IdParam],
responses => #{
200 => #{description => <<"OK">>,
content => #{<<"application/json">> => #{schema => #{type => array, items => SpecSchema}}}}
}
},
#{
path => <<"/v1/calendars/:id/specialists">>,
method => <<"POST">>,
description => <<"Add specialist (owner, commercial)">>,
tags => [<<"Calendars">>],
parameters => [IdParam],
responses => #{201 => #{description => <<"Created">>}}
},
#{
path => <<"/v1/calendars/:id/specialists/:user_id">>,
method => <<"PUT">>,
description => <<"Update specialist">>,
tags => [<<"Calendars">>],
parameters => [IdParam, UserParam],
responses => #{200 => #{description => <<"Updated">>}}
},
#{
path => <<"/v1/calendars/:id/specialists/:user_id">>,
method => <<"DELETE">>,
description => <<"Remove specialist">>,
tags => [<<"Calendars">>],
parameters => [IdParam, UserParam],
responses => #{200 => #{description => <<"Deleted">>}}
}
].
-spec handle(cowboy_req:req(), any()) -> {ok, cowboy_req:req(), any()}.
handle(Req, _Opts) ->
Method = cowboy_req:method(Req),
case {Method, has_user_binding(Req)} of
{<<"GET">>, false} -> list_specialists(Req);
{<<"POST">>, false} -> add_specialist(Req);
{<<"PUT">>, true} -> update_specialist(Req);
{<<"DELETE">>, true} -> remove_specialist(Req);
_ ->
handler_utils:send_error(Req, 405, <<"Method not allowed">>)
end.
has_user_binding(Req) ->
cowboy_req:binding(user_id, Req) =/= undefined.
list_specialists(Req) ->
case handler_utils:auth_user(Req) of
{ok, UserId, Req1} ->
CalendarId = cowboy_req:binding(id, Req1),
case logic_calendar_specialist:list(UserId, CalendarId) of
{ok, List} ->
handler_utils:send_json(Req1, 200,
[logic_calendar_specialist:to_json(S) || S <- List]);
{error, not_found} ->
handler_utils:send_error(Req1, 404, <<"Calendar not found">>);
{error, access_denied} ->
handler_utils:send_error(Req1, 403, <<"Access denied">>);
{error, _} ->
handler_utils:send_error(Req1, 500, <<"Internal server error">>)
end;
{error, Code, Message, Req1} ->
handler_utils:send_error(Req1, Code, Message)
end.
add_specialist(Req) ->
case handler_utils:auth_user(Req) of
{ok, OwnerId, Req1} ->
CalendarId = cowboy_req:binding(id, Req1),
{ok, Body, Req2} = cowboy_req:read_body(Req1),
try jsx:decode(Body, [return_maps]) of
#{<<"user_id">> := SpecUserId} = Map when is_binary(SpecUserId) ->
Name = maps:get(<<"name">>, Map, <<>>),
Specs = maps:get(<<"specialization">>, Map, []),
Specs2 = case is_list(Specs) of true -> Specs; false -> [] end,
case logic_calendar_specialist:add(OwnerId, CalendarId, SpecUserId, Name, Specs2) of
{ok, Rec} ->
handler_utils:send_json(Req2, 201, logic_calendar_specialist:to_json(Rec));
{error, not_found} ->
handler_utils:send_error(Req2, 404, <<"Calendar not found">>);
{error, access_denied} ->
handler_utils:send_error(Req2, 403, <<"Access denied">>);
{error, not_commercial} ->
handler_utils:send_error(Req2, 400, <<"Calendar is not commercial">>);
{error, user_not_found} ->
handler_utils:send_error(Req2, 404, <<"User not found">>);
{error, already_exists} ->
handler_utils:send_error(Req2, 409, <<"Specialist already exists">>);
{error, _} ->
handler_utils:send_error(Req2, 500, <<"Internal server error">>)
end;
_ ->
handler_utils:send_error(Req2, 400, <<"user_id required">>)
catch
_:_ -> handler_utils:send_error(Req2, 400, <<"Invalid JSON">>)
end;
{error, Code, Message, Req1} ->
handler_utils:send_error(Req1, Code, Message)
end.
update_specialist(Req) ->
case handler_utils:auth_user(Req) of
{ok, OwnerId, Req1} ->
CalendarId = cowboy_req:binding(id, Req1),
SpecUserId = cowboy_req:binding(user_id, Req1),
{ok, Body, Req2} = cowboy_req:read_body(Req1),
try jsx:decode(Body, [return_maps]) of
Map when is_map(Map) ->
Updates = parse_updates(Map),
case logic_calendar_specialist:update(OwnerId, CalendarId, SpecUserId, Updates) of
{ok, Rec} ->
handler_utils:send_json(Req2, 200, logic_calendar_specialist:to_json(Rec));
{error, not_found} ->
handler_utils:send_error(Req2, 404, <<"Not found">>);
{error, access_denied} ->
handler_utils:send_error(Req2, 403, <<"Access denied">>);
{error, not_commercial} ->
handler_utils:send_error(Req2, 400, <<"Calendar is not commercial">>);
{error, _} ->
handler_utils:send_error(Req2, 500, <<"Internal server error">>)
end;
_ ->
handler_utils:send_error(Req2, 400, <<"Invalid JSON">>)
catch
_:_ -> handler_utils:send_error(Req2, 400, <<"Invalid JSON">>)
end;
{error, Code, Message, Req1} ->
handler_utils:send_error(Req1, Code, Message)
end.
remove_specialist(Req) ->
case handler_utils:auth_user(Req) of
{ok, OwnerId, Req1} ->
CalendarId = cowboy_req:binding(id, Req1),
SpecUserId = cowboy_req:binding(user_id, Req1),
case logic_calendar_specialist:remove(OwnerId, CalendarId, SpecUserId) of
ok ->
handler_utils:send_json(Req1, 200, #{status => <<"deleted">>});
{error, not_found} ->
handler_utils:send_error(Req1, 404, <<"Not found">>);
{error, access_denied} ->
handler_utils:send_error(Req1, 403, <<"Access denied">>);
{error, not_commercial} ->
handler_utils:send_error(Req1, 400, <<"Calendar is not commercial">>);
{error, _} ->
handler_utils:send_error(Req1, 500, <<"Internal server error">>)
end;
{error, Code, Message, Req1} ->
handler_utils:send_error(Req1, Code, Message)
end.
parse_updates(Map) ->
lists:filtermap(fun
({<<"name">>, V}) when is_binary(V) -> {true, {name, V}};
({<<"specialization">>, V}) when is_list(V) -> {true, {specialization, V}};
({<<"status">>, <<"active">>}) -> {true, {status, active}};
({<<"status">>, <<"inactive">>}) -> {true, {status, inactive}};
(_) -> false
end, maps:to_list(Map)).
Regular → Executable
+7 -1
View File
@@ -443,7 +443,8 @@ calendar_to_json(Calendar) ->
settings => Calendar#calendar.settings, settings => Calendar#calendar.settings,
tags => Calendar#calendar.tags, tags => Calendar#calendar.tags,
type => Calendar#calendar.type, type => Calendar#calendar.type,
confirmation => Calendar#calendar.confirmation, confirmation => confirmation_to_json(Calendar#calendar.confirmation),
booking_open => logic_calendar:booking_open(Calendar),
rating_avg => Calendar#calendar.rating_avg, rating_avg => Calendar#calendar.rating_avg,
rating_count => Calendar#calendar.rating_count, rating_count => Calendar#calendar.rating_count,
status => Calendar#calendar.status, status => Calendar#calendar.status,
@@ -452,6 +453,11 @@ calendar_to_json(Calendar) ->
updated_at => datetime_to_iso8601(Calendar#calendar.updated_at) updated_at => datetime_to_iso8601(Calendar#calendar.updated_at)
}. }.
confirmation_to_json(auto) -> <<"auto">>;
confirmation_to_json(manual) -> <<"manual">>;
confirmation_to_json({timeout, N}) when is_integer(N) -> #{<<"timeout">> => N};
confirmation_to_json(Other) -> Other.
%% @doc Преобразует #subscription{} в JSON-карту. %% @doc Преобразует #subscription{} в JSON-карту.
-spec subscription_to_json(#subscription{}) -> map(). -spec subscription_to_json(#subscription{}) -> map().
subscription_to_json(Subscription) -> subscription_to_json(Subscription) ->
Regular → Executable
+7 -1
View File
@@ -50,6 +50,12 @@ init([]) ->
restart => permanent, restart => permanent,
shutdown => 5000, shutdown => 5000,
type => worker, type => worker,
modules => [migration_engine]} modules => [migration_engine]},
#{id => subscription_worker,
start => {subscription_worker, start_link, []},
restart => permanent,
shutdown => 5000,
type => worker,
modules => [subscription_worker]}
], ],
{ok, {SupFlags, Children}}. {ok, {SupFlags, Children}}.
+2 -1
View File
@@ -25,7 +25,8 @@
'20260717180000_stats_counters', '20260717180000_stats_counters',
'20260717190000_admin_stats_indexes', '20260717190000_admin_stats_indexes',
'20260719210000_review_vote', '20260719210000_review_vote',
'20260720210000_calendar_follow' '20260720210000_calendar_follow',
'20260722150000_calendar_specialist_id'
]). ]).
%% ------------------------------ %% ------------------------------
+38
View File
@@ -0,0 +1,38 @@
%%%-------------------------------------------------------------------
%%% @doc Периодическая обработка: истечение подписок и timeout-booking.
%%% @end
%%%-------------------------------------------------------------------
-module(subscription_worker).
-behaviour(gen_server).
-export([start_link/0]).
-export([init/1, handle_call/3, handle_cast/2, handle_info/2, terminate/2, code_change/3]).
-define(INTERVAL_MS, 15000).
start_link() ->
gen_server:start_link({local, ?MODULE}, ?MODULE, [], []).
init([]) ->
self() ! tick,
{ok, #{}}.
handle_call(_Req, _From, State) ->
{reply, ok, State}.
handle_cast(_Msg, State) ->
{noreply, State}.
handle_info(tick, State) ->
_ = catch logic_subscription:handle_expired_subscriptions(),
_ = catch logic_booking:process_timeout_bookings(),
erlang:send_after(?INTERVAL_MS, self(), tick),
{noreply, State};
handle_info(_Info, State) ->
{noreply, State}.
terminate(_Reason, _State) ->
ok.
code_change(_Old, State, _Extra) ->
{ok, State}.
+168 -86
View File
@@ -4,39 +4,71 @@
cancel_booking/2, cancel_booking/3, get_booking/2, cancel_booking/2, cancel_booking/3, get_booking/2,
list_bookings/2, list_user_bookings/1, delete_booking/2, list_bookings/2, list_user_bookings/1, delete_booking/2,
list_bookings_admin/0, get_booking_admin/1, list_bookings_admin/0, get_booking_admin/1,
list_event_bookings/1, list_event_bookings/2]). list_event_bookings/1, list_event_bookings/2,
process_timeout_bookings/0, cancel_pending_for_owner/1,
cancel_pending_for_calendar/1]).
%%%------------------------------------------------------------------- %%%-------------------------------------------------------------------
%%% @doc Создание бронирования со статусом `pending`. %%% @doc Создание бронирования с учётом commercial / confirmation / capacity.
%%% @end %%% @end
%%%------------------------------------------------------------------- %%%-------------------------------------------------------------------
-spec create_booking(UserId :: binary(), EventId :: binary()) -> -spec create_booking(UserId :: binary(), EventId :: binary()) ->
{ok, #booking{}} | {error, full | already_booked | not_found}. {ok, #booking{}} |
{error, full | already_booked | not_found | personal_calendar |
subscription_inactive | own_event | event_not_active | access_denied}.
create_booking(UserId, EventId) -> create_booking(UserId, EventId) ->
case core_event:get_by_id(EventId) of case core_event:get_by_id(EventId) of
{ok, Event} -> {ok, #event{status = active} = Event} ->
case check_capacity(EventId, Event#event.capacity) of case core_calendar:get_by_id(Event#event.calendar_id) of
{ok, _} -> {ok, Calendar} ->
case core_booking:get_by_event_and_user(EventId, UserId) of create_on_calendar(UserId, Event, Calendar);
{error, not_found} -> {error, not_found} ->
core_booking:create(EventId, UserId, pending); {error, not_found}
{ok, _} ->
{error, already_booked}
end;
{error, full} ->
{error, full}
end; end;
{ok, _} ->
{error, event_not_active};
{error, not_found} -> {error, not_found} ->
{error, not_found} {error, not_found}
end. end.
%%%------------------------------------------------------------------- create_on_calendar(UserId, _Event, #calendar{owner_id = UserId}) ->
%%% @doc Подтверждение бронирования (двухарная версия). {error, own_event};
%%% Только владелец календаря события или admin. create_on_calendar(_UserId, _Event, #calendar{type = personal}) ->
%%% @end {error, personal_calendar};
create_on_calendar(UserId, Event, #calendar{type = commercial} = Calendar) ->
case logic_calendar:booking_open(Calendar) of
false ->
{error, subscription_inactive};
true ->
case active_booking(Event#event.id, UserId) of
{ok, _} ->
{error, already_booked};
{error, not_found} ->
case check_capacity(Event#event.id, Event#event.capacity) of
{ok, _} ->
Initial = initial_status(Calendar#calendar.confirmation),
case core_booking:create(Event#event.id, UserId, Initial) of
{ok, Booking} when Initial =:= confirmed ->
Now = calendar:universal_time(),
core_booking:update(Booking#booking.id,
[{status, confirmed}, {confirmed_at, Now}]);
Other ->
Other
end;
{error, full} ->
{error, full}
end
end
end;
create_on_calendar(_, _, _) ->
{error, access_denied}.
initial_status(auto) -> confirmed;
initial_status(_) -> pending.
%%%------------------------------------------------------------------- %%%-------------------------------------------------------------------
-spec confirm_booking(BookingId :: binary(), UserId :: binary()) -> -spec confirm_booking(BookingId :: binary(), UserId :: binary()) ->
{ok, #booking{}} | {error, not_found | access_denied}. {ok, #booking{}} | {error, not_found | access_denied | full}.
confirm_booking(BookingId, UserId) -> confirm_booking(BookingId, UserId) ->
case core_booking:get_by_id(BookingId) of case core_booking:get_by_id(BookingId) of
{ok, Booking} -> {ok, Booking} ->
@@ -44,8 +76,13 @@ confirm_booking(BookingId, UserId) ->
true -> true ->
case Booking#booking.status of case Booking#booking.status of
pending -> pending ->
Now = calendar:universal_time(), case event_capacity_ok(Booking#booking.event_id) of
core_booking:update(BookingId, [{status, confirmed}, {confirmed_at, Now}]); true ->
Now = calendar:universal_time(),
core_booking:update(BookingId, [{status, confirmed}, {confirmed_at, Now}]);
false ->
{error, full}
end;
_ -> _ ->
{error, access_denied} {error, access_denied}
end; end;
@@ -55,13 +92,8 @@ confirm_booking(BookingId, UserId) ->
Error -> Error Error -> Error
end. end.
%%%-------------------------------------------------------------------
%%% @doc Подтверждение или отклонение бронирования (для обработчиков).
%%% `decline` переводит pending-заявку в `cancelled`.
%%% @end
%%%-------------------------------------------------------------------
-spec confirm_booking(UserId :: binary(), BookingId :: binary(), confirm | decline) -> -spec confirm_booking(UserId :: binary(), BookingId :: binary(), confirm | decline) ->
{ok, #booking{}} | {error, not_found | access_denied}. {ok, #booking{}} | {error, not_found | access_denied | full}.
confirm_booking(UserId, BookingId, confirm) -> confirm_booking(UserId, BookingId, confirm) ->
confirm_booking(BookingId, UserId); confirm_booking(BookingId, UserId);
confirm_booking(UserId, BookingId, decline) -> confirm_booking(UserId, BookingId, decline) ->
@@ -81,10 +113,6 @@ confirm_booking(UserId, BookingId, decline) ->
Error -> Error Error -> Error
end. end.
%%%-------------------------------------------------------------------
%%% @doc Отмена бронирования (двухарная версия).
%%% @end
%%%-------------------------------------------------------------------
-spec cancel_booking(BookingId :: binary(), UserId :: binary()) -> -spec cancel_booking(BookingId :: binary(), UserId :: binary()) ->
{ok, #booking{}} | {error, not_found | access_denied}. {ok, #booking{}} | {error, not_found | access_denied}.
cancel_booking(BookingId, UserId) -> cancel_booking(BookingId, UserId) ->
@@ -102,19 +130,11 @@ cancel_booking(BookingId, UserId) ->
Error -> Error Error -> Error
end. end.
%%%-------------------------------------------------------------------
%%% @doc Отмена бронирования (трёхарная версия для обработчиков).
%%% @end
%%%-------------------------------------------------------------------
-spec cancel_booking(UserId :: binary(), BookingId :: binary(), cancel) -> -spec cancel_booking(UserId :: binary(), BookingId :: binary(), cancel) ->
{ok, #booking{}} | {error, not_found | access_denied}. {ok, #booking{}} | {error, not_found | access_denied}.
cancel_booking(UserId, BookingId, cancel) -> cancel_booking(UserId, BookingId, cancel) ->
cancel_booking(BookingId, UserId). cancel_booking(BookingId, UserId).
%%%-------------------------------------------------------------------
%%% @doc Получение бронирования по ID.
%%% @end
%%%-------------------------------------------------------------------
-spec get_booking(BookingId :: binary(), UserId :: binary()) -> -spec get_booking(BookingId :: binary(), UserId :: binary()) ->
{ok, #booking{}} | {error, not_found | access_denied}. {ok, #booking{}} | {error, not_found | access_denied}.
get_booking(BookingId, UserId) -> get_booking(BookingId, UserId) ->
@@ -122,15 +142,15 @@ get_booking(BookingId, UserId) ->
{ok, Booking} -> {ok, Booking} ->
case Booking#booking.user_id =:= UserId of case Booking#booking.user_id =:= UserId of
true -> {ok, Booking}; true -> {ok, Booking};
false -> {error, access_denied} false ->
case can_manage_event_bookings(UserId, Booking#booking.event_id) of
true -> {ok, Booking};
{error, _} -> {error, access_denied}
end
end; end;
Error -> Error Error -> Error
end. end.
%%%-------------------------------------------------------------------
%%% @doc Список бронирований события.
%%% @end
%%%-------------------------------------------------------------------
-spec list_bookings(EventId :: binary(), UserId :: binary()) -> -spec list_bookings(EventId :: binary(), UserId :: binary()) ->
{ok, [#booking{}]}. {ok, [#booking{}]}.
list_bookings(EventId, UserId) -> list_bookings(EventId, UserId) ->
@@ -141,18 +161,10 @@ list_bookings(EventId, UserId) ->
end, end,
{ok, Filtered}. {ok, Filtered}.
%%%-------------------------------------------------------------------
%%% @doc Список бронирований пользователя.
%%% @end
%%%-------------------------------------------------------------------
-spec list_user_bookings(UserId :: binary()) -> {ok, [#booking{}]}. -spec list_user_bookings(UserId :: binary()) -> {ok, [#booking{}]}.
list_user_bookings(UserId) -> list_user_bookings(UserId) ->
core_booking:list_by_user(UserId). core_booking:list_by_user(UserId).
%%%-------------------------------------------------------------------
%%% @doc Удаление бронирования (только владелец).
%%% @end
%%%-------------------------------------------------------------------
-spec delete_booking(BookingId :: binary(), UserId :: binary()) -> -spec delete_booking(BookingId :: binary(), UserId :: binary()) ->
ok | {error, not_found | access_denied}. ok | {error, not_found | access_denied}.
delete_booking(BookingId, UserId) -> delete_booking(BookingId, UserId) ->
@@ -165,28 +177,15 @@ delete_booking(BookingId, UserId) ->
Error -> Error Error -> Error
end. end.
%%%-------------------------------------------------------------------
%%% @doc Административное получение бронирования.
%%% @end
%%%-------------------------------------------------------------------
-spec get_booking_admin(BookingId :: binary()) -> -spec get_booking_admin(BookingId :: binary()) ->
{ok, #booking{}} | {error, not_found}. {ok, #booking{}} | {error, not_found}.
get_booking_admin(BookingId) -> get_booking_admin(BookingId) ->
core_booking:get_by_id(BookingId). core_booking:get_by_id(BookingId).
%%%-------------------------------------------------------------------
%%% @doc Список бронирований события (административный / внутренний).
%%% @end
%%%-------------------------------------------------------------------
-spec list_event_bookings(EventId :: binary()) -> {ok, [#booking{}]}. -spec list_event_bookings(EventId :: binary()) -> {ok, [#booking{}]}.
list_event_bookings(EventId) -> list_event_bookings(EventId) ->
core_booking:list_by_event(EventId). core_booking:list_by_event(EventId).
%%%-------------------------------------------------------------------
%%% @doc Список бронирований события для владельца календаря (или admin).
%%% Возвращает полный список заявок без фильтрации по участнику.
%%% @end
%%%-------------------------------------------------------------------
-spec list_event_bookings(UserId :: binary(), EventId :: binary()) -> -spec list_event_bookings(UserId :: binary(), EventId :: binary()) ->
{ok, [#booking{}]} | {error, not_found | access_denied}. {ok, [#booking{}]} | {error, not_found | access_denied}.
list_event_bookings(UserId, EventId) -> list_event_bookings(UserId, EventId) ->
@@ -197,22 +196,82 @@ list_event_bookings(UserId, EventId) ->
{error, Reason} {error, Reason}
end. end.
%%%-------------------------------------------------------------------
%%% @doc Список всех бронирований (административный).
%%% @end
%%%-------------------------------------------------------------------
-spec list_bookings_admin() -> {ok, [#booking{}]}. -spec list_bookings_admin() -> {ok, [#booking{}]}.
list_bookings_admin() -> list_bookings_admin() ->
{ok, core_booking:list_all()}. {ok, core_booking:list_all()}.
%%%===================================================================
%%% ВНУТРЕННИЕ ФУНКЦИИ
%%%===================================================================
%%%------------------------------------------------------------------- %%%-------------------------------------------------------------------
%%% @doc Владелец календаря события или admin может управлять заявками. %%% @doc Авто-confirm/cancel по политике {timeout, N}.
%%% @end %%% @end
%%%------------------------------------------------------------------- %%%-------------------------------------------------------------------
-spec process_timeout_bookings() -> ok.
process_timeout_bookings() ->
NowSec = calendar:datetime_to_gregorian_seconds(calendar:universal_time()),
Pending = mnesia:dirty_match_object(#booking{status = pending, _ = '_'}),
lists:foreach(fun(B) -> maybe_timeout(B, NowSec) end, Pending),
ok.
maybe_timeout(#booking{id = Id, event_id = EventId, created_at = Created} = Booking, NowSec) ->
case core_event:get_by_id(EventId) of
{ok, Event} ->
case core_calendar:get_by_id(Event#event.calendar_id) of
{ok, #calendar{confirmation = {timeout, N}}} when is_integer(N), N > 0 ->
CreatedSec = calendar:datetime_to_gregorian_seconds(Created),
case NowSec - CreatedSec >= N of
true ->
case event_capacity_ok(EventId) of
true ->
Now = calendar:universal_time(),
_ = core_booking:update(Id, [{status, confirmed}, {confirmed_at, Now}]);
false ->
_ = core_booking:update(Id, [{status, cancelled}])
end;
false ->
ok
end;
_ ->
ok
end;
_ ->
ok
end,
Booking.
%%%-------------------------------------------------------------------
%%% @doc Отмена всех pending владельца (expire подписки).
%%% @end
%%%-------------------------------------------------------------------
-spec cancel_pending_for_owner(UserId :: binary()) -> ok.
cancel_pending_for_owner(UserId) ->
case core_calendar:list_by_owner(UserId) of
{ok, Cals} ->
lists:foreach(fun(#calendar{id = Id, type = commercial}) ->
cancel_pending_for_calendar(Id);
(_) -> ok
end, Cals);
_ -> ok
end.
-spec cancel_pending_for_calendar(CalendarId :: binary()) -> ok.
cancel_pending_for_calendar(CalendarId) ->
case core_event:list_by_calendar(CalendarId) of
{ok, Events} ->
lists:foreach(fun(#event{id = EventId}) ->
{ok, Bookings} = core_booking:list_by_event(EventId),
lists:foreach(fun
(#booking{id = Bid, status = pending}) ->
_ = core_booking:update(Bid, [{status, cancelled}]);
(_) -> ok
end, Bookings)
end, Events);
_ -> ok
end,
ok.
%%%===================================================================
%%% INTERNAL
%%%===================================================================
-spec can_manage_event_bookings(UserId :: binary(), EventId :: binary()) -> -spec can_manage_event_bookings(UserId :: binary(), EventId :: binary()) ->
true | {error, not_found | access_denied}. true | {error, not_found | access_denied}.
can_manage_event_bookings(UserId, EventId) -> can_manage_event_bookings(UserId, EventId) ->
@@ -220,8 +279,14 @@ can_manage_event_bookings(UserId, EventId) ->
{ok, Event} -> {ok, Event} ->
case core_calendar:get_by_id(Event#event.calendar_id) of case core_calendar:get_by_id(Event#event.calendar_id) of
{ok, Calendar} -> {ok, Calendar} ->
case Calendar#calendar.owner_id =:= UserId OwnerOk = Calendar#calendar.owner_id =:= UserId,
orelse admin_utils:is_admin(UserId) of AdminOk = admin_utils:is_admin(UserId),
SpecOk = is_binary(Event#event.specialist_id)
andalso Event#event.specialist_id =/= <<>>
andalso Event#event.specialist_id =:= UserId
andalso core_calendar_specialist:is_active_specialist(
Calendar#calendar.id, UserId),
case OwnerOk orelse AdminOk orelse SpecOk of
true -> true; true -> true;
false -> {error, access_denied} false -> {error, access_denied}
end; end;
@@ -232,19 +297,36 @@ can_manage_event_bookings(UserId, EventId) ->
{error, not_found} {error, not_found}
end. end.
%%%-------------------------------------------------------------------
%%% @doc Проверка вместимости события.
%%% `undefined` и `0` означают неограниченную вместимость.
%%% @end
%%%-------------------------------------------------------------------
-spec check_capacity(EventId :: binary(), Capacity :: integer() | undefined) -> -spec check_capacity(EventId :: binary(), Capacity :: integer() | undefined) ->
{ok, integer() | unlimited} | {error, full}. {ok, integer() | unlimited} | {error, full}.
check_capacity(_EventId, undefined) -> {ok, unlimited}; check_capacity(_EventId, undefined) -> {ok, unlimited};
check_capacity(_EventId, 0) -> {ok, unlimited}; check_capacity(_EventId, 0) -> {ok, unlimited};
check_capacity(EventId, Capacity) -> check_capacity(EventId, Capacity) ->
{ok, Bookings} = core_booking:list_by_event(EventId), case occupied_slots(EventId) < Capacity of
ConfirmedCount = length([B || B <- Bookings, B#booking.status =:= confirmed]), true -> {ok, Capacity - occupied_slots(EventId)};
case ConfirmedCount < Capacity of
true -> {ok, Capacity - ConfirmedCount};
false -> {error, full} false -> {error, full}
end. end.
event_capacity_ok(EventId) ->
case core_event:get_by_id(EventId) of
{ok, #event{capacity = Cap}} when Cap =:= undefined; Cap =:= 0 ->
true;
{ok, #event{capacity = Cap}} when is_integer(Cap) ->
%% при confirm текущий pending уже в occupied слот свой, ок если <= Cap
occupied_slots(EventId) =< Cap;
_ ->
false
end.
occupied_slots(EventId) ->
{ok, Bookings} = core_booking:list_by_event(EventId),
length([B || B <- Bookings,
B#booking.status =:= pending orelse B#booking.status =:= confirmed]).
active_booking(EventId, UserId) ->
Match = #booking{event_id = EventId, user_id = UserId, _ = '_'},
case [B || B <- mnesia:dirty_match_object(Match),
B#booking.status =:= pending orelse B#booking.status =:= confirmed] of
[B | _] -> {ok, B};
[] -> {error, not_found}
end.
Regular → Executable
+84 -20
View File
@@ -3,7 +3,7 @@
-export([create_calendar/3, create_calendar/4, get_calendar/2, list_calendars/1, -export([create_calendar/3, create_calendar/4, get_calendar/2, list_calendars/1,
update_calendar/3, delete_calendar/2, ensure_default_calendar/1]). update_calendar/3, delete_calendar/2, ensure_default_calendar/1]).
-export([can_access/2, can_edit/2]). -export([can_access/2, can_edit/2, booking_open/1]).
-export([admin_list_all/0, admin_get_by_id/1, admin_update/2, admin_delete/1]). -export([admin_list_all/0, admin_get_by_id/1, admin_update/2, admin_delete/1]).
%% Создание календаря с политикой по умолчанию (manual) %% Создание календаря с политикой по умолчанию (manual)
@@ -110,23 +110,11 @@ update_calendar(UserId, CalendarId, Updates) ->
case can_edit(UserId, Calendar) of case can_edit(UserId, Calendar) of
true -> true ->
ValidUpdates = validate_updates(Updates), ValidUpdates = validate_updates(Updates),
case content_fields(ValidUpdates, [title, description]) of case gate_type_change(UserId, Calendar, ValidUpdates) of
{[], []} -> {error, _} = Err ->
core_calendar:update(CalendarId, ValidUpdates); Err;
{Fields, Texts} -> {ok, ValidUpdates2} ->
case logic_automoderation:evaluate_texts(Texts) of apply_calendar_update(CalendarId, Calendar, ValidUpdates2)
{reject, Words} ->
{error, {content_banned, Words}};
{ok, Action, OutTexts, Words} ->
FinalUpdates = apply_text_results(ValidUpdates, Fields, OutTexts),
case core_calendar:update(CalendarId, FinalUpdates) of
{ok, _Updated} ->
logic_automoderation:apply_after_save(calendar, CalendarId, Action, Words),
core_calendar:get_by_id(CalendarId);
Error ->
Error
end
end
end; end;
false -> false ->
{error, access_denied} {error, access_denied}
@@ -135,6 +123,66 @@ update_calendar(UserId, CalendarId, Updates) ->
Error Error
end. end.
apply_calendar_update(CalendarId, Calendar, ValidUpdates) ->
case content_fields(ValidUpdates, [title, description]) of
{[], []} ->
case core_calendar:update(CalendarId, ValidUpdates) of
{ok, Updated} = Ok ->
maybe_cancel_pending_on_personal(Calendar, Updated),
Ok;
Error ->
Error
end;
{Fields, Texts} ->
case logic_automoderation:evaluate_texts(Texts) of
{reject, Words} ->
{error, {content_banned, Words}};
{ok, Action, OutTexts, Words} ->
FinalUpdates = apply_text_results(ValidUpdates, Fields, OutTexts),
case core_calendar:update(CalendarId, FinalUpdates) of
{ok, Updated} ->
logic_automoderation:apply_after_save(calendar, CalendarId, Action, Words),
maybe_cancel_pending_on_personal(Calendar, Updated),
core_calendar:get_by_id(CalendarId);
Error ->
Error
end
end
end.
gate_type_change(UserId, #calendar{type = personal}, Updates) ->
case lists:keyfind(type, 1, Updates) of
{type, commercial} ->
case logic_subscription:can_create_commercial_calendar(UserId) of
true -> {ok, Updates};
false -> {error, subscription_required}
end;
_ ->
{ok, Updates}
end;
gate_type_change(_UserId, _Calendar, Updates) ->
{ok, Updates}.
maybe_cancel_pending_on_personal(#calendar{type = commercial},
#calendar{type = personal, id = Id}) ->
logic_booking:cancel_pending_for_calendar(Id);
maybe_cancel_pending_on_personal(_, _) ->
ok.
%%%-------------------------------------------------------------------
%%% @doc Можно ли принимать новые booking на календарь.
%%% commercial + active + active subscription владельца.
%%% @end
%%%-------------------------------------------------------------------
-spec booking_open(#calendar{}) -> boolean().
booking_open(#calendar{type = commercial, status = active, owner_id = OwnerId}) ->
case logic_subscription:check_user_subscription(OwnerId) of
{ok, active, _} -> true;
_ -> false
end;
booking_open(_) ->
false.
content_fields(Updates, Fields) -> content_fields(Updates, Fields) ->
lists:foldl(fun(F, {Fs, Ts}) -> lists:foldl(fun(F, {Fs, Ts}) ->
case lists:keyfind(F, 1, Updates) of case lists:keyfind(F, 1, Updates) of
@@ -179,12 +227,28 @@ can_edit(_, _) ->
%% Валидация полей обновления %% Валидация полей обновления
validate_updates(Updates) -> validate_updates(Updates) ->
lists:filter(fun validate_update/1, Updates). lists:filtermap(fun(U) ->
case normalize_update(U) of
false -> false;
Norm ->
case validate_update(Norm) of
true -> {true, Norm};
false -> false
end
end
end, Updates).
normalize_update({type, <<"personal">>}) -> {type, personal};
normalize_update({type, <<"commercial">>}) -> {type, commercial};
normalize_update({type, personal}) -> {type, personal};
normalize_update({type, commercial}) -> {type, commercial};
normalize_update(Other) -> Other.
validate_update({title, Value}) when is_binary(Value) -> true; validate_update({title, Value}) when is_binary(Value) -> true;
validate_update({description, Value}) when is_binary(Value) -> true; validate_update({description, Value}) when is_binary(Value) -> true;
validate_update({tags, Value}) when is_list(Value) -> true; validate_update({tags, Value}) when is_list(Value) -> true;
validate_update({type, Value}) when Value =:= personal; Value =:= commercial -> true; validate_update({type, personal}) -> true;
validate_update({type, commercial}) -> true;
validate_update({confirmation, Value}) -> validate_update({confirmation, Value}) ->
case Value of case Value of
auto -> true; auto -> true;
+84
View File
@@ -0,0 +1,84 @@
%%%-------------------------------------------------------------------
%%% @doc Логика специалистов commercial-календаря.
%%% @end
%%%-------------------------------------------------------------------
-module(logic_calendar_specialist).
-include("records.hrl").
-export([list/2, add/5, update/4, remove/3, to_json/1]).
-spec list(ActorId :: binary(), CalendarId :: binary()) ->
{ok, [#calendar_specialist{}]} | {error, not_found | access_denied}.
list(ActorId, CalendarId) ->
case core_calendar:get_by_id(CalendarId) of
{ok, Cal} ->
case logic_calendar:can_access(ActorId, Cal) of
true -> {ok, core_calendar_specialist:list_by_calendar(CalendarId)};
false -> {error, access_denied}
end;
Error -> Error
end.
-spec add(OwnerId :: binary(), CalendarId :: binary(), UserId :: binary(),
Name :: binary(), Specs :: [binary()]) ->
{ok, #calendar_specialist{}} |
{error, not_found | access_denied | not_commercial | user_not_found |
already_exists | term()}.
add(OwnerId, CalendarId, UserId, Name, Specs) ->
case require_owner_commercial(OwnerId, CalendarId) of
{ok, _} ->
case core_user:get_by_id(UserId) of
{ok, _} ->
core_calendar_specialist:create(CalendarId, UserId, Name, Specs);
{error, _} ->
{error, user_not_found}
end;
Error -> Error
end.
-spec update(OwnerId :: binary(), CalendarId :: binary(), UserId :: binary(),
Updates :: [{atom(), term()}]) ->
{ok, #calendar_specialist{}} | {error, not_found | access_denied | not_commercial | term()}.
update(OwnerId, CalendarId, UserId, Updates) ->
case require_owner_commercial(OwnerId, CalendarId) of
{ok, _} ->
core_calendar_specialist:update(CalendarId, UserId, Updates);
Error -> Error
end.
-spec remove(OwnerId :: binary(), CalendarId :: binary(), UserId :: binary()) ->
ok | {error, not_found | access_denied | not_commercial | term()}.
remove(OwnerId, CalendarId, UserId) ->
case require_owner_commercial(OwnerId, CalendarId) of
{ok, _} ->
core_calendar_specialist:delete(CalendarId, UserId);
Error -> Error
end.
-spec to_json(#calendar_specialist{}) -> map().
to_json(S) ->
#{
id => S#calendar_specialist.id,
calendar_id => S#calendar_specialist.calendar_id,
user_id => S#calendar_specialist.user_id,
name => S#calendar_specialist.name,
specialization => S#calendar_specialist.specialization,
status => S#calendar_specialist.status,
added_at => handler_utils:datetime_to_iso8601(S#calendar_specialist.added_at),
updated_at => handler_utils:datetime_to_iso8601(S#calendar_specialist.updated_at)
}.
%%%===================================================================
%%% Internal
%%%===================================================================
require_owner_commercial(OwnerId, CalendarId) ->
case core_calendar:get_by_id(CalendarId) of
{ok, #calendar{owner_id = OwnerId, type = commercial, status = active} = Cal} ->
{ok, Cal};
{ok, #calendar{owner_id = OwnerId, type = personal}} ->
{error, not_commercial};
{ok, _} ->
{error, access_denied};
Error -> Error
end.
Regular → Executable
+52 -26
View File
@@ -176,33 +176,38 @@ update_event(UserId, EventId, Updates) ->
{ok, Calendar} -> {ok, Calendar} ->
case logic_calendar:can_edit(UserId, Calendar) of case logic_calendar:can_edit(UserId, Calendar) of
true -> true ->
ValidUpdates = validate_updates(Updates, UserId), case validate_specialist_update(Calendar, Updates) of
Title = proplists:get_value(title, ValidUpdates, Event#event.title), {error, _} = E ->
Desc = proplists:get_value(description, ValidUpdates, Event#event.description), E;
case logic_automoderation:evaluate_texts([Title, Desc]) of ok ->
{reject, Words} -> ValidUpdates = validate_updates(Updates, UserId),
{error, {content_banned, Words}}; Title = proplists:get_value(title, ValidUpdates, Event#event.title),
{ok, Action, [Title2, Desc2], Words} -> Desc = proplists:get_value(description, ValidUpdates, Event#event.description),
Final0 = case lists:keymember(title, 1, ValidUpdates) of case logic_automoderation:evaluate_texts([Title, Desc]) of
true -> lists:keystore(title, 1, ValidUpdates, {title, Title2}); {reject, Words} ->
false -> ValidUpdates {error, {content_banned, Words}};
end, {ok, Action, [Title2, Desc2], Words} ->
Final = case lists:keymember(description, 1, Final0) orelse Desc2 =/= Event#event.description of Final0 = case lists:keymember(title, 1, ValidUpdates) of
true when Action =:= censor -> true -> lists:keystore(title, 1, ValidUpdates, {title, Title2});
lists:keystore(description, 1, Final0, {description, Desc2}); false -> ValidUpdates
true -> end,
case lists:keymember(description, 1, Final0) of Final = case lists:keymember(description, 1, Final0) orelse Desc2 =/= Event#event.description of
true -> lists:keystore(description, 1, Final0, {description, Desc2}); true when Action =:= censor ->
lists:keystore(description, 1, Final0, {description, Desc2});
true ->
case lists:keymember(description, 1, Final0) of
true -> lists:keystore(description, 1, Final0, {description, Desc2});
false -> Final0
end;
false -> Final0 false -> Final0
end; end,
false -> Final0 case core_event:update(EventId, Final) of
end, {ok, _} ->
case core_event:update(EventId, Final) of logic_automoderation:apply_after_save(event, EventId, Action, Words),
{ok, _} -> core_event:get_by_id(EventId);
logic_automoderation:apply_after_save(event, EventId, Action, Words), Error ->
core_event:get_by_id(EventId); Error
Error -> end
Error
end end
end; end;
false -> false ->
@@ -378,6 +383,8 @@ validate_update({start_time, Value}, UserId) ->
end; end;
validate_update({duration, Value}, _) when is_integer(Value), Value > 0 -> true; validate_update({duration, Value}, _) when is_integer(Value), Value > 0 -> true;
validate_update({specialist_id, Value}, _) when is_binary(Value) -> true; validate_update({specialist_id, Value}, _) when is_binary(Value) -> true;
validate_update({specialist_id, null}, _) -> true;
validate_update({specialist_id, undefined}, _) -> true;
validate_update({location, Value}, _) -> validate_update({location, Value}, _) ->
case Value of case Value of
#location{} -> true; #location{} -> true;
@@ -389,6 +396,25 @@ validate_update({online_link, Value}, _) when is_binary(Value) -> true;
validate_update({status, Value}, _) when is_atom(Value) -> true; validate_update({status, Value}, _) when is_atom(Value) -> true;
validate_update(_, _) -> false. validate_update(_, _) -> false.
validate_specialist_update(Calendar, Updates) ->
case lists:keyfind(specialist_id, 1, Updates) of
false -> ok;
{specialist_id, null} -> ok;
{specialist_id, undefined} -> ok;
{specialist_id, <<>>} -> ok;
{specialist_id, SpecId} when is_binary(SpecId) ->
case Calendar#calendar.type of
commercial ->
case core_calendar_specialist:is_active_specialist(Calendar#calendar.id, SpecId) of
true -> ok;
false -> {error, invalid_specialist}
end;
_ ->
{error, invalid_specialist}
end;
_ -> ok
end.
get_exceptions(MasterId) -> get_exceptions(MasterId) ->
Match = #recurrence_exception{master_id = MasterId, _ = '_'}, Match = #recurrence_exception{master_id = MasterId, _ = '_'},
mnesia:dirty_match_object(Match). mnesia:dirty_match_object(Match).
+13 -11
View File
@@ -163,23 +163,24 @@ filter_accessible_events(Events, UserId) ->
lists:filter(fun(Event) -> lists:filter(fun(Event) ->
case core_calendar:get_by_id(Event#event.calendar_id) of case core_calendar:get_by_id(Event#event.calendar_id) of
{ok, Calendar} -> {ok, Calendar} ->
CanAccess = logic_calendar:can_access(UserId, Calendar), logic_calendar:can_access(UserId, Calendar)
case CanAccess of andalso calendar_discoverable(Calendar);
false ->
%% io:format("Access denied for user ~p to calendar ~p (type: ~p, owner: ~p, status: ~p)~n",
%% [UserId, Calendar#calendar.id, Calendar#calendar.type,
%% Calendar#calendar.owner_id, Calendar#calendar.status]);
false;
true -> ok
end,
CanAccess;
_ -> false _ -> false
end end
end, Events). end, Events).
-spec filter_accessible_calendars([#calendar{}], binary()) -> [#calendar{}]. -spec filter_accessible_calendars([#calendar{}], binary()) -> [#calendar{}].
filter_accessible_calendars(Calendars, UserId) -> filter_accessible_calendars(Calendars, UserId) ->
lists:filter(fun(Calendar) -> logic_calendar:can_access(UserId, Calendar) end, Calendars). lists:filter(fun(Calendar) ->
logic_calendar:can_access(UserId, Calendar)
andalso calendar_discoverable(Calendar)
end, Calendars).
%% Restricted commercial не показываем в search/discovery (deep-link остаётся).
calendar_discoverable(#calendar{type = commercial} = C) ->
logic_calendar:booking_open(C);
calendar_discoverable(_) ->
true.
%% ============ Применение фильтров ============ %% ============ Применение фильтров ============
@@ -357,6 +358,7 @@ format_calendar(Calendar) ->
title => Calendar#calendar.title, title => Calendar#calendar.title,
description => Calendar#calendar.description, description => Calendar#calendar.description,
type => Calendar#calendar.type, type => Calendar#calendar.type,
booking_open => logic_calendar:booking_open(Calendar),
tags => Calendar#calendar.tags, tags => Calendar#calendar.tags,
rating_avg => Calendar#calendar.rating_avg, rating_avg => Calendar#calendar.rating_avg,
rating_count => Calendar#calendar.rating_count, rating_count => Calendar#calendar.rating_count,
+47
View File
@@ -0,0 +1,47 @@
%% @doc calendar_specialist: PK id (было calendar_id один specialist на календарь).
%% Таблица не использовалась в API безопасно пересоздать.
-module('20260722150000_calendar_specialist_id').
-export([up/0, down/0]).
-include("records.hrl").
up() ->
case lists:member(calendar_specialist, mnesia:system_info(tables)) of
true ->
Attrs = mnesia:table_info(calendar_specialist, attributes),
case Attrs =:= record_info(fields, calendar_specialist) of
true ->
ok;
false ->
_ = mnesia:delete_table(calendar_specialist),
create_table()
end;
false ->
create_table()
end,
ensure_index(calendar_id),
ensure_index(user_id),
ok.
down() ->
%% откат к старому layout без id не восстанавливаем данные
ok.
create_table() ->
case mnesia:create_table(calendar_specialist, [
{disc_copies, [node()]},
{attributes, record_info(fields, calendar_specialist)}
]) of
{atomic, ok} -> ok;
{aborted, {already_exists, calendar_specialist}} -> ok;
{aborted, Reason} -> error({create_table_failed, calendar_specialist, Reason})
end.
ensure_index(Attr) ->
case mnesia:add_table_index(calendar_specialist, Attr) of
{atomic, ok} -> ok;
{aborted, {already_exists, _, _}} -> ok;
{aborted, {already_exists, _}} -> ok;
{aborted, Reason} -> error({add_index_failed, calendar_specialist, Attr, Reason})
end.
+1
View File
@@ -73,6 +73,7 @@ user() ->
handler_bookings, handler_bookings,
handler_calendar_by_id, handler_calendar_by_id,
handler_calendar_follow, handler_calendar_follow,
handler_calendar_specialists,
handler_calendar_view, handler_calendar_view,
handler_calendars, handler_calendars,
handler_event_by_id, handler_event_by_id,
+5 -2
View File
@@ -24,8 +24,11 @@ test() ->
ct:pal("=== Admin Moderation Tests ==="), ct:pal("=== Admin Moderation Tests ==="),
Token = api_test_runner:get_admin_token(), Token = api_test_runner:get_admin_token(),
UserToken = api_test_runner:get_user_token(), UserToken = api_test_runner:get_user_token(),
% Создаём календарь и событие % Создаём commercial-календарь и событие (бронь только на commercial)
CalId = api_test_runner:create_calendar(UserToken, #{title => <<"ModTestCal">>}), CalId = api_test_runner:create_calendar(UserToken, #{
title => <<"ModTestCal">>,
type => <<"commercial">>,
confirmation => <<"manual">>}),
EventId = api_test_runner:create_event(UserToken, CalId, #{ EventId = api_test_runner:create_event(UserToken, CalId, #{
title => <<"Event to moderate">>, title => <<"Event to moderate">>,
start_time => api_test_runner:future_date(), start_time => api_test_runner:future_date(),
+5 -2
View File
@@ -28,8 +28,11 @@ test() ->
ct:pal("=== Admin Reviews Tests ==="), ct:pal("=== Admin Reviews Tests ==="),
Token = api_test_runner:get_admin_token(), Token = api_test_runner:get_admin_token(),
UserToken = api_test_runner:get_user_token(), UserToken = api_test_runner:get_user_token(),
%% Создаём тестовые данные: календарь, событие %% Создаём тестовые данные: commercial-календарь (бронь только на нём), событие
CalId = api_test_runner:create_calendar(UserToken, #{title => <<"ReviewsTestCal">>}), CalId = api_test_runner:create_calendar(UserToken, #{
title => <<"ReviewsTestCal">>,
type => <<"commercial">>,
confirmation => <<"manual">>}),
EventId = api_test_runner:create_event(UserToken, CalId, #{ EventId = api_test_runner:create_event(UserToken, CalId, #{
title => <<"Event for review testing">>, title => <<"Event for review testing">>,
start_time => api_test_runner:future_date(), start_time => api_test_runner:future_date(),
+10 -5
View File
@@ -40,22 +40,27 @@ test() ->
% Создаём тестовые данные для ненулевой статистики % Создаём тестовые данные для ненулевой статистики
UserToken = api_test_runner:get_user_token(), UserToken = api_test_runner:get_user_token(),
CalId = api_test_runner:create_calendar(UserToken, #{title => <<"StatsCal">>}), ParticipantEmail = api_test_runner:unique_email(<<"statspart">>),
ParticipantToken = api_test_runner:register_and_login(ParticipantEmail, <<"pass">>),
CalId = api_test_runner:create_calendar(UserToken, #{
title => <<"StatsCal">>,
type => <<"commercial">>,
confirmation => <<"manual">>}),
EventId = api_test_runner:create_event(UserToken, CalId, #{ EventId = api_test_runner:create_event(UserToken, CalId, #{
title => <<"Stats Event">>, title => <<"Stats Event">>,
start_time => api_test_runner:future_date(), start_time => api_test_runner:future_date(),
duration => 60 duration => 60
}), }),
% Бронируем и подтверждаем, чтобы можно было оставить отзыв % Бронируем участником (не владельцем) и подтверждаем, чтобы можно было оставить отзыв
#{<<"id">> := BookingId} = api_test_runner:client_post( #{<<"id">> := BookingId} = api_test_runner:client_post(
<<"/v1/events/", EventId/binary, "/bookings">>, UserToken, #{}), <<"/v1/events/", EventId/binary, "/bookings">>, ParticipantToken, #{}),
api_test_runner:client_put(<<"/v1/bookings/", BookingId/binary>>, UserToken, api_test_runner:client_put(<<"/v1/bookings/", BookingId/binary>>, UserToken,
#{action => <<"confirm">>}), #{action => <<"confirm">>}),
% Оставляем отзыв % Оставляем отзыв
api_test_runner:client_post(<<"/v1/reviews">>, UserToken, api_test_runner:client_post(<<"/v1/reviews">>, ParticipantToken,
#{target_type => <<"event">>, target_id => EventId, rating => 5, comment => <<"Great!">>}), #{target_type => <<"event">>, target_id => EventId, rating => 5, comment => <<"Great!">>}),
% Жалоба % Жалоба
api_test_runner:client_post(<<"/v1/reports">>, UserToken, api_test_runner:client_post(<<"/v1/reports">>, ParticipantToken,
#{<<"target_type">> => <<"event">>, <<"target_id">> => EventId, <<"reason">> => <<"Test">>}), #{<<"target_type">> => <<"event">>, <<"target_id">> => EventId, <<"reason">> => <<"Test">>}),
% Подписка % Подписка
SubUserToken = api_test_runner:get_user_token(), SubUserToken = api_test_runner:get_user_token(),
+2 -1
View File
@@ -91,7 +91,8 @@ test_report_on_review(Admin) ->
<<"report_threshold">> => 3 <<"report_threshold">> => 3
}), }),
Owner = api_test_runner:get_user_token(), Owner = api_test_runner:get_user_token(),
CalId = api_test_runner:create_calendar(Owner, #{title => <<"RevRepCal">>}), CalId = api_test_runner:create_calendar(Owner, #{
title => <<"RevRepCal">>, type => <<"commercial">>, confirmation => <<"manual">>}),
#{<<"id">> := EventId} = api_test_runner:client_post( #{<<"id">> := EventId} = api_test_runner:client_post(
<<"/v1/calendars/", CalId/binary, "/events">>, Owner, <<"/v1/calendars/", CalId/binary, "/events">>, Owner,
#{title => <<"Event for review report">>, #{title => <<"Event for review report">>,
+5 -1
View File
@@ -31,7 +31,11 @@ test() ->
ParticipantToken = api_test_runner:register_and_login(ParticipantEmail, <<"pass">>), ParticipantToken = api_test_runner:register_and_login(ParticipantEmail, <<"pass">>),
% Создаём календарь и событие % Создаём календарь и событие
CalId = api_test_runner:create_calendar(OwnerToken, #{title => <<"BookingTest">>}), CalId = api_test_runner:create_calendar(OwnerToken, #{
title => <<"BookingTest">>,
type => <<"commercial">>,
confirmation => <<"manual">>
}),
#{<<"id">> := EventId} = api_test_runner:client_post( #{<<"id">> := EventId} = api_test_runner:client_post(
<<"/v1/calendars/", CalId/binary, "/events">>, OwnerToken, <<"/v1/calendars/", CalId/binary, "/events">>, OwnerToken,
#{title => <<"Event to book">>, #{title => <<"Event to book">>,
+5 -1
View File
@@ -27,7 +27,11 @@ test() ->
ParticipantToken = api_test_runner:register_and_login(ParticipantEmail, <<"pass">>), ParticipantToken = api_test_runner:register_and_login(ParticipantEmail, <<"pass">>),
% Создаём календарь и событие % Создаём календарь и событие
CalId = api_test_runner:create_calendar(OwnerToken, #{title => <<"MyBookTest">>}), CalId = api_test_runner:create_calendar(OwnerToken, #{
title => <<"MyBookTest">>,
type => <<"commercial">>,
confirmation => <<"manual">>
}),
#{<<"id">> := EventId} = api_test_runner:client_post( #{<<"id">> := EventId} = api_test_runner:client_post(
<<"/v1/calendars/", CalId/binary, "/events">>, OwnerToken, <<"/v1/calendars/", CalId/binary, "/events">>, OwnerToken,
#{title => <<"Event for my booking">>, #{title => <<"Event for my booking">>,
+2 -1
View File
@@ -27,7 +27,8 @@ test() ->
ParticipantToken = api_test_runner:register_and_login(ParticipantEmail, <<"pass">>), ParticipantToken = api_test_runner:register_and_login(ParticipantEmail, <<"pass">>),
% Создаём календарь и событие % Создаём календарь и событие
CalId = api_test_runner:create_calendar(OwnerToken, #{title => <<"MyRevTest">>}), CalId = api_test_runner:create_calendar(OwnerToken, #{
title => <<"MyRevTest">>, type => <<"commercial">>, confirmation => <<"manual">>}),
#{<<"id">> := EventId} = api_test_runner:client_post( #{<<"id">> := EventId} = api_test_runner:client_post(
<<"/v1/calendars/", CalId/binary, "/events">>, OwnerToken, <<"/v1/calendars/", CalId/binary, "/events">>, OwnerToken,
#{title => <<"Event for my review">>, #{title => <<"Event for my review">>,
+2 -1
View File
@@ -34,7 +34,8 @@ test() ->
StrangerToken = api_test_runner:register_and_login(StrangerEmail, <<"pass">>), StrangerToken = api_test_runner:register_and_login(StrangerEmail, <<"pass">>),
% Создаём календарь, событие, бронирование и отзыв % Создаём календарь, событие, бронирование и отзыв
CalId = api_test_runner:create_calendar(OwnerToken, #{title => <<"RevById">>}), CalId = api_test_runner:create_calendar(OwnerToken, #{
title => <<"RevById">>, type => <<"commercial">>, confirmation => <<"manual">>}),
#{<<"id">> := EventId} = api_test_runner:client_post( #{<<"id">> := EventId} = api_test_runner:client_post(
<<"/v1/calendars/", CalId/binary, "/events">>, OwnerToken, <<"/v1/calendars/", CalId/binary, "/events">>, OwnerToken,
#{title => <<"Event for review">>, #{title => <<"Event for review">>,
+2 -1
View File
@@ -22,7 +22,8 @@ test() ->
OtherEmail = api_test_runner:unique_email(<<"rvother">>), OtherEmail = api_test_runner:unique_email(<<"rvother">>),
OtherToken = api_test_runner:register_and_login(OtherEmail, <<"pass">>), OtherToken = api_test_runner:register_and_login(OtherEmail, <<"pass">>),
CalId = api_test_runner:create_calendar(OwnerToken, #{title => <<"VoteCal">>}), CalId = api_test_runner:create_calendar(OwnerToken, #{
title => <<"VoteCal">>, type => <<"commercial">>, confirmation => <<"manual">>}),
#{<<"id">> := EventId} = api_test_runner:client_post( #{<<"id">> := EventId} = api_test_runner:client_post(
<<"/v1/calendars/", CalId/binary, "/events">>, OwnerToken, <<"/v1/calendars/", CalId/binary, "/events">>, OwnerToken,
#{title => <<"Event for votes">>, #{title => <<"Event for votes">>,
+5 -1
View File
@@ -31,7 +31,11 @@ test() ->
StrangerToken = api_test_runner:register_and_login(StrangerEmail, <<"pass">>), StrangerToken = api_test_runner:register_and_login(StrangerEmail, <<"pass">>),
% Создаём календарь и событие % Создаём календарь и событие
CalId = api_test_runner:create_calendar(OwnerToken, #{title => <<"ReviewTest">>}), CalId = api_test_runner:create_calendar(OwnerToken, #{
title => <<"ReviewTest">>,
type => <<"commercial">>,
confirmation => <<"manual">>
}),
#{<<"id">> := EventId} = api_test_runner:client_post( #{<<"id">> := EventId} = api_test_runner:client_post(
<<"/v1/calendars/", CalId/binary, "/events">>, OwnerToken, <<"/v1/calendars/", CalId/binary, "/events">>, OwnerToken,
#{title => <<"Event for review">>, #{title => <<"Event for review">>,
+13 -14
View File
@@ -2,7 +2,7 @@
-include_lib("eunit/include/eunit.hrl"). -include_lib("eunit/include/eunit.hrl").
-include("records.hrl"). -include("records.hrl").
-define(TABLES, [user, calendar, event, booking, admin]). -define(TABLES, [user, calendar, event, booking, admin, subscription]).
setup() -> setup() ->
eh_test_support:start_mnesia(), eh_test_support:start_mnesia(),
@@ -19,7 +19,7 @@ booking_integration_test_() ->
fun setup/0, fun setup/0,
fun cleanup/1, fun cleanup/1,
[ [
{"Booking create stays pending", fun test_pending_booking_flow/0}, {"Booking auto confirms", fun test_auto_booking_flow/0},
{"Full booking flow with manual confirmation", fun test_manual_booking_flow/0}, {"Full booking flow with manual confirmation", fun test_manual_booking_flow/0},
{"Capacity management test", fun test_capacity_management/0}, {"Capacity management test", fun test_capacity_management/0},
{"Multiple bookings test", fun test_multiple_bookings/0} {"Multiple bookings test", fun test_multiple_bookings/0}
@@ -39,24 +39,25 @@ create_user() ->
mnesia:dirty_write(User), mnesia:dirty_write(User),
UserId. UserId.
create_commercial(OwnerId, Confirmation) ->
{ok, _} = core_subscription:create(OwnerId, monthly, true),
{ok, Calendar} = core_calendar:create(OwnerId, <<"Cal">>, <<"">>, Confirmation, commercial),
Calendar.
add_days(DateTime, Days) -> add_days(DateTime, Days) ->
Sec = calendar:datetime_to_gregorian_seconds(DateTime) + Days * 86400, Sec = calendar:datetime_to_gregorian_seconds(DateTime) + Days * 86400,
calendar:gregorian_seconds_to_datetime(Sec). calendar:gregorian_seconds_to_datetime(Sec).
test_pending_booking_flow() -> test_auto_booking_flow() ->
OwnerId = create_user(), OwnerId = create_user(),
ParticipantId = create_user(), ParticipantId = create_user(),
Calendar = create_commercial(OwnerId, auto),
{ok, Calendar} = core_calendar:create(OwnerId, <<"Auto">>, <<"">>, auto),
StartTime = eh_test_support:future_start(), StartTime = eh_test_support:future_start(),
{ok, Event} = core_event:create(Calendar#calendar.id, <<"Event">>, StartTime, 60), {ok, Event} = core_event:create(Calendar#calendar.id, <<"Event">>, StartTime, 60),
{ok, Booking} = logic_booking:create_booking(ParticipantId, Event#event.id), {ok, Booking} = logic_booking:create_booking(ParticipantId, Event#event.id),
?assertEqual(pending, Booking#booking.status), ?assertEqual(confirmed, Booking#booking.status),
{ok, Stored} = core_booking:get_by_id(Booking#booking.id),
?assertEqual(pending, Stored#booking.status),
{ok, EventBookings} = logic_booking:list_event_bookings(Event#event.id), {ok, EventBookings} = logic_booking:list_event_bookings(Event#event.id),
?assertEqual(1, length(EventBookings)), ?assertEqual(1, length(EventBookings)),
@@ -67,8 +68,7 @@ test_pending_booking_flow() ->
test_manual_booking_flow() -> test_manual_booking_flow() ->
OwnerId = create_user(), OwnerId = create_user(),
ParticipantId = create_user(), ParticipantId = create_user(),
Calendar = create_commercial(OwnerId, manual),
{ok, Calendar} = core_calendar:create(OwnerId, <<"Manual">>, <<"">>, manual),
StartTime = eh_test_support:future_start(), StartTime = eh_test_support:future_start(),
{ok, Event} = core_event:create(Calendar#calendar.id, <<"Event">>, StartTime, 60), {ok, Event} = core_event:create(Calendar#calendar.id, <<"Event">>, StartTime, 60),
@@ -88,7 +88,7 @@ test_capacity_management() ->
Participant2Id = create_user(), Participant2Id = create_user(),
Participant3Id = create_user(), Participant3Id = create_user(),
{ok, Calendar} = core_calendar:create(OwnerId, <<"Test">>, <<"">>, manual), Calendar = create_commercial(OwnerId, manual),
StartTime = eh_test_support:future_start(), StartTime = eh_test_support:future_start(),
{ok, Event} = core_event:create(Calendar#calendar.id, <<"Event">>, StartTime, 60), {ok, Event} = core_event:create(Calendar#calendar.id, <<"Event">>, StartTime, 60),
@@ -108,8 +108,7 @@ test_capacity_management() ->
test_multiple_bookings() -> test_multiple_bookings() ->
OwnerId = create_user(), OwnerId = create_user(),
ParticipantId = create_user(), ParticipantId = create_user(),
Calendar = create_commercial(OwnerId, manual),
{ok, Calendar} = core_calendar:create(OwnerId, <<"Test">>, <<"">>, manual),
Base = eh_test_support:future_start(), Base = eh_test_support:future_start(),
StartTime1 = Base, StartTime1 = Base,
+2 -1
View File
@@ -129,7 +129,8 @@ table_opts(calendar_share) ->
table_opts(calendar_follow) -> table_opts(calendar_follow) ->
[{ram_copies, [node()]}, {attributes, record_info(fields, calendar_follow)}]; [{ram_copies, [node()]}, {attributes, record_info(fields, calendar_follow)}];
table_opts(calendar_specialist) -> table_opts(calendar_specialist) ->
[{ram_copies, [node()]}, {attributes, record_info(fields, calendar_specialist)}]; [{ram_copies, [node()]}, {attributes, record_info(fields, calendar_specialist)},
{index, [calendar_id, user_id]}];
table_opts(event) -> table_opts(event) ->
[{ram_copies, [node()]}, {attributes, record_info(fields, event)}]; [{ram_copies, [node()]}, {attributes, record_info(fields, event)}];
table_opts(recurrence_exception) -> table_opts(recurrence_exception) ->
+48 -8
View File
@@ -2,7 +2,7 @@
-include_lib("eunit/include/eunit.hrl"). -include_lib("eunit/include/eunit.hrl").
-include("records.hrl"). -include("records.hrl").
-define(TABLES, [user, calendar, event, booking, admin]). -define(TABLES, [user, calendar, event, booking, admin, subscription, calendar_specialist]).
setup() -> setup() ->
eh_test_support:start_mnesia(), eh_test_support:start_mnesia(),
@@ -19,14 +19,17 @@ logic_booking_test_() ->
fun setup/0, fun setup/0,
fun cleanup/1, fun cleanup/1,
[ [
{"Create booking returns pending", fun test_create_booking_pending/0}, {"Create booking auto confirms", fun test_create_booking_auto/0},
{"Create booking manual pending", fun test_create_booking_pending/0},
{"Create booking personal denied", fun test_booking_personal_denied/0},
{"Create duplicate booking", fun test_create_duplicate_booking/0}, {"Create duplicate booking", fun test_create_duplicate_booking/0},
{"Create booking for missing event", fun test_booking_missing_event/0}, {"Create booking for missing event", fun test_booking_missing_event/0},
{"Create booking when event is full", fun test_booking_event_full/0}, {"Create booking when event is full", fun test_booking_event_full/0},
{"Pending bookings do not fill capacity", fun test_pending_does_not_fill/0}, {"Pending bookings fill capacity", fun test_pending_fills_capacity/0},
{"Confirm booking", fun test_confirm_booking/0}, {"Confirm booking", fun test_confirm_booking/0},
{"Confirm booking as booker denied", fun test_confirm_booker_denied/0}, {"Confirm booking as booker denied", fun test_confirm_booker_denied/0},
{"Confirm booking as stranger denied", fun test_confirm_stranger_denied/0}, {"Confirm booking as stranger denied", fun test_confirm_stranger_denied/0},
{"Confirm booking as specialist", fun test_confirm_booking_specialist/0},
{"Confirm booking as admin", fun test_confirm_booking_admin/0}, {"Confirm booking as admin", fun test_confirm_booking_admin/0},
{"Decline booking by owner", fun test_decline_booking/0}, {"Decline booking by owner", fun test_decline_booking/0},
{"Decline booking as booker denied", fun test_decline_booker_denied/0}, {"Decline booking as booker denied", fun test_decline_booker_denied/0},
@@ -59,8 +62,17 @@ create_test_admin() ->
Admin = eh_test_support:seed_admin(#{id => AdminId, email => <<AdminId/binary, "@admin.test">>}), Admin = eh_test_support:seed_admin(#{id => AdminId, email => <<AdminId/binary, "@admin.test">>}),
Admin#admin.id. Admin#admin.id.
ensure_subscription(OwnerId) ->
{ok, _} = core_subscription:create(OwnerId, monthly, true),
ok.
create_test_calendar(OwnerId, Confirmation) -> create_test_calendar(OwnerId, Confirmation) ->
{ok, Calendar} = core_calendar:create(OwnerId, <<"Test Calendar">>, <<"">>, Confirmation), ensure_subscription(OwnerId),
{ok, Calendar} = core_calendar:create(OwnerId, <<"Test Calendar">>, <<"">>, Confirmation, commercial),
Calendar#calendar.id.
create_personal_calendar(OwnerId) ->
{ok, Calendar} = core_calendar:create(OwnerId, <<"Personal">>, <<"">>, manual, personal),
Calendar#calendar.id. Calendar#calendar.id.
create_test_event(CalendarId) -> create_test_event(CalendarId) ->
@@ -75,18 +87,34 @@ create_test_event_with_capacity(CalendarId, Capacity) ->
Updated#event.id. Updated#event.id.
%% Тесты %% Тесты
test_create_booking_pending() -> test_create_booking_auto() ->
OwnerId = create_test_user(user), OwnerId = create_test_user(user),
ParticipantId = create_test_user(user), ParticipantId = create_test_user(user),
CalendarId = create_test_calendar(OwnerId, auto), CalendarId = create_test_calendar(OwnerId, auto),
EventId = create_test_event(CalendarId), EventId = create_test_event(CalendarId),
{ok, Booking} = logic_booking:create_booking(ParticipantId, EventId),
?assertEqual(confirmed, Booking#booking.status).
test_create_booking_pending() ->
OwnerId = create_test_user(user),
ParticipantId = create_test_user(user),
CalendarId = create_test_calendar(OwnerId, manual),
EventId = create_test_event(CalendarId),
{ok, Booking} = logic_booking:create_booking(ParticipantId, EventId), {ok, Booking} = logic_booking:create_booking(ParticipantId, EventId),
?assertEqual(pending, Booking#booking.status), ?assertEqual(pending, Booking#booking.status),
{ok, Stored} = core_booking:get_by_id(Booking#booking.id), {ok, Stored} = core_booking:get_by_id(Booking#booking.id),
?assertEqual(pending, Stored#booking.status). ?assertEqual(pending, Stored#booking.status).
test_booking_personal_denied() ->
OwnerId = create_test_user(user),
ParticipantId = create_test_user(user),
CalendarId = create_personal_calendar(OwnerId),
EventId = create_test_event(CalendarId),
{error, personal_calendar} = logic_booking:create_booking(ParticipantId, EventId).
test_create_duplicate_booking() -> test_create_duplicate_booking() ->
OwnerId = create_test_user(user), OwnerId = create_test_user(user),
ParticipantId = create_test_user(user), ParticipantId = create_test_user(user),
@@ -111,7 +139,7 @@ test_booking_event_full() ->
{ok, _} = logic_booking:confirm_booking(OwnerId, B1#booking.id, confirm), {ok, _} = logic_booking:confirm_booking(OwnerId, B1#booking.id, confirm),
{error, full} = logic_booking:create_booking(Participant2Id, EventId). {error, full} = logic_booking:create_booking(Participant2Id, EventId).
test_pending_does_not_fill() -> test_pending_fills_capacity() ->
OwnerId = create_test_user(user), OwnerId = create_test_user(user),
Participant1Id = create_test_user(user), Participant1Id = create_test_user(user),
Participant2Id = create_test_user(user), Participant2Id = create_test_user(user),
@@ -119,8 +147,7 @@ test_pending_does_not_fill() ->
EventId = create_test_event_with_capacity(CalendarId, 1), EventId = create_test_event_with_capacity(CalendarId, 1),
{ok, _} = logic_booking:create_booking(Participant1Id, EventId), {ok, _} = logic_booking:create_booking(Participant1Id, EventId),
{ok, B2} = logic_booking:create_booking(Participant2Id, EventId), {error, full} = logic_booking:create_booking(Participant2Id, EventId).
?assertEqual(pending, B2#booking.status).
test_confirm_booking() -> test_confirm_booking() ->
OwnerId = create_test_user(user), OwnerId = create_test_user(user),
@@ -151,6 +178,19 @@ test_confirm_stranger_denied() ->
{ok, Booking} = logic_booking:create_booking(ParticipantId, EventId), {ok, Booking} = logic_booking:create_booking(ParticipantId, EventId),
{error, access_denied} = logic_booking:confirm_booking(StrangerId, Booking#booking.id, confirm). {error, access_denied} = logic_booking:confirm_booking(StrangerId, Booking#booking.id, confirm).
test_confirm_booking_specialist() ->
OwnerId = create_test_user(user),
SpecId = create_test_user(user),
ParticipantId = create_test_user(user),
CalendarId = create_test_calendar(OwnerId, manual),
{ok, _} = core_calendar_specialist:create(CalendarId, SpecId, <<"Spec">>, []),
EventId = create_test_event(CalendarId),
{ok, _} = core_event:update(EventId, [{specialist_id, SpecId}]),
{ok, Booking} = logic_booking:create_booking(ParticipantId, EventId),
{ok, Confirmed} = logic_booking:confirm_booking(SpecId, Booking#booking.id, confirm),
?assertEqual(confirmed, Confirmed#booking.status).
test_confirm_booking_admin() -> test_confirm_booking_admin() ->
OwnerId = create_test_user(user), OwnerId = create_test_user(user),
ParticipantId = create_test_user(user), ParticipantId = create_test_user(user),
+5
View File
@@ -12,9 +12,14 @@ setup() ->
{attributes, record_info(fields, calendar)}, {attributes, record_info(fields, calendar)},
{ram_copies, [node()]} {ram_copies, [node()]}
]), ]),
mnesia:create_table(subscription, [
{attributes, record_info(fields, subscription)},
{ram_copies, [node()]}
]),
ok. ok.
cleanup(_) -> cleanup(_) ->
mnesia:delete_table(subscription),
mnesia:delete_table(calendar), mnesia:delete_table(calendar),
mnesia:delete_table(user), mnesia:delete_table(user),
mnesia:stop(), mnesia:stop(),
Regular → Executable
+7 -1
View File
@@ -2,7 +2,7 @@
-include_lib("eunit/include/eunit.hrl"). -include_lib("eunit/include/eunit.hrl").
-include("records.hrl"). -include("records.hrl").
-define(TABLES, [user, calendar, event]). -define(TABLES, [user, calendar, event, subscription]).
setup() -> setup() ->
eh_test_support:start_mnesia(), eh_test_support:start_mnesia(),
@@ -50,6 +50,12 @@ create_test_user(Role) ->
UserId. UserId.
create_test_calendar(OwnerId, Type, Tags) -> create_test_calendar(OwnerId, Type, Tags) ->
case Type of
commercial ->
_ = core_subscription:create(OwnerId, monthly, true);
_ ->
ok
end,
{ok, Calendar} = core_calendar:create(OwnerId, <<"Test Calendar">>, <<"Description">>, manual), {ok, Calendar} = core_calendar:create(OwnerId, <<"Test Calendar">>, <<"Description">>, manual),
core_calendar:update(Calendar#calendar.id, [{type, Type}, {tags, Tags}]), core_calendar:update(Calendar#calendar.id, [{type, Type}, {tags, Tags}]),
{ok, Updated} = core_calendar:get_by_id(Calendar#calendar.id), {ok, Updated} = core_calendar:get_by_id(Calendar#calendar.id),
+2 -1
View File
@@ -11,7 +11,8 @@
"20260717180000_stats_counters", "20260717180000_stats_counters",
"20260717190000_admin_stats_indexes", "20260717190000_admin_stats_indexes",
"20260719210000_review_vote", "20260719210000_review_vote",
"20260720210000_calendar_follow" "20260720210000_calendar_follow",
"20260722150000_calendar_specialist_id"
]). ]).
setup() -> setup() ->
Regular → Executable
+4 -1
View File
@@ -45,7 +45,10 @@ test_event_rating_top() ->
ok = mnesia:dirty_write(make_event(<<"e_low">>, 2.0, Now)), ok = mnesia:dirty_write(make_event(<<"e_low">>, 2.0, Now)),
ok = mnesia:dirty_write(make_event(<<"e_high">>, 5.0, Now)), ok = mnesia:dirty_write(make_event(<<"e_high">>, 5.0, Now)),
ok = mnesia:dirty_write(make_event(<<"e_mid">>, 3.5, Now)), ok = mnesia:dirty_write(make_event(<<"e_mid">>, 3.5, Now)),
wait_top_event(<<"e_high">>, 30), wait_until(fun() ->
[E#event.id || E <- core_event:get_top_events_by_rating(2)]
=:= [<<"e_high">>, <<"e_mid">>]
end, 40),
Top = core_event:get_top_events_by_rating(2), Top = core_event:get_top_events_by_rating(2),
Ids = [E#event.id || E <- Top], Ids = [E#event.id || E <- Top],
?assertEqual([<<"e_high">>, <<"e_mid">>], Ids). ?assertEqual([<<"e_high">>, <<"e_mid">>], Ids).