-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.