From 25675853336a4ebbb3f5f60d75d09e8f1fcce2cd Mon Sep 17 00:00:00 2001 From: Aleksey Sabilin Date: Fri, 14 Aug 2026 11:13:01 +0300 Subject: [PATCH] feat(booking): studio journal and instance inbox. Refs EventHub/EventHubFront#60 --- src/core/core_event.erl | 13 +++- src/eventhub_app.erl | 1 + .../handler_user_booking_requests.erl | 7 ++- src/handlers/handler_user_studio_bookings.erl | 59 +++++++++++++++++++ src/logic/logic_booking.erl | 55 +++++++++++++++-- src/swagger/eventhub_trails.erl | 1 + test/unit/logic_booking_tests.erl | 29 +++++++++ 7 files changed, 155 insertions(+), 10 deletions(-) create mode 100644 src/handlers/handler_user_studio_bookings.erl diff --git a/src/core/core_event.erl b/src/core/core_event.erl index a31666b..97aed5c 100644 --- a/src/core/core_event.erl +++ b/src/core/core_event.erl @@ -1,6 +1,7 @@ -module(core_event). -include("records.hrl"). --export([create/4, create_recurring/5, get_by_id/1, list_by_calendar/1, update/2, delete/1, +-export([create/4, create_recurring/5, get_by_id/1, list_by_calendar/1, + list_active_including_instances/1, update/2, delete/1, materialize_occurrence/3]). -export([count_events/0, count_events_by_date/2]). -export([freeze/2, unfreeze/2]). @@ -189,6 +190,16 @@ list_by_calendar(CalendarId) -> E#event.status =:= active andalso E#event.is_instance =:= false], {ok, Events}. +%%%------------------------------------------------------------------- +%%% @doc Active events including materialized occurrences (for booking inbox). +%%% @end +%%%------------------------------------------------------------------- +-spec list_active_including_instances(CalendarId :: binary()) -> {ok, [#event{}]}. +list_active_including_instances(CalendarId) -> + Candidates = mnesia:dirty_index_read(event, CalendarId, #event.calendar_id), + Events = [E || E <- Candidates, E#event.status =:= active], + {ok, Events}. + %%%------------------------------------------------------------------- %%% @doc Обновить поля события. %%% `Updates` – список пар `[{atom(), term()}]`. diff --git a/src/eventhub_app.erl b/src/eventhub_app.erl index 33b9fa3..9996b1e 100755 --- a/src/eventhub_app.erl +++ b/src/eventhub_app.erl @@ -92,6 +92,7 @@ start_http() -> {"/v1/user/me", handler_user_me, []}, {"/v1/user/bookings", handler_user_bookings, []}, {"/v1/user/booking-requests", handler_user_booking_requests, []}, + {"/v1/user/studio-bookings", handler_user_studio_bookings, []}, {"/v1/user/reviews", handler_user_reviews, []}, {"/v1/user/following", handler_user_following, []}, {"/v1/user/specialist-invites", handler_specialist_invites, []}, diff --git a/src/handlers/handler_user_booking_requests.erl b/src/handlers/handler_user_booking_requests.erl index 9ba1c57..6e99375 100644 --- a/src/handlers/handler_user_booking_requests.erl +++ b/src/handlers/handler_user_booking_requests.erl @@ -8,6 +8,7 @@ -export([init/2]). -export([trails/0]). +-export([item_to_json/1]). -include("records.hrl"). @@ -76,7 +77,7 @@ list_requests(Req) -> {ok, UserId, Req1} -> case logic_booking:list_user_booking_requests(UserId) of {ok, Items} -> - Response = [request_to_json(I) || I <- Items], + Response = [item_to_json(I) || I <- Items], handler_utils:send_json(Req1, 200, Response); {error, _} -> handler_utils:send_error(Req1, 500, <<"Internal server error">>) @@ -85,8 +86,8 @@ list_requests(Req) -> handler_utils:send_error(Req1, Code, Message) end. --spec request_to_json({#booking{}, #event{}, owner | specialist}) -> map(). -request_to_json({Booking, Event, Role}) -> +-spec item_to_json({#booking{}, #event{}, owner | specialist}) -> map(). +item_to_json({Booking, Event, Role}) -> CalendarTitle = case core_calendar:get_by_id(Event#event.calendar_id) of {ok, Cal} -> Cal#calendar.title; _ -> null diff --git a/src/handlers/handler_user_studio_bookings.erl b/src/handlers/handler_user_studio_bookings.erl new file mode 100644 index 0000000..c0f8970 --- /dev/null +++ b/src/handlers/handler_user_studio_bookings.erl @@ -0,0 +1,59 @@ +%%%------------------------------------------------------------------- +%%% @doc Confirmed + pending bookings on calendars the user owns or staffs. +%%% GET /v1/user/studio-bookings +%%% @end +%%%------------------------------------------------------------------- +-module(handler_user_studio_bookings). +-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() -> + [ + #{ + path => <<"/v1/user/studio-bookings">>, + method => <<"GET">>, + description => <<"Pending and confirmed bookings on owned/staffed calendars">>, + tags => [<<"Bookings">>], + responses => #{ + 200 => #{ + description => <<"Array of enriched studio bookings">>, + content => #{<<"application/json">> => #{schema => #{ + type => array, + items => #{type => object} + }}} + }, + 401 => #{description => <<"Unauthorized">>} + } + } + ]. + +-spec handle(cowboy_req:req(), any()) -> {ok, cowboy_req:req(), any()}. +handle(Req, _Opts) -> + case cowboy_req:method(Req) of + <<"GET">> -> list_studio(Req); + _ -> handler_utils:send_error(Req, 405, <<"Method not allowed">>) + end. + +-spec list_studio(cowboy_req:req()) -> {ok, cowboy_req:req(), any()}. +list_studio(Req) -> + case handler_utils:auth_user(Req) of + {ok, UserId, Req1} -> + case logic_booking:list_user_studio_bookings(UserId) of + {ok, Items} -> + Response = [handler_user_booking_requests:item_to_json(I) || I <- Items], + handler_utils:send_json(Req1, 200, Response); + {error, _} -> + handler_utils:send_error(Req1, 500, <<"Internal server error">>) + end; + {error, Code, Message, Req1} -> + handler_utils:send_error(Req1, Code, Message) + end. diff --git a/src/logic/logic_booking.erl b/src/logic/logic_booking.erl index 965361f..d8d05ab 100755 --- a/src/logic/logic_booking.erl +++ b/src/logic/logic_booking.erl @@ -3,6 +3,7 @@ -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, @@ -206,20 +207,31 @@ list_user_bookings(UserId) -> -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)], - OwnerItems = collect_pending(OwnedEventIds, owner), - SpecItems = collect_pending(SpecOnly, specialist), - Items = sort_requests(OwnerItems ++ SpecItems), - {ok, Items}. + {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_by_calendar(CalId) of + case core_event:list_active_including_instances(CalId) of {ok, Events} -> [E#event.id || E <- Events]; _ -> [] end @@ -232,7 +244,7 @@ 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_by_calendar(CalId) of + case core_event:list_active_including_instances(CalId) of {ok, Events} -> [E#event.id || E <- Events, is_binary(E#event.specialist_id), @@ -272,6 +284,37 @@ collect_pending(EventIds, Role) -> 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 diff --git a/src/swagger/eventhub_trails.erl b/src/swagger/eventhub_trails.erl index 9d14fdf..8ce5e27 100755 --- a/src/swagger/eventhub_trails.erl +++ b/src/swagger/eventhub_trails.erl @@ -95,6 +95,7 @@ user() -> handler_tickets, handler_user_bookings, handler_user_booking_requests, + handler_user_studio_bookings, handler_user_following, handler_user_me, handler_user_reviews diff --git a/test/unit/logic_booking_tests.erl b/test/unit/logic_booking_tests.erl index 918f256..7b43242 100755 --- a/test/unit/logic_booking_tests.erl +++ b/test/unit/logic_booking_tests.erl @@ -48,6 +48,8 @@ logic_booking_test_() -> {"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}, + {"Recurring pending in owner inbox via instance", fun test_recurring_pending_in_inbox/0}, + {"Studio bookings include confirmed after confirm", fun test_studio_bookings_after_confirm/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}, @@ -426,6 +428,33 @@ test_list_booking_requests_stranger() -> {ok, _} = logic_booking:create_booking(ParticipantId, EventId), {ok, []} = logic_booking:list_user_booking_requests(StrangerId). +test_recurring_pending_in_inbox() -> + OwnerId = create_test_user(user), + ParticipantId = create_test_user(user), + CalendarId = create_test_calendar(OwnerId, manual), + {MasterId, StartTime} = create_recurring_event(CalendarId), + Occ = add_days(StartTime, 7), + {ok, Booking} = logic_booking:create_booking(ParticipantId, MasterId, Occ), + {ok, Items} = logic_booking:list_user_booking_requests(OwnerId), + ?assertEqual(1, length(Items)), + [{B, Event, owner}] = Items, + ?assertEqual(Booking#booking.id, B#booking.id), + ?assertEqual(true, Event#event.is_instance), + ?assertEqual(Occ, Event#event.start_time). + +test_studio_bookings_after_confirm() -> + 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, _} = logic_booking:confirm_booking(OwnerId, Booking#booking.id, confirm), + {ok, []} = logic_booking:list_user_booking_requests(OwnerId), + {ok, Studio} = logic_booking:list_user_studio_bookings(OwnerId), + ?assertEqual(1, length(Studio)), + [{B, _Event, owner}] = Studio, + ?assertEqual(confirmed, B#booking.status). + test_past_pending_expires_on_list() -> OwnerId = create_test_user(user), ParticipantId = create_test_user(user),