Files
EventHubBack/src/logic/logic_booking.erl
T
aleksey 12274f9ed0
CI / test (push) Successful in 7m43s
CI / deploy-ift (push) Successful in 3m33s
CI / e2e-ift (push) Successful in 1m28s
CI / deploy-stage (push) Failing after 3m40s
CI / e2e-stage (push) Has been skipped
feat(notify): Web Push subscriptions and email/push prefs.
Prefs in user.preferences; push_subscription table; VAPID send on
reminder/waitlist. Refs EventHub/EventHubBack#75
2026-08-15 20:19:44 +03:00

661 lines
23 KiB
Erlang
Executable File

-module(logic_booking).
-include("records.hrl").
-export([create_booking/2, create_booking/3, confirm_booking/2, confirm_booking/3,
cancel_booking/2, cancel_booking/3, get_booking/2,
list_bookings/2, list_user_bookings/1, list_user_booking_requests/1,
list_user_studio_bookings/1,
delete_booking/2,
list_bookings_admin/0, get_booking_admin/1,
list_event_bookings/1, list_event_bookings/2,
process_timeout_bookings/0, process_reminders/0, cancel_pending_for_owner/1,
cancel_pending_for_calendar/1, can_manage_event_bookings/2]).
%%%-------------------------------------------------------------------
%%% @doc Создание бронирования с учётом commercial / confirmation / capacity.
%%% @end
%%%-------------------------------------------------------------------
-spec create_booking(UserId :: binary(), EventId :: binary()) ->
{ok, #booking{}} |
{error, full | already_booked | not_found | personal_calendar |
subscription_inactive | own_event | event_not_active | access_denied |
occurrence_start_required | invalid_occurrence}.
create_booking(UserId, EventId) ->
create_booking(UserId, EventId, undefined).
-spec create_booking(UserId :: binary(), EventId :: binary(),
OccurrenceStart :: calendar:datetime() | undefined) ->
{ok, #booking{}} |
{error, full | already_booked | not_found | personal_calendar |
subscription_inactive | own_event | event_not_active | access_denied |
occurrence_start_required | invalid_occurrence}.
create_booking(UserId, EventId, OccurrenceStart) ->
case core_event:get_by_id(EventId) of
{ok, #event{status = active} = Event} ->
case resolve_bookable_event(Event, OccurrenceStart) of
{ok, BookEvent} ->
case core_calendar:get_by_id(BookEvent#event.calendar_id) of
{ok, Calendar} ->
create_on_calendar(UserId, BookEvent, Calendar);
{error, not_found} ->
{error, not_found}
end;
{error, _} = Err ->
Err
end;
{ok, _} ->
{error, event_not_active};
{error, not_found} ->
{error, not_found}
end.
resolve_bookable_event(#event{event_type = recurring}, undefined) ->
{error, occurrence_start_required};
resolve_bookable_event(#event{event_type = recurring} = Master, OccurrenceStart) ->
case logic_event:validate_occurrence(Master, OccurrenceStart) of
ok ->
logic_event:materialize_for_booking(
Master#event.id, OccurrenceStart, Master#event.specialist_id);
{error, _} = Err ->
Err
end;
resolve_bookable_event(Event, _OccurrenceStart) ->
{ok, Event}.
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 | full | expired}.
confirm_booking(BookingId, UserId) ->
case core_booking:get_by_id(BookingId) of
{ok, Booking} ->
case can_manage_event_bookings(UserId, Booking#booking.event_id) of
true ->
case ensure_pending_actionable(Booking) of
{ok, _} ->
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, Reason} ->
{error, Reason}
end;
{error, Reason} ->
{error, Reason}
end;
Error -> Error
end.
-spec confirm_booking(UserId :: binary(), BookingId :: binary(), confirm | decline) ->
{ok, #booking{}} | {error, not_found | access_denied | full | expired}.
confirm_booking(UserId, BookingId, confirm) ->
confirm_booking(BookingId, UserId);
confirm_booking(UserId, BookingId, decline) ->
case core_booking:get_by_id(BookingId) of
{ok, Booking} ->
case can_manage_event_bookings(UserId, Booking#booking.event_id) of
true ->
case ensure_pending_actionable(Booking) of
{ok, _} ->
case core_booking:update(BookingId, [{status, cancelled}]) of
{ok, _} = Ok ->
_ = logic_waitlist:maybe_promote(Booking#booking.event_id),
Ok;
Err ->
Err
end;
{error, Reason} ->
{error, Reason}
end;
{error, Reason} ->
{error, Reason}
end;
Error -> Error
end.
-spec cancel_booking(BookingId :: binary(), UserId :: binary()) ->
{ok, #booking{}} | {error, not_found | access_denied}.
cancel_booking(BookingId, UserId) ->
case core_booking:get_by_id(BookingId) of
{ok, Booking} ->
case Booking#booking.status of
cancelled ->
{ok, Booking};
expired ->
{ok, Booking};
_ ->
case Booking#booking.user_id =:= UserId of
true ->
case core_booking:update(BookingId, [{status, cancelled}]) of
{ok, _} = Ok ->
_ = logic_waitlist:maybe_promote(Booking#booking.event_id),
Ok;
Err ->
Err
end;
false -> {error, access_denied}
end
end;
Error -> Error
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).
-spec get_booking(BookingId :: binary(), UserId :: binary()) ->
{ok, #booking{}} | {error, not_found | access_denied}.
get_booking(BookingId, UserId) ->
case core_booking:get_by_id(BookingId) of
{ok, Booking0} ->
Booking = ensure_not_past_pending(Booking0),
case Booking#booking.user_id =:= UserId of
true -> {ok, Booking};
false ->
case can_manage_event_bookings(UserId, Booking#booking.event_id) of
true -> {ok, Booking};
{error, _} -> {error, access_denied}
end
end;
Error -> Error
end.
-spec list_bookings(EventId :: binary(), UserId :: binary()) ->
{ok, [#booking{}]}.
list_bookings(EventId, UserId) ->
{ok, Bookings} = core_booking:list_by_event(EventId),
Filtered = case admin_utils:is_admin(UserId) of
true -> Bookings;
false -> [B || B <- Bookings, B#booking.user_id =:= UserId]
end,
{ok, [ensure_not_past_pending(B) || B <- Filtered]}.
-spec list_user_bookings(UserId :: binary()) -> {ok, [#booking{}]}.
list_user_bookings(UserId) ->
{ok, Bookings} = core_booking:list_by_user(UserId),
{ok, [ensure_not_past_pending(B) || B <- Bookings]}.
%%%-------------------------------------------------------------------
%%% @doc Pending bookings the user can confirm/decline as owner or specialist.
%%% Returns `{ok, [{Booking, Event, Role}]}` where Role is `owner` | `specialist`.
%%% @end
%%%-------------------------------------------------------------------
-spec list_user_booking_requests(UserId :: binary()) ->
{ok, [{#booking{}, #event{}, owner | specialist}]}.
list_user_booking_requests(UserId) ->
{OwnedEventIds, SpecOnly} = managed_event_ids(UserId),
OwnerItems = collect_pending(OwnedEventIds, owner),
SpecItems = collect_pending(SpecOnly, specialist),
{ok, sort_requests(OwnerItems ++ SpecItems)}.
-spec list_user_studio_bookings(UserId :: binary()) ->
{ok, [{#booking{}, #event{}, owner | specialist}]}.
list_user_studio_bookings(UserId) ->
{OwnedEventIds, SpecOnly} = managed_event_ids(UserId),
OwnerItems = collect_studio(OwnedEventIds, owner),
SpecItems = collect_studio(SpecOnly, specialist),
{ok, sort_requests(OwnerItems ++ SpecItems)}.
managed_event_ids(UserId) ->
OwnedEventIds = owned_event_ids(UserId),
SpecEventIds = specialist_event_ids(UserId),
OwnedSet = sets:from_list(OwnedEventIds),
SpecOnly = [E || E <- SpecEventIds, not sets:is_element(E, OwnedSet)],
{OwnedEventIds, SpecOnly}.
owned_event_ids(UserId) ->
case core_calendar:list_by_owner(UserId) of
{ok, Cals} ->
lists:flatmap(fun(#calendar{id = CalId}) ->
case core_event:list_active_including_instances(CalId) of
{ok, Events} -> [E#event.id || E <- Events];
_ -> []
end
end, Cals);
_ ->
[]
end.
specialist_event_ids(UserId) ->
Specs = [S || S <- core_calendar_specialist:list_by_user(UserId),
S#calendar_specialist.status =:= active],
lists:flatmap(fun(#calendar_specialist{calendar_id = CalId}) ->
case core_event:list_active_including_instances(CalId) of
{ok, Events} ->
[E#event.id || E <- Events,
is_binary(E#event.specialist_id),
E#event.specialist_id =/= <<>>,
E#event.specialist_id =:= UserId];
_ ->
[]
end
end, Specs).
collect_pending(EventIds, Role) ->
NowSec = calendar:datetime_to_gregorian_seconds(calendar:universal_time()),
lists:flatmap(fun(EventId) ->
case core_booking:list_by_event(EventId) of
{ok, Bookings} ->
case core_event:get_by_id(EventId) of
{ok, Event} ->
lists:filtermap(fun(B) ->
case B#booking.status of
pending ->
case event_started(Event, NowSec) of
true ->
_ = mark_expired(B#booking.id),
false;
false ->
{true, {B, Event, Role}}
end;
_ ->
false
end
end, Bookings);
_ ->
[]
end;
_ ->
[]
end
end, EventIds).
collect_studio(EventIds, Role) ->
NowSec = calendar:datetime_to_gregorian_seconds(calendar:universal_time()),
lists:flatmap(fun(EventId) ->
case core_booking:list_by_event(EventId) of
{ok, Bookings} ->
case core_event:get_by_id(EventId) of
{ok, Event} ->
lists:filtermap(fun(B) ->
case B#booking.status of
pending ->
case event_started(Event, NowSec) of
true ->
_ = mark_expired(B#booking.id),
false;
false ->
{true, {B, Event, Role}}
end;
confirmed ->
{true, {B, Event, Role}};
_ ->
false
end
end, Bookings);
_ ->
[]
end;
_ ->
[]
end
end, EventIds).
sort_requests(Items) ->
lists:sort(fun({A, _, _}, {B, _, _}) ->
A#booking.created_at >= B#booking.created_at
end, Items).
-spec delete_booking(BookingId :: binary(), UserId :: binary()) ->
ok | {error, not_found | access_denied}.
delete_booking(BookingId, UserId) ->
case core_booking:get_by_id(BookingId) of
{ok, Booking} ->
case Booking#booking.user_id =:= UserId of
true -> core_booking:delete(BookingId);
false -> {error, access_denied}
end;
Error -> Error
end.
-spec get_booking_admin(BookingId :: binary()) ->
{ok, #booking{}} | {error, not_found}.
get_booking_admin(BookingId) ->
core_booking:get_by_id(BookingId).
-spec list_event_bookings(EventId :: binary()) -> {ok, [#booking{}]}.
list_event_bookings(EventId) ->
{ok, Bookings} = core_booking:list_by_event(EventId),
{ok, [ensure_not_past_pending(B) || B <- Bookings]}.
-spec list_event_bookings(UserId :: binary(), EventId :: binary()) ->
{ok, [#booking{}]} | {error, not_found | access_denied}.
list_event_bookings(UserId, EventId) ->
case can_manage_event_bookings(UserId, EventId) of
true ->
list_event_bookings(EventId);
{error, Reason} ->
{error, Reason}
end.
-spec list_bookings_admin() -> {ok, [#booking{}]}.
list_bookings_admin() ->
{ok, [ensure_not_past_pending(B) || B <- core_booking:list_all()]}.
%%%-------------------------------------------------------------------
%%% @doc Авто-confirm/cancel по политике {timeout, N}; past-pending → expired.
%%% @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.
%%%-------------------------------------------------------------------
%%% @doc Email + in-app reminder before event start (Back#70).
%%% Confirmed bookings with reminder_sent=false whose event starts within
%%% REMINDER_LEAD_HOURS (default 24). Flag set before send (once).
%%% @end
%%%-------------------------------------------------------------------
-spec process_reminders() -> ok.
process_reminders() ->
NowSec = calendar:datetime_to_gregorian_seconds(calendar:universal_time()),
LeadSec = reminder_lead_hours() * 3600,
Horizon = NowSec + LeadSec,
Candidates = mnesia:dirty_match_object(
#booking{status = confirmed, reminder_sent = false, _ = '_'}),
lists:foreach(fun(B) -> maybe_remind(B, NowSec, Horizon) end, Candidates),
ok.
maybe_remind(#booking{id = Id, event_id = EventId, user_id = UserId} = Booking,
NowSec, Horizon) ->
case core_event:get_by_id(EventId) of
{ok, #event{status = active, start_time = Start, title = Title,
calendar_id = CalId} = _Event} ->
StartSec = calendar:datetime_to_gregorian_seconds(Start),
case StartSec > NowSec andalso StartSec =< Horizon of
true ->
case core_booking:update(Id, [{reminder_sent, true}]) of
{ok, _} ->
send_reminder(UserId, Title, Start, CalId, EventId),
ok;
_ ->
ok
end;
false ->
ok
end;
_ ->
ok
end,
Booking.
send_reminder(UserId, Title, Start, CalId, EventId) ->
Path = <<"/c/", CalId/binary, "/e/", EventId/binary>>,
WhenText = format_when(Start),
case logic_notification_prefs:email_enabled(UserId) of
true ->
case core_user:get_by_id(UserId) of
{ok, #user{email = Email}} when is_binary(Email), Email =/= <<>> ->
_ = logic_email:send_booking_reminder(Email, Title, Start, Path);
_ ->
ok
end;
false ->
ok
end,
_ = logic_notification:notify_event_reminder(UserId, #{
event_id => EventId,
calendar_id => CalId,
title => Title,
start_time => Start,
when_text => WhenText
}),
_ = logic_web_push:send(
UserId,
<<"Напоминание о записи"/utf8>>,
iolist_to_binary([<<"Скоро: «"/utf8>>, Title, <<"» — "/utf8>>, WhenText]),
Path
),
ok.
format_when({{Y, Mo, D}, {H, Mi, _S}}) ->
iolist_to_binary(io_lib:format("~4..0B-~2..0B-~2..0B ~2..0B:~2..0B",
[Y, Mo, D, H, Mi])).
reminder_lead_hours() ->
case application:get_env(eventhub, reminder_lead_hours) of
{ok, N} when is_integer(N), N > 0 -> N;
_ ->
case os:getenv("REMINDER_LEAD_HOURS") of
false -> 24;
"" -> 24;
S ->
try list_to_integer(S) of
N when N > 0 -> N;
_ -> 24
catch
_:_ -> 24
end
end
end.
maybe_timeout(#booking{id = Id, event_id = EventId, created_at = Created} = Booking, NowSec) ->
case core_event:get_by_id(EventId) of
{ok, Event} ->
case event_started(Event, NowSec) of
true ->
_ = mark_expired(Id);
false ->
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 ->
case core_booking:update(Id, [{status, cancelled}]) of
{ok, _} ->
_ = logic_waitlist:maybe_promote(EventId);
_ ->
ok
end
end;
false ->
ok
end;
_ ->
ok
end
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
%%%===================================================================
%% @doc Pending after event start is no longer actionable → expired.
-spec ensure_pending_actionable(#booking{}) ->
{ok, #booking{}} | {error, access_denied | expired | not_found}.
ensure_pending_actionable(#booking{status = pending} = Booking) ->
case core_event:get_by_id(Booking#booking.event_id) of
{ok, Event} ->
NowSec = calendar:datetime_to_gregorian_seconds(calendar:universal_time()),
case event_started(Event, NowSec) of
true ->
_ = mark_expired(Booking#booking.id),
{error, expired};
false ->
{ok, Booking}
end;
{error, not_found} ->
{error, not_found}
end;
ensure_pending_actionable(_) ->
{error, access_denied}.
-spec ensure_not_past_pending(#booking{}) -> #booking{}.
ensure_not_past_pending(#booking{status = pending, id = Id, event_id = EventId} = B) ->
case core_event:get_by_id(EventId) of
{ok, Event} ->
NowSec = calendar:datetime_to_gregorian_seconds(calendar:universal_time()),
case event_started(Event, NowSec) of
true ->
case mark_expired(Id) of
{ok, Updated} -> Updated;
_ -> B#booking{status = expired}
end;
false ->
B
end;
_ ->
B
end;
ensure_not_past_pending(B) ->
B.
-spec mark_expired(binary()) -> {ok, #booking{}} | {error, term()}.
mark_expired(BookingId) ->
case core_booking:get_by_id(BookingId) of
{ok, #booking{event_id = EventId}} ->
case core_booking:update(BookingId, [{status, expired}]) of
{ok, _} = Ok ->
_ = logic_waitlist:maybe_promote(EventId),
Ok;
Err ->
Err
end;
{error, _} = Err ->
Err
end.
-spec event_started(#event{}, non_neg_integer()) -> boolean().
event_started(#event{start_time = Start}, NowSec) ->
StartSec = calendar:datetime_to_gregorian_seconds(Start),
NowSec >= StartSec.
-spec can_manage_event_bookings(UserId :: binary(), EventId :: binary()) ->
true | {error, not_found | access_denied}.
can_manage_event_bookings(UserId, EventId) ->
case core_event:get_by_id(EventId) of
{ok, Event} ->
case core_calendar:get_by_id(Event#event.calendar_id) of
{ok, Calendar} ->
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;
{error, not_found} ->
{error, not_found}
end;
{error, not_found} ->
{error, not_found}
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) ->
case occupied_slots(EventId) < Capacity of
true -> {ok, Capacity - occupied_slots(EventId)};
false -> {error, full}
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.