Expire past-pending bookings to expired status. Fixes EventHub/EventHubBack#60
CI / test (push) Successful in 7m14s
CI / deploy-ift (push) Successful in 3m40s
CI / e2e-ift (push) Successful in 1m43s
CI / deploy-stage (push) Successful in 3m8s
CI / e2e-stage (push) Successful in 1m12s

This commit is contained in:
2026-07-27 16:45:30 +03:00
parent 1d9752bb69
commit edb7c20f70
6 changed files with 181 additions and 36 deletions
+1 -1
View File
@@ -192,7 +192,7 @@
id :: binary(),
event_id :: binary(), % ссылка на конкретный экземпляр события
user_id :: binary(),
status :: pending | confirmed | cancelled,
status :: pending | confirmed | cancelled | expired,
notes :: binary(),
reminder_sent :: boolean(),
confirmed_at :: calendar:datetime(),
+5 -1
View File
@@ -88,7 +88,7 @@ booking_schema() ->
id => #{type => string},
event_id => #{type => string},
user_id => #{type => string},
status => #{type => string, enum => [<<"pending">>, <<"confirmed">>, <<"cancelled">>]},
status => #{type => string, enum => [<<"pending">>, <<"confirmed">>, <<"cancelled">>, <<"expired">>]},
notes => #{type => string, nullable => true},
reminder_sent => #{type => boolean},
confirmed_at => #{type => string, format => <<"date-time">>, nullable => true},
@@ -143,6 +143,10 @@ update_booking(Req) ->
handler_utils:send_json(Req2, 200, booking_to_json(Booking));
{error, access_denied} ->
handler_utils:send_error(Req2, 403, <<"Access denied">>);
{error, expired} ->
handler_utils:send_error(Req2, 409, <<"Booking expired">>);
{error, full} ->
handler_utils:send_error(Req2, 409, <<"Event is full">>);
{error, not_found} ->
handler_utils:send_error(Req2, 404, <<"Booking not found">>);
{error, _} ->
+1 -1
View File
@@ -92,7 +92,7 @@ booking_schema() ->
id => #{type => string},
event_id => #{type => string},
user_id => #{type => string},
status => #{type => string, enum => [<<"pending">>, <<"confirmed">>, <<"cancelled">>]},
status => #{type => string, enum => [<<"pending">>, <<"confirmed">>, <<"cancelled">>, <<"expired">>]},
notes => #{type => string, nullable => true},
reminder_sent => #{type => boolean},
confirmed_at => #{type => string, format => <<"date-time">>, nullable => true},
+1 -1
View File
@@ -45,7 +45,7 @@ booking_schema() ->
id => #{type => string},
event_id => #{type => string},
user_id => #{type => string},
status => #{type => string, enum => [<<"pending">>, <<"confirmed">>, <<"cancelled">>]},
status => #{type => string, enum => [<<"pending">>, <<"confirmed">>, <<"cancelled">>, <<"expired">>]},
notes => #{type => string, nullable => true},
reminder_sent => #{type => boolean},
confirmed_at => #{type => string, format => <<"date-time">>, nullable => true},
+104 -31
View File
@@ -69,14 +69,14 @@ initial_status(_) -> pending.
%%%-------------------------------------------------------------------
-spec confirm_booking(BookingId :: binary(), UserId :: binary()) ->
{ok, #booking{}} | {error, not_found | access_denied | full}.
{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 Booking#booking.status of
pending ->
case ensure_pending_actionable(Booking) of
{ok, _} ->
case event_capacity_ok(Booking#booking.event_id) of
true ->
Now = calendar:universal_time(),
@@ -84,8 +84,8 @@ confirm_booking(BookingId, UserId) ->
false ->
{error, full}
end;
_ ->
{error, access_denied}
{error, Reason} ->
{error, Reason}
end;
{error, Reason} ->
{error, Reason}
@@ -94,7 +94,7 @@ confirm_booking(BookingId, UserId) ->
end.
-spec confirm_booking(UserId :: binary(), BookingId :: binary(), confirm | decline) ->
{ok, #booking{}} | {error, not_found | access_denied | full}.
{ok, #booking{}} | {error, not_found | access_denied | full | expired}.
confirm_booking(UserId, BookingId, confirm) ->
confirm_booking(BookingId, UserId);
confirm_booking(UserId, BookingId, decline) ->
@@ -102,11 +102,11 @@ confirm_booking(UserId, BookingId, decline) ->
{ok, Booking} ->
case can_manage_event_bookings(UserId, Booking#booking.event_id) of
true ->
case Booking#booking.status of
pending ->
case ensure_pending_actionable(Booking) of
{ok, _} ->
core_booking:update(BookingId, [{status, cancelled}]);
_ ->
{error, access_denied}
{error, Reason} ->
{error, Reason}
end;
{error, Reason} ->
{error, Reason}
@@ -122,6 +122,8 @@ cancel_booking(BookingId, UserId) ->
case Booking#booking.status of
cancelled ->
{ok, Booking};
expired ->
{ok, Booking};
_ ->
case Booking#booking.user_id =:= UserId of
true -> core_booking:update(BookingId, [{status, cancelled}]);
@@ -140,7 +142,8 @@ cancel_booking(UserId, BookingId, cancel) ->
{ok, #booking{}} | {error, not_found | access_denied}.
get_booking(BookingId, UserId) ->
case core_booking:get_by_id(BookingId) of
{ok, Booking} ->
{ok, Booking0} ->
Booking = ensure_not_past_pending(Booking0),
case Booking#booking.user_id =:= UserId of
true -> {ok, Booking};
false ->
@@ -160,11 +163,12 @@ list_bookings(EventId, UserId) ->
true -> Bookings;
false -> [B || B <- Bookings, B#booking.user_id =:= UserId]
end,
{ok, Filtered}.
{ok, [ensure_not_past_pending(B) || B <- Filtered]}.
-spec list_user_bookings(UserId :: binary()) -> {ok, [#booking{}]}.
list_user_bookings(UserId) ->
core_booking:list_by_user(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.
@@ -212,12 +216,26 @@ specialist_event_ids(UserId) ->
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} ->
[{B, Event, Role} || B <- Bookings, B#booking.status =:= pending];
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;
@@ -250,24 +268,25 @@ get_booking_admin(BookingId) ->
-spec list_event_bookings(EventId :: binary()) -> {ok, [#booking{}]}.
list_event_bookings(EventId) ->
core_booking:list_by_event(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 ->
core_booking:list_by_event(EventId);
list_event_bookings(EventId);
{error, Reason} ->
{error, Reason}
end.
-spec list_bookings_admin() -> {ok, [#booking{}]}.
list_bookings_admin() ->
{ok, core_booking:list_all()}.
{ok, [ensure_not_past_pending(B) || B <- core_booking:list_all()]}.
%%%-------------------------------------------------------------------
%%% @doc Авто-confirm/cancel по политике {timeout, N}.
%%% @doc Авто-confirm/cancel по политике {timeout, N}; past-pending → expired.
%%% @end
%%%-------------------------------------------------------------------
-spec process_timeout_bookings() -> ok.
@@ -280,23 +299,28 @@ process_timeout_bookings() ->
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
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 ->
Now = calendar:universal_time(),
_ = core_booking:update(Id, [{status, confirmed}, {confirmed_at, Now}]);
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 ->
_ = core_booking:update(Id, [{status, cancelled}])
ok
end;
false ->
_ ->
ok
end;
_ ->
ok
end
end;
_ ->
ok
@@ -338,6 +362,55 @@ cancel_pending_for_calendar(CalendarId) ->
%%% 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) ->
core_booking:update(BookingId, [{status, expired}]).
-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) ->
+69 -1
View File
@@ -42,7 +42,12 @@ logic_booking_test_() ->
{"List user bookings", fun test_list_user_bookings/0},
{"List booking requests as owner", fun test_list_booking_requests_owner/0},
{"List booking requests as specialist", fun test_list_booking_requests_specialist/0},
{"List booking requests stranger empty", fun test_list_booking_requests_stranger/0}
{"List booking requests stranger empty", fun test_list_booking_requests_stranger/0},
{"Past pending expires on list", fun test_past_pending_expires_on_list/0},
{"Past pending excluded from booking requests", fun test_past_pending_excluded_from_requests/0},
{"Past pending confirm denied", fun test_past_pending_confirm_denied/0},
{"Future pending still confirmable", fun test_future_pending_still_confirmable/0},
{"Process timeout expires past pending", fun test_process_timeout_expires_past/0}
]}.
%% Вспомогательные функции
@@ -83,6 +88,14 @@ create_test_event(CalendarId) ->
{ok, Event} = core_event:create(CalendarId, <<"Test Event">>, StartTime, 60),
Event#event.id.
create_test_event_at(CalendarId, StartTime) ->
{ok, Event} = core_event:create(CalendarId, <<"Test Event">>, StartTime, 60),
Event#event.id.
past_start() ->
Sec = calendar:datetime_to_gregorian_seconds(calendar:universal_time()) - 3600,
calendar:gregorian_seconds_to_datetime(Sec).
create_test_event_with_capacity(CalendarId, Capacity) ->
StartTime = eh_test_support:future_start(),
{ok, Event} = core_event:create(CalendarId, <<"Test Event">>, StartTime, 60),
@@ -355,3 +368,58 @@ test_list_booking_requests_stranger() ->
EventId = create_test_event(CalendarId),
{ok, _} = logic_booking:create_booking(ParticipantId, EventId),
{ok, []} = logic_booking:list_user_booking_requests(StrangerId).
test_past_pending_expires_on_list() ->
OwnerId = create_test_user(user),
ParticipantId = create_test_user(user),
CalendarId = create_test_calendar(OwnerId, manual),
EventId = create_test_event_at(CalendarId, past_start()),
{ok, Booking} = core_booking:create(EventId, ParticipantId, pending),
{ok, [Listed]} = logic_booking:list_user_bookings(ParticipantId),
?assertEqual(Booking#booking.id, Listed#booking.id),
?assertEqual(expired, Listed#booking.status),
{ok, Stored} = core_booking:get_by_id(Booking#booking.id),
?assertEqual(expired, Stored#booking.status).
test_past_pending_excluded_from_requests() ->
OwnerId = create_test_user(user),
ParticipantId = create_test_user(user),
CalendarId = create_test_calendar(OwnerId, manual),
PastId = create_test_event_at(CalendarId, past_start()),
FutureId = create_test_event(CalendarId),
{ok, _} = core_booking:create(PastId, ParticipantId, pending),
{ok, FutureBooking} = logic_booking:create_booking(ParticipantId, FutureId),
{ok, Items} = logic_booking:list_user_booking_requests(OwnerId),
?assertEqual(1, length(Items)),
[{B, Event, owner}] = Items,
?assertEqual(FutureBooking#booking.id, B#booking.id),
?assertEqual(FutureId, Event#event.id).
test_past_pending_confirm_denied() ->
OwnerId = create_test_user(user),
ParticipantId = create_test_user(user),
CalendarId = create_test_calendar(OwnerId, manual),
EventId = create_test_event_at(CalendarId, past_start()),
{ok, Booking} = core_booking:create(EventId, ParticipantId, pending),
{error, expired} = logic_booking:confirm_booking(Booking#booking.id, OwnerId),
{ok, Stored} = core_booking:get_by_id(Booking#booking.id),
?assertEqual(expired, Stored#booking.status).
test_future_pending_still_confirmable() ->
OwnerId = create_test_user(user),
ParticipantId = create_test_user(user),
CalendarId = create_test_calendar(OwnerId, manual),
EventId = create_test_event(CalendarId),
{ok, Booking} = logic_booking:create_booking(ParticipantId, EventId),
{ok, Confirmed} = logic_booking:confirm_booking(Booking#booking.id, OwnerId),
?assertEqual(confirmed, Confirmed#booking.status).
test_process_timeout_expires_past() ->
OwnerId = create_test_user(user),
ParticipantId = create_test_user(user),
CalendarId = create_test_calendar(OwnerId, manual),
EventId = create_test_event_at(CalendarId, past_start()),
{ok, Booking} = core_booking:create(EventId, ParticipantId, pending),
ok = logic_booking:process_timeout_bookings(),
{ok, Stored} = core_booking:get_by_id(Booking#booking.id),
?assertEqual(expired, Stored#booking.status).