diff --git a/include/records.hrl b/include/records.hrl index de81c91..eeeb1f4 100755 --- a/include/records.hrl +++ b/include/records.hrl @@ -112,7 +112,10 @@ }). %% ------------------- Специалисты календаря --------------------------- +%% PK = id (несколько специалистов на календарь). Уникальность пары +%% calendar_id+user_id — на уровне logic. -record(calendar_specialist, { + id :: binary(), calendar_id :: binary(), user_id :: binary(), % id пользователя-специалиста name :: binary(), % отображаемое имя в этом календаре diff --git a/src/core/core_calendar_specialist.erl b/src/core/core_calendar_specialist.erl new file mode 100755 index 0000000..33b8b25 --- /dev/null +++ b/src/core/core_calendar_specialist.erl @@ -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. diff --git a/src/core/core_subscription.erl b/src/core/core_subscription.erl old mode 100644 new mode 100755 index ec4d9c4..0720e97 --- a/src/core/core_subscription.erl +++ b/src/core/core_subscription.erl @@ -57,9 +57,10 @@ get_by_id(Id) -> -spec get_active_by_user(UserId :: binary()) -> {ok, #subscription{}} | {error, not_found}. get_active_by_user(UserId) -> 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}; - [Subscription] -> {ok, Subscription} + [Subscription | _] -> {ok, Subscription} end. %%%------------------------------------------------------------------- @@ -69,9 +70,13 @@ get_active_by_user(UserId) -> -spec list_by_user(UserId :: binary()) -> {ok, [#subscription{}]}. list_by_user(UserId) -> Match = #subscription{user_id = UserId, _ = '_'}, - Subscriptions = mnesia:dirty_match_object(Match), - {ok, lists:sort(fun(A, B) -> A#subscription.created_at >= B#subscription.created_at end, - Subscriptions)}. + case catch mnesia:dirty_match_object(Match) of + {'EXIT', _} -> + {ok, []}; + Subscriptions when is_list(Subscriptions) -> + {ok, lists:sort(fun(A, B) -> A#subscription.created_at >= B#subscription.created_at end, + Subscriptions)} + end. %%%------------------------------------------------------------------- %%% @doc Список всех подписок (для администраторов). @@ -122,25 +127,14 @@ check_expired() -> update_status(Sub#subscription.id, expired), case get_active_by_user(Sub#subscription.user_id) of {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 end; false -> ok end 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). - %%%------------------------------------------------------------------- %%% Вспомогательные функции %%%------------------------------------------------------------------- diff --git a/src/eventhub_app.erl b/src/eventhub_app.erl index 3d837e1..ae5da88 100755 --- a/src/eventhub_app.erl +++ b/src/eventhub_app.erl @@ -95,6 +95,8 @@ start_http() -> {"/v1/calendars", handler_calendars, []}, {"/v1/calendars/:id", handler_calendar_by_id, []}, {"/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/events", handler_events, []}, {"/v1/events/:id", handler_event_by_id, []}, diff --git a/src/handlers/handler_bookings.erl b/src/handlers/handler_bookings.erl old mode 100644 new mode 100755 index f7ff2ec..4eaebef --- a/src/handlers/handler_bookings.erl +++ b/src/handlers/handler_bookings.erl @@ -116,10 +116,18 @@ create_booking(Req) -> handler_utils:send_json(Req1, 201, booking_to_json(Booking)); {error, already_booked} -> handler_utils:send_error(Req1, 409, <<"Already booked">>); + {error, full} -> + handler_utils:send_error(Req1, 400, <<"Event is full">>); {error, event_full} -> handler_utils:send_error(Req1, 400, <<"Event is full">>); {error, event_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} -> handler_utils:send_error(Req1, 403, <<"Access denied">>); {error, not_found} -> diff --git a/src/handlers/handler_calendar_by_id.erl b/src/handlers/handler_calendar_by_id.erl index 6c753a7..794bfe9 100755 --- a/src/handlers/handler_calendar_by_id.erl +++ b/src/handlers/handler_calendar_by_id.erl @@ -139,9 +139,13 @@ update_calendar(Req) -> Updates = convert_calendar_fields(Updates0), case logic_calendar:update_calendar(UserId, CalendarId, Updates) of {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} -> 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} -> handler_utils:send_error(Req2, 404, <<"Calendar not found">>); {error, _} -> @@ -182,6 +186,8 @@ convert_calendar_fields(Updates) -> -spec convert_field({binary(), term()}) -> {atom(), term()}. convert_field({<<"title">>, Val}) -> {title, 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({<<"confirmation">>, <<"auto">>}) -> {confirmation, auto}; convert_field({<<"confirmation">>, <<"manual">>}) -> {confirmation, manual}; diff --git a/src/handlers/handler_calendar_specialists.erl b/src/handlers/handler_calendar_specialists.erl new file mode 100755 index 0000000..64f0ae2 --- /dev/null +++ b/src/handlers/handler_calendar_specialists.erl @@ -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)). diff --git a/src/handlers/handler_utils.erl b/src/handlers/handler_utils.erl old mode 100644 new mode 100755 index 6c43f6f..bcde83f --- a/src/handlers/handler_utils.erl +++ b/src/handlers/handler_utils.erl @@ -443,7 +443,8 @@ calendar_to_json(Calendar) -> settings => Calendar#calendar.settings, tags => Calendar#calendar.tags, 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_count => Calendar#calendar.rating_count, status => Calendar#calendar.status, @@ -452,6 +453,11 @@ calendar_to_json(Calendar) -> 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-карту. -spec subscription_to_json(#subscription{}) -> map(). subscription_to_json(Subscription) -> diff --git a/src/infra/infra_sup.erl b/src/infra/infra_sup.erl old mode 100644 new mode 100755 index 6dbccc7..9a0eb42 --- a/src/infra/infra_sup.erl +++ b/src/infra/infra_sup.erl @@ -50,6 +50,12 @@ init([]) -> restart => permanent, shutdown => 5000, 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}}. \ No newline at end of file diff --git a/src/infra/migration_engine.erl b/src/infra/migration_engine.erl index 2ac8011..3513978 100755 --- a/src/infra/migration_engine.erl +++ b/src/infra/migration_engine.erl @@ -25,7 +25,8 @@ '20260717180000_stats_counters', '20260717190000_admin_stats_indexes', '20260719210000_review_vote', - '20260720210000_calendar_follow' + '20260720210000_calendar_follow', + '20260722150000_calendar_specialist_id' ]). %% ------------------------------ diff --git a/src/infra/subscription_worker.erl b/src/infra/subscription_worker.erl new file mode 100755 index 0000000..e9e8a03 --- /dev/null +++ b/src/infra/subscription_worker.erl @@ -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}. diff --git a/src/logic/logic_booking.erl b/src/logic/logic_booking.erl index c7a7dbe..875cf6a 100755 --- a/src/logic/logic_booking.erl +++ b/src/logic/logic_booking.erl @@ -4,39 +4,71 @@ cancel_booking/2, cancel_booking/3, get_booking/2, list_bookings/2, list_user_bookings/1, delete_booking/2, 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 %%%------------------------------------------------------------------- -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) -> case core_event:get_by_id(EventId) of - {ok, Event} -> - case check_capacity(EventId, Event#event.capacity) of - {ok, _} -> - case core_booking:get_by_event_and_user(EventId, UserId) of - {error, not_found} -> - core_booking:create(EventId, UserId, pending); - {ok, _} -> - {error, already_booked} - end; - {error, full} -> - {error, full} + {ok, #event{status = active} = Event} -> + case core_calendar:get_by_id(Event#event.calendar_id) of + {ok, Calendar} -> + create_on_calendar(UserId, Event, Calendar); + {error, not_found} -> + {error, not_found} end; + {ok, _} -> + {error, event_not_active}; {error, not_found} -> {error, not_found} end. -%%%------------------------------------------------------------------- -%%% @doc Подтверждение бронирования (двухарная версия). -%%% Только владелец календаря события или admin. -%%% @end +create_on_calendar(UserId, _Event, #calendar{owner_id = UserId}) -> + {error, own_event}; +create_on_calendar(_UserId, _Event, #calendar{type = personal}) -> + {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()) -> - {ok, #booking{}} | {error, not_found | access_denied}. + {ok, #booking{}} | {error, not_found | access_denied | full}. confirm_booking(BookingId, UserId) -> case core_booking:get_by_id(BookingId) of {ok, Booking} -> @@ -44,8 +76,13 @@ confirm_booking(BookingId, UserId) -> true -> case Booking#booking.status of pending -> - Now = calendar:universal_time(), - core_booking:update(BookingId, [{status, confirmed}, {confirmed_at, Now}]); + case event_capacity_ok(Booking#booking.event_id) of + true -> + Now = calendar:universal_time(), + core_booking:update(BookingId, [{status, confirmed}, {confirmed_at, Now}]); + false -> + {error, full} + end; _ -> {error, access_denied} end; @@ -55,13 +92,8 @@ confirm_booking(BookingId, UserId) -> Error -> Error end. -%%%------------------------------------------------------------------- -%%% @doc Подтверждение или отклонение бронирования (для обработчиков). -%%% `decline` переводит pending-заявку в `cancelled`. -%%% @end -%%%------------------------------------------------------------------- -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(BookingId, UserId); confirm_booking(UserId, BookingId, decline) -> @@ -81,10 +113,6 @@ confirm_booking(UserId, BookingId, decline) -> Error -> Error end. -%%%------------------------------------------------------------------- -%%% @doc Отмена бронирования (двухарная версия). -%%% @end -%%%------------------------------------------------------------------- -spec cancel_booking(BookingId :: binary(), UserId :: binary()) -> {ok, #booking{}} | {error, not_found | access_denied}. cancel_booking(BookingId, UserId) -> @@ -102,19 +130,11 @@ cancel_booking(BookingId, UserId) -> Error -> Error end. -%%%------------------------------------------------------------------- -%%% @doc Отмена бронирования (трёхарная версия для обработчиков). -%%% @end -%%%------------------------------------------------------------------- -spec cancel_booking(UserId :: binary(), BookingId :: binary(), cancel) -> {ok, #booking{}} | {error, not_found | access_denied}. cancel_booking(UserId, BookingId, cancel) -> cancel_booking(BookingId, UserId). -%%%------------------------------------------------------------------- -%%% @doc Получение бронирования по ID. -%%% @end -%%%------------------------------------------------------------------- -spec get_booking(BookingId :: binary(), UserId :: binary()) -> {ok, #booking{}} | {error, not_found | access_denied}. get_booking(BookingId, UserId) -> @@ -122,15 +142,15 @@ get_booking(BookingId, UserId) -> {ok, Booking} -> case Booking#booking.user_id =:= UserId of 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; Error -> Error end. -%%%------------------------------------------------------------------- -%%% @doc Список бронирований события. -%%% @end -%%%------------------------------------------------------------------- -spec list_bookings(EventId :: binary(), UserId :: binary()) -> {ok, [#booking{}]}. list_bookings(EventId, UserId) -> @@ -141,18 +161,10 @@ list_bookings(EventId, UserId) -> end, {ok, Filtered}. -%%%------------------------------------------------------------------- -%%% @doc Список бронирований пользователя. -%%% @end -%%%------------------------------------------------------------------- -spec list_user_bookings(UserId :: binary()) -> {ok, [#booking{}]}. list_user_bookings(UserId) -> core_booking:list_by_user(UserId). -%%%------------------------------------------------------------------- -%%% @doc Удаление бронирования (только владелец). -%%% @end -%%%------------------------------------------------------------------- -spec delete_booking(BookingId :: binary(), UserId :: binary()) -> ok | {error, not_found | access_denied}. delete_booking(BookingId, UserId) -> @@ -165,28 +177,15 @@ delete_booking(BookingId, UserId) -> Error -> Error end. -%%%------------------------------------------------------------------- -%%% @doc Административное получение бронирования. -%%% @end -%%%------------------------------------------------------------------- -spec get_booking_admin(BookingId :: binary()) -> {ok, #booking{}} | {error, not_found}. get_booking_admin(BookingId) -> core_booking:get_by_id(BookingId). -%%%------------------------------------------------------------------- -%%% @doc Список бронирований события (административный / внутренний). -%%% @end -%%%------------------------------------------------------------------- -spec list_event_bookings(EventId :: binary()) -> {ok, [#booking{}]}. list_event_bookings(EventId) -> core_booking:list_by_event(EventId). -%%%------------------------------------------------------------------- -%%% @doc Список бронирований события для владельца календаря (или admin). -%%% Возвращает полный список заявок без фильтрации по участнику. -%%% @end -%%%------------------------------------------------------------------- -spec list_event_bookings(UserId :: binary(), EventId :: binary()) -> {ok, [#booking{}]} | {error, not_found | access_denied}. list_event_bookings(UserId, EventId) -> @@ -197,22 +196,82 @@ list_event_bookings(UserId, EventId) -> {error, Reason} end. -%%%------------------------------------------------------------------- -%%% @doc Список всех бронирований (административный). -%%% @end -%%%------------------------------------------------------------------- -spec list_bookings_admin() -> {ok, [#booking{}]}. list_bookings_admin() -> {ok, core_booking:list_all()}. -%%%=================================================================== -%%% ВНУТРЕННИЕ ФУНКЦИИ -%%%=================================================================== - %%%------------------------------------------------------------------- -%%% @doc Владелец календаря события или admin может управлять заявками. +%%% @doc Авто-confirm/cancel по политике {timeout, N}. %%% @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()) -> true | {error, not_found | access_denied}. can_manage_event_bookings(UserId, EventId) -> @@ -220,8 +279,14 @@ can_manage_event_bookings(UserId, EventId) -> {ok, Event} -> case core_calendar:get_by_id(Event#event.calendar_id) of {ok, Calendar} -> - case Calendar#calendar.owner_id =:= UserId - orelse admin_utils:is_admin(UserId) of + OwnerOk = Calendar#calendar.owner_id =:= UserId, + 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; false -> {error, access_denied} end; @@ -232,19 +297,36 @@ can_manage_event_bookings(UserId, EventId) -> {error, not_found} end. -%%%------------------------------------------------------------------- -%%% @doc Проверка вместимости события. -%%% `undefined` и `0` означают неограниченную вместимость. -%%% @end -%%%------------------------------------------------------------------- -spec check_capacity(EventId :: binary(), Capacity :: integer() | undefined) -> {ok, integer() | unlimited} | {error, full}. check_capacity(_EventId, undefined) -> {ok, unlimited}; check_capacity(_EventId, 0) -> {ok, unlimited}; check_capacity(EventId, Capacity) -> - {ok, Bookings} = core_booking:list_by_event(EventId), - ConfirmedCount = length([B || B <- Bookings, B#booking.status =:= confirmed]), - case ConfirmedCount < Capacity of - true -> {ok, Capacity - ConfirmedCount}; + case occupied_slots(EventId) < Capacity of + true -> {ok, Capacity - occupied_slots(EventId)}; false -> {error, full} - end. \ No newline at end of file + 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. diff --git a/src/logic/logic_calendar.erl b/src/logic/logic_calendar.erl old mode 100644 new mode 100755 index 6cbd1dc..3a8adee --- a/src/logic/logic_calendar.erl +++ b/src/logic/logic_calendar.erl @@ -3,7 +3,7 @@ -export([create_calendar/3, create_calendar/4, get_calendar/2, list_calendars/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]). %% Создание календаря с политикой по умолчанию (manual) @@ -110,23 +110,11 @@ update_calendar(UserId, CalendarId, Updates) -> case can_edit(UserId, Calendar) of true -> ValidUpdates = validate_updates(Updates), - case content_fields(ValidUpdates, [title, description]) of - {[], []} -> - core_calendar:update(CalendarId, ValidUpdates); - {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), - core_calendar:get_by_id(CalendarId); - Error -> - Error - end - end + case gate_type_change(UserId, Calendar, ValidUpdates) of + {error, _} = Err -> + Err; + {ok, ValidUpdates2} -> + apply_calendar_update(CalendarId, Calendar, ValidUpdates2) end; false -> {error, access_denied} @@ -135,6 +123,66 @@ update_calendar(UserId, CalendarId, Updates) -> Error 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) -> lists:foldl(fun(F, {Fs, Ts}) -> case lists:keyfind(F, 1, Updates) of @@ -179,12 +227,28 @@ can_edit(_, _) -> %% Валидация полей обновления 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({description, Value}) when is_binary(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}) -> case Value of auto -> true; diff --git a/src/logic/logic_calendar_specialist.erl b/src/logic/logic_calendar_specialist.erl new file mode 100755 index 0000000..02e993e --- /dev/null +++ b/src/logic/logic_calendar_specialist.erl @@ -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. diff --git a/src/logic/logic_event.erl b/src/logic/logic_event.erl old mode 100644 new mode 100755 index f363436..73267bb --- a/src/logic/logic_event.erl +++ b/src/logic/logic_event.erl @@ -176,33 +176,38 @@ update_event(UserId, EventId, Updates) -> {ok, Calendar} -> case logic_calendar:can_edit(UserId, Calendar) of true -> - ValidUpdates = validate_updates(Updates, UserId), - Title = proplists:get_value(title, ValidUpdates, Event#event.title), - Desc = proplists:get_value(description, ValidUpdates, Event#event.description), - case logic_automoderation:evaluate_texts([Title, Desc]) of - {reject, Words} -> - {error, {content_banned, Words}}; - {ok, Action, [Title2, Desc2], Words} -> - Final0 = case lists:keymember(title, 1, ValidUpdates) of - true -> lists:keystore(title, 1, ValidUpdates, {title, Title2}); - false -> ValidUpdates - end, - Final = case lists:keymember(description, 1, Final0) orelse Desc2 =/= Event#event.description of - 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}); + case validate_specialist_update(Calendar, Updates) of + {error, _} = E -> + E; + ok -> + ValidUpdates = validate_updates(Updates, UserId), + Title = proplists:get_value(title, ValidUpdates, Event#event.title), + Desc = proplists:get_value(description, ValidUpdates, Event#event.description), + case logic_automoderation:evaluate_texts([Title, Desc]) of + {reject, Words} -> + {error, {content_banned, Words}}; + {ok, Action, [Title2, Desc2], Words} -> + Final0 = case lists:keymember(title, 1, ValidUpdates) of + true -> lists:keystore(title, 1, ValidUpdates, {title, Title2}); + false -> ValidUpdates + end, + Final = case lists:keymember(description, 1, Final0) orelse Desc2 =/= Event#event.description of + 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 - end; - false -> Final0 - end, - case core_event:update(EventId, Final) of - {ok, _} -> - logic_automoderation:apply_after_save(event, EventId, Action, Words), - core_event:get_by_id(EventId); - Error -> - Error + end, + case core_event:update(EventId, Final) of + {ok, _} -> + logic_automoderation:apply_after_save(event, EventId, Action, Words), + core_event:get_by_id(EventId); + Error -> + Error + end end end; false -> @@ -378,6 +383,8 @@ validate_update({start_time, Value}, UserId) -> end; 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, null}, _) -> true; +validate_update({specialist_id, undefined}, _) -> true; validate_update({location, Value}, _) -> case Value of #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(_, _) -> 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) -> Match = #recurrence_exception{master_id = MasterId, _ = '_'}, mnesia:dirty_match_object(Match). diff --git a/src/logic/logic_search.erl b/src/logic/logic_search.erl index e74c7d2..40d4d66 100755 --- a/src/logic/logic_search.erl +++ b/src/logic/logic_search.erl @@ -163,23 +163,24 @@ filter_accessible_events(Events, UserId) -> lists:filter(fun(Event) -> case core_calendar:get_by_id(Event#event.calendar_id) of {ok, Calendar} -> - CanAccess = logic_calendar:can_access(UserId, Calendar), - case CanAccess of - 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; + logic_calendar:can_access(UserId, Calendar) + andalso calendar_discoverable(Calendar); _ -> false end end, Events). -spec filter_accessible_calendars([#calendar{}], binary()) -> [#calendar{}]. 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, description => Calendar#calendar.description, type => Calendar#calendar.type, + booking_open => logic_calendar:booking_open(Calendar), tags => Calendar#calendar.tags, rating_avg => Calendar#calendar.rating_avg, rating_count => Calendar#calendar.rating_count, diff --git a/src/migrations/20260722150000_calendar_specialist_id.erl b/src/migrations/20260722150000_calendar_specialist_id.erl new file mode 100755 index 0000000..9958279 --- /dev/null +++ b/src/migrations/20260722150000_calendar_specialist_id.erl @@ -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. diff --git a/src/swagger/eventhub_trails.erl b/src/swagger/eventhub_trails.erl index fc9d435..ef3754e 100755 --- a/src/swagger/eventhub_trails.erl +++ b/src/swagger/eventhub_trails.erl @@ -73,6 +73,7 @@ user() -> handler_bookings, handler_calendar_by_id, handler_calendar_follow, + handler_calendar_specialists, handler_calendar_view, handler_calendars, handler_event_by_id, diff --git a/test/api/admins/admin_moderation_tests.erl b/test/api/admins/admin_moderation_tests.erl old mode 100644 new mode 100755 index a7dd3bf..ac46e94 --- a/test/api/admins/admin_moderation_tests.erl +++ b/test/api/admins/admin_moderation_tests.erl @@ -24,8 +24,11 @@ test() -> ct:pal("=== Admin Moderation Tests ==="), Token = api_test_runner:get_admin_token(), UserToken = api_test_runner:get_user_token(), - % Создаём календарь и событие - CalId = api_test_runner:create_calendar(UserToken, #{title => <<"ModTestCal">>}), + % Создаём commercial-календарь и событие (бронь только на commercial) + CalId = api_test_runner:create_calendar(UserToken, #{ + title => <<"ModTestCal">>, + type => <<"commercial">>, + confirmation => <<"manual">>}), EventId = api_test_runner:create_event(UserToken, CalId, #{ title => <<"Event to moderate">>, start_time => api_test_runner:future_date(), diff --git a/test/api/admins/admin_reviews_tests.erl b/test/api/admins/admin_reviews_tests.erl old mode 100644 new mode 100755 index a90b50d..df63f4c --- a/test/api/admins/admin_reviews_tests.erl +++ b/test/api/admins/admin_reviews_tests.erl @@ -28,8 +28,11 @@ test() -> ct:pal("=== Admin Reviews Tests ==="), Token = api_test_runner:get_admin_token(), UserToken = api_test_runner:get_user_token(), - %% Создаём тестовые данные: календарь, событие - CalId = api_test_runner:create_calendar(UserToken, #{title => <<"ReviewsTestCal">>}), + %% Создаём тестовые данные: commercial-календарь (бронь только на нём), событие + CalId = api_test_runner:create_calendar(UserToken, #{ + title => <<"ReviewsTestCal">>, + type => <<"commercial">>, + confirmation => <<"manual">>}), EventId = api_test_runner:create_event(UserToken, CalId, #{ title => <<"Event for review testing">>, start_time => api_test_runner:future_date(), diff --git a/test/api/admins/admin_stats_tests.erl b/test/api/admins/admin_stats_tests.erl old mode 100644 new mode 100755 index 57725f3..2e478ca --- a/test/api/admins/admin_stats_tests.erl +++ b/test/api/admins/admin_stats_tests.erl @@ -40,22 +40,27 @@ test() -> % Создаём тестовые данные для ненулевой статистики 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, #{ title => <<"Stats Event">>, start_time => api_test_runner:future_date(), duration => 60 }), - % Бронируем и подтверждаем, чтобы можно было оставить отзыв + % Бронируем участником (не владельцем) и подтверждаем, чтобы можно было оставить отзыв #{<<"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, #{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!">>}), % Жалоба - api_test_runner:client_post(<<"/v1/reports">>, UserToken, + api_test_runner:client_post(<<"/v1/reports">>, ParticipantToken, #{<<"target_type">> => <<"event">>, <<"target_id">> => EventId, <<"reason">> => <<"Test">>}), % Подписка SubUserToken = api_test_runner:get_user_token(), diff --git a/test/api/users/user_automod_tests.erl b/test/api/users/user_automod_tests.erl index 106d314..6785bbb 100755 --- a/test/api/users/user_automod_tests.erl +++ b/test/api/users/user_automod_tests.erl @@ -91,7 +91,8 @@ test_report_on_review(Admin) -> <<"report_threshold">> => 3 }), 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( <<"/v1/calendars/", CalId/binary, "/events">>, Owner, #{title => <<"Event for review report">>, diff --git a/test/api/users/user_bookings_tests.erl b/test/api/users/user_bookings_tests.erl old mode 100644 new mode 100755 index eaa220c..6df58a8 --- a/test/api/users/user_bookings_tests.erl +++ b/test/api/users/user_bookings_tests.erl @@ -31,7 +31,11 @@ test() -> 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( <<"/v1/calendars/", CalId/binary, "/events">>, OwnerToken, #{title => <<"Event to book">>, diff --git a/test/api/users/user_my_bookings_tests.erl b/test/api/users/user_my_bookings_tests.erl old mode 100644 new mode 100755 index 13793b3..03a20ba --- a/test/api/users/user_my_bookings_tests.erl +++ b/test/api/users/user_my_bookings_tests.erl @@ -27,7 +27,11 @@ test() -> 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( <<"/v1/calendars/", CalId/binary, "/events">>, OwnerToken, #{title => <<"Event for my booking">>, diff --git a/test/api/users/user_my_reviews_tests.erl b/test/api/users/user_my_reviews_tests.erl old mode 100644 new mode 100755 index dee38ed..5e8f1e5 --- a/test/api/users/user_my_reviews_tests.erl +++ b/test/api/users/user_my_reviews_tests.erl @@ -27,7 +27,8 @@ test() -> 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( <<"/v1/calendars/", CalId/binary, "/events">>, OwnerToken, #{title => <<"Event for my review">>, diff --git a/test/api/users/user_review_by_id_tests.erl b/test/api/users/user_review_by_id_tests.erl old mode 100644 new mode 100755 index 481cb74..04240c7 --- a/test/api/users/user_review_by_id_tests.erl +++ b/test/api/users/user_review_by_id_tests.erl @@ -34,7 +34,8 @@ test() -> 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( <<"/v1/calendars/", CalId/binary, "/events">>, OwnerToken, #{title => <<"Event for review">>, diff --git a/test/api/users/user_review_vote_tests.erl b/test/api/users/user_review_vote_tests.erl old mode 100644 new mode 100755 index 6ce890c..2ec1fd4 --- a/test/api/users/user_review_vote_tests.erl +++ b/test/api/users/user_review_vote_tests.erl @@ -22,7 +22,8 @@ test() -> OtherEmail = api_test_runner:unique_email(<<"rvother">>), 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( <<"/v1/calendars/", CalId/binary, "/events">>, OwnerToken, #{title => <<"Event for votes">>, diff --git a/test/api/users/user_reviews_tests.erl b/test/api/users/user_reviews_tests.erl old mode 100644 new mode 100755 index eae1a53..a4172e7 --- a/test/api/users/user_reviews_tests.erl +++ b/test/api/users/user_reviews_tests.erl @@ -31,7 +31,11 @@ test() -> 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( <<"/v1/calendars/", CalId/binary, "/events">>, OwnerToken, #{title => <<"Event for review">>, diff --git a/test/unit/booking_integration_tests.erl b/test/unit/booking_integration_tests.erl index c50ed58..34c6a64 100755 --- a/test/unit/booking_integration_tests.erl +++ b/test/unit/booking_integration_tests.erl @@ -2,7 +2,7 @@ -include_lib("eunit/include/eunit.hrl"). -include("records.hrl"). --define(TABLES, [user, calendar, event, booking, admin]). +-define(TABLES, [user, calendar, event, booking, admin, subscription]). setup() -> eh_test_support:start_mnesia(), @@ -19,7 +19,7 @@ booking_integration_test_() -> fun setup/0, 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}, {"Capacity management test", fun test_capacity_management/0}, {"Multiple bookings test", fun test_multiple_bookings/0} @@ -39,24 +39,25 @@ create_user() -> mnesia:dirty_write(User), 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) -> Sec = calendar:datetime_to_gregorian_seconds(DateTime) + Days * 86400, calendar:gregorian_seconds_to_datetime(Sec). -test_pending_booking_flow() -> +test_auto_booking_flow() -> OwnerId = create_user(), ParticipantId = create_user(), - - {ok, Calendar} = core_calendar:create(OwnerId, <<"Auto">>, <<"">>, auto), + Calendar = create_commercial(OwnerId, auto), StartTime = eh_test_support:future_start(), {ok, Event} = core_event:create(Calendar#calendar.id, <<"Event">>, StartTime, 60), {ok, Booking} = logic_booking:create_booking(ParticipantId, Event#event.id), - ?assertEqual(pending, Booking#booking.status), - - {ok, Stored} = core_booking:get_by_id(Booking#booking.id), - ?assertEqual(pending, Stored#booking.status), + ?assertEqual(confirmed, Booking#booking.status), {ok, EventBookings} = logic_booking:list_event_bookings(Event#event.id), ?assertEqual(1, length(EventBookings)), @@ -67,8 +68,7 @@ test_pending_booking_flow() -> test_manual_booking_flow() -> OwnerId = create_user(), ParticipantId = create_user(), - - {ok, Calendar} = core_calendar:create(OwnerId, <<"Manual">>, <<"">>, manual), + Calendar = create_commercial(OwnerId, manual), StartTime = eh_test_support:future_start(), {ok, Event} = core_event:create(Calendar#calendar.id, <<"Event">>, StartTime, 60), @@ -88,7 +88,7 @@ test_capacity_management() -> Participant2Id = create_user(), Participant3Id = create_user(), - {ok, Calendar} = core_calendar:create(OwnerId, <<"Test">>, <<"">>, manual), + Calendar = create_commercial(OwnerId, manual), StartTime = eh_test_support:future_start(), {ok, Event} = core_event:create(Calendar#calendar.id, <<"Event">>, StartTime, 60), @@ -108,8 +108,7 @@ test_capacity_management() -> test_multiple_bookings() -> OwnerId = create_user(), ParticipantId = create_user(), - - {ok, Calendar} = core_calendar:create(OwnerId, <<"Test">>, <<"">>, manual), + Calendar = create_commercial(OwnerId, manual), Base = eh_test_support:future_start(), StartTime1 = Base, diff --git a/test/unit/eh_test_support.erl b/test/unit/eh_test_support.erl index cb51252..a4b50b8 100755 --- a/test/unit/eh_test_support.erl +++ b/test/unit/eh_test_support.erl @@ -129,7 +129,8 @@ table_opts(calendar_share) -> table_opts(calendar_follow) -> [{ram_copies, [node()]}, {attributes, record_info(fields, calendar_follow)}]; 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) -> [{ram_copies, [node()]}, {attributes, record_info(fields, event)}]; table_opts(recurrence_exception) -> diff --git a/test/unit/logic_booking_tests.erl b/test/unit/logic_booking_tests.erl index 2f030b0..bfe6c95 100755 --- a/test/unit/logic_booking_tests.erl +++ b/test/unit/logic_booking_tests.erl @@ -2,7 +2,7 @@ -include_lib("eunit/include/eunit.hrl"). -include("records.hrl"). --define(TABLES, [user, calendar, event, booking, admin]). +-define(TABLES, [user, calendar, event, booking, admin, subscription, calendar_specialist]). setup() -> eh_test_support:start_mnesia(), @@ -19,14 +19,17 @@ logic_booking_test_() -> fun setup/0, 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 booking for missing event", fun test_booking_missing_event/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 as booker denied", fun test_confirm_booker_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}, {"Decline booking by owner", fun test_decline_booking/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 => <>}), Admin#admin.id. +ensure_subscription(OwnerId) -> + {ok, _} = core_subscription:create(OwnerId, monthly, true), + ok. + 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. create_test_event(CalendarId) -> @@ -75,18 +87,34 @@ create_test_event_with_capacity(CalendarId, Capacity) -> Updated#event.id. %% Тесты -test_create_booking_pending() -> +test_create_booking_auto() -> OwnerId = create_test_user(user), ParticipantId = create_test_user(user), CalendarId = create_test_calendar(OwnerId, auto), 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), ?assertEqual(pending, Booking#booking.status), {ok, Stored} = core_booking:get_by_id(Booking#booking.id), ?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() -> OwnerId = 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), {error, full} = logic_booking:create_booking(Participant2Id, EventId). -test_pending_does_not_fill() -> +test_pending_fills_capacity() -> OwnerId = create_test_user(user), Participant1Id = 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), {ok, _} = logic_booking:create_booking(Participant1Id, EventId), - {ok, B2} = logic_booking:create_booking(Participant2Id, EventId), - ?assertEqual(pending, B2#booking.status). + {error, full} = logic_booking:create_booking(Participant2Id, EventId). test_confirm_booking() -> OwnerId = create_test_user(user), @@ -151,6 +178,19 @@ test_confirm_stranger_denied() -> {ok, Booking} = logic_booking:create_booking(ParticipantId, EventId), {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() -> OwnerId = create_test_user(user), ParticipantId = create_test_user(user), diff --git a/test/unit/logic_calendar_tests.erl b/test/unit/logic_calendar_tests.erl old mode 100644 new mode 100755 index 41389d0..f0754ab --- a/test/unit/logic_calendar_tests.erl +++ b/test/unit/logic_calendar_tests.erl @@ -12,9 +12,14 @@ setup() -> {attributes, record_info(fields, calendar)}, {ram_copies, [node()]} ]), + mnesia:create_table(subscription, [ + {attributes, record_info(fields, subscription)}, + {ram_copies, [node()]} + ]), ok. cleanup(_) -> + mnesia:delete_table(subscription), mnesia:delete_table(calendar), mnesia:delete_table(user), mnesia:stop(), diff --git a/test/unit/logic_search_tests.erl b/test/unit/logic_search_tests.erl old mode 100644 new mode 100755 index 648e8cc..0aa6e04 --- a/test/unit/logic_search_tests.erl +++ b/test/unit/logic_search_tests.erl @@ -2,7 +2,7 @@ -include_lib("eunit/include/eunit.hrl"). -include("records.hrl"). --define(TABLES, [user, calendar, event]). +-define(TABLES, [user, calendar, event, subscription]). setup() -> eh_test_support:start_mnesia(), @@ -50,6 +50,12 @@ create_test_user(Role) -> UserId. 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), core_calendar:update(Calendar#calendar.id, [{type, Type}, {tags, Tags}]), {ok, Updated} = core_calendar:get_by_id(Calendar#calendar.id), diff --git a/test/unit/migration_engine_tests.erl b/test/unit/migration_engine_tests.erl index 672564e..2250fc7 100755 --- a/test/unit/migration_engine_tests.erl +++ b/test/unit/migration_engine_tests.erl @@ -11,7 +11,8 @@ "20260717180000_stats_counters", "20260717190000_admin_stats_indexes", "20260719210000_review_vote", - "20260720210000_calendar_follow" + "20260720210000_calendar_follow", + "20260722150000_calendar_specialist_id" ]). setup() -> diff --git a/test/unit/stats_tops_tests.erl b/test/unit/stats_tops_tests.erl old mode 100644 new mode 100755 index 9546d04..6125f59 --- a/test/unit/stats_tops_tests.erl +++ b/test/unit/stats_tops_tests.erl @@ -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_high">>, 5.0, 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), Ids = [E#event.id || E <- Top], ?assertEqual([<<"e_high">>, <<"e_mid">>], Ids).