diff --git a/include/records.hrl b/include/records.hrl index be6e6b3..2da130d 100755 --- a/include/records.hrl +++ b/include/records.hrl @@ -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(), diff --git a/src/handlers/handler_booking_by_id.erl b/src/handlers/handler_booking_by_id.erl index 9d50b7f..15ee414 100755 --- a/src/handlers/handler_booking_by_id.erl +++ b/src/handlers/handler_booking_by_id.erl @@ -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, _} -> diff --git a/src/handlers/handler_bookings.erl b/src/handlers/handler_bookings.erl index 2680e57..11d53af 100755 --- a/src/handlers/handler_bookings.erl +++ b/src/handlers/handler_bookings.erl @@ -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}, diff --git a/src/handlers/handler_user_bookings.erl b/src/handlers/handler_user_bookings.erl index a7c1b99..c018d2d 100755 --- a/src/handlers/handler_user_bookings.erl +++ b/src/handlers/handler_user_bookings.erl @@ -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}, diff --git a/src/logic/logic_booking.erl b/src/logic/logic_booking.erl index 605ec02..3552802 100755 --- a/src/logic/logic_booking.erl +++ b/src/logic/logic_booking.erl @@ -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) -> diff --git a/test/unit/logic_booking_tests.erl b/test/unit/logic_booking_tests.erl index 0bce0f1..1e31f67 100755 --- a/test/unit/logic_booking_tests.erl +++ b/test/unit/logic_booking_tests.erl @@ -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).