From 1d9752bb69113ccb0e3303375c663478f6ab5bd8 Mon Sep 17 00:00:00 2001 From: Aleksey Sabilin Date: Mon, 27 Jul 2026 14:53:25 +0300 Subject: [PATCH] feat: GET /v1/user/booking-requests + search image_url. Fixes EventHub/EventHubBack#59 --- src/core/core_calendar_specialist.erl | 6 +- src/eventhub_app.erl | 1 + .../handler_user_booking_requests.erl | 112 ++++++++++++++++++ src/logic/logic_booking.erl | 68 ++++++++++- src/logic/logic_search.erl | 18 ++- src/swagger/eventhub_trails.erl | 1 + test/unit/logic_booking_tests.erl | 56 ++++++++- test/unit/logic_search_tests.erl | 9 ++ 8 files changed, 263 insertions(+), 8 deletions(-) create mode 100644 src/handlers/handler_user_booking_requests.erl diff --git a/src/core/core_calendar_specialist.erl b/src/core/core_calendar_specialist.erl index 33b8b25..f54e5b0 100755 --- a/src/core/core_calendar_specialist.erl +++ b/src/core/core_calendar_specialist.erl @@ -6,7 +6,7 @@ -include("records.hrl"). -export([create/4, get_by_calendar_and_user/2, list_by_calendar/1, - update/3, delete/2, is_active_specialist/2]). + list_by_user/1, update/3, delete/2, is_active_specialist/2]). -spec create(CalendarId :: binary(), UserId :: binary(), Name :: binary(), Specs :: [binary()]) -> @@ -51,6 +51,10 @@ get_by_calendar_and_user(CalendarId, UserId) -> list_by_calendar(CalendarId) -> mnesia:dirty_match_object(#calendar_specialist{calendar_id = CalendarId, _ = '_'}). +-spec list_by_user(UserId :: binary()) -> [#calendar_specialist{}]. +list_by_user(UserId) -> + mnesia:dirty_match_object(#calendar_specialist{user_id = UserId, _ = '_'}). + -spec update(CalendarId :: binary(), UserId :: binary(), Updates :: [{atom(), term()}]) -> {ok, #calendar_specialist{}} | {error, not_found | term()}. update(CalendarId, UserId, Updates) -> diff --git a/src/eventhub_app.erl b/src/eventhub_app.erl index a3a5166..33b9fa3 100755 --- a/src/eventhub_app.erl +++ b/src/eventhub_app.erl @@ -91,6 +91,7 @@ start_http() -> {"/v1/refresh", handler_refresh, []}, {"/v1/user/me", handler_user_me, []}, {"/v1/user/bookings", handler_user_bookings, []}, + {"/v1/user/booking-requests", handler_user_booking_requests, []}, {"/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 new file mode 100644 index 0000000..9ba1c57 --- /dev/null +++ b/src/handlers/handler_user_booking_requests.erl @@ -0,0 +1,112 @@ +%%%------------------------------------------------------------------- +%%% @doc Pending booking requests for the current user (owner / specialist inbox). +%%% GET /v1/user/booking-requests +%%% @end +%%%------------------------------------------------------------------- +-module(handler_user_booking_requests). +-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/booking-requests">>, + method => <<"GET">>, + description => <<"List pending booking requests the user can confirm/decline">>, + tags => [<<"Bookings">>], + responses => #{ + 200 => #{ + description => <<"Array of enriched booking requests">>, + content => #{<<"application/json">> => #{schema => #{ + type => array, + items => request_schema() + }}} + }, + 401 => #{description => <<"Unauthorized">>} + } + } + ]. + +request_schema() -> + #{ + type => object, + properties => #{ + id => #{type => string}, + event_id => #{type => string}, + user_id => #{type => string}, + status => #{type => string, enum => [<<"pending">>]}, + role => #{type => string, enum => [<<"owner">>, <<"specialist">>]}, + user_nickname => #{type => string, nullable => true}, + user_email => #{type => string, nullable => true}, + created_at => #{type => string, format => <<"date-time">>}, + event => #{ + type => object, + properties => #{ + id => #{type => string}, + calendar_id => #{type => string}, + calendar_title => #{type => string, nullable => true}, + title => #{type => string}, + start_time => #{type => string, format => <<"date-time">>}, + duration => #{type => integer}, + specialist_id => #{type => string, nullable => true} + } + } + } + }. + +-spec handle(cowboy_req:req(), any()) -> {ok, cowboy_req:req(), any()}. +handle(Req, _Opts) -> + case cowboy_req:method(Req) of + <<"GET">> -> list_requests(Req); + _ -> handler_utils:send_error(Req, 405, <<"Method not allowed">>) + end. + +-spec list_requests(cowboy_req:req()) -> {ok, cowboy_req:req(), any()}. +list_requests(Req) -> + case handler_utils:auth_user(Req) of + {ok, UserId, Req1} -> + case logic_booking:list_user_booking_requests(UserId) of + {ok, Items} -> + Response = [request_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. + +-spec request_to_json({#booking{}, #event{}, owner | specialist}) -> map(). +request_to_json({Booking, Event, Role}) -> + CalendarTitle = case core_calendar:get_by_id(Event#event.calendar_id) of + {ok, Cal} -> Cal#calendar.title; + _ -> null + end, + SpecialistId = case Event#event.specialist_id of + <<>> -> null; + undefined -> null; + Sid -> Sid + end, + Base = handler_utils:booking_to_json(Booking), + EventMap = #{ + id => Event#event.id, + calendar_id => Event#event.calendar_id, + calendar_title => CalendarTitle, + title => Event#event.title, + start_time => handler_utils:datetime_to_iso8601(Event#event.start_time), + duration => Event#event.duration, + specialist_id => SpecialistId + }, + Base#{ + role => Role, + event => EventMap + }. diff --git a/src/logic/logic_booking.erl b/src/logic/logic_booking.erl index 875cf6a..605ec02 100755 --- a/src/logic/logic_booking.erl +++ b/src/logic/logic_booking.erl @@ -2,7 +2,8 @@ -include("records.hrl"). -export([create_booking/2, confirm_booking/2, confirm_booking/3, cancel_booking/2, cancel_booking/3, get_booking/2, - list_bookings/2, list_user_bookings/1, delete_booking/2, + list_bookings/2, list_user_bookings/1, list_user_booking_requests/1, + delete_booking/2, list_bookings_admin/0, get_booking_admin/1, list_event_bookings/1, list_event_bookings/2, process_timeout_bookings/0, cancel_pending_for_owner/1, @@ -165,6 +166,71 @@ list_bookings(EventId, UserId) -> list_user_bookings(UserId) -> core_booking:list_by_user(UserId). +%%%------------------------------------------------------------------- +%%% @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 = 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}. + +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 + {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_by_calendar(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) -> + 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]; + _ -> + [] + 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) -> diff --git a/src/logic/logic_search.erl b/src/logic/logic_search.erl index 40d4d66..02ce768 100755 --- a/src/logic/logic_search.erl +++ b/src/logic/logic_search.erl @@ -325,10 +325,12 @@ format_event(Event) -> #location{address = Addr, lat = Lat, lon = Lon} -> #{address => Addr, lat => Lat, lon => Lon} end, - CalendarTitle = case core_calendar:get_by_id(Event#event.calendar_id) of - {ok, Cal} -> Cal#calendar.title; - _ -> null - end, + {CalendarTitle, ImageUrl} = case core_calendar:get_by_id(Event#event.calendar_id) of + {ok, #calendar{title = T, image_url = <<>>}} -> {T, null}; + {ok, #calendar{title = T, image_url = undefined}} -> {T, null}; + {ok, #calendar{title = T, image_url = Url}} -> {T, Url}; + _ -> {null, null} + end, #{ id => Event#event.id, calendar_id => Event#event.calendar_id, @@ -343,7 +345,8 @@ format_event(Event) -> capacity => Event#event.capacity, rating_avg => Event#event.rating_avg, rating_count => Event#event.rating_count, - status => Event#event.status + status => Event#event.status, + image_url => ImageUrl }. -spec format_calendars([#calendar{}]) -> [map()]. @@ -359,6 +362,11 @@ format_calendar(Calendar) -> description => Calendar#calendar.description, type => Calendar#calendar.type, booking_open => logic_calendar:booking_open(Calendar), + image_url => case Calendar#calendar.image_url of + <<>> -> null; + undefined -> null; + Url -> Url + end, tags => Calendar#calendar.tags, rating_avg => Calendar#calendar.rating_avg, rating_count => Calendar#calendar.rating_count, diff --git a/src/swagger/eventhub_trails.erl b/src/swagger/eventhub_trails.erl index 7ff41cb..9d14fdf 100755 --- a/src/swagger/eventhub_trails.erl +++ b/src/swagger/eventhub_trails.erl @@ -94,6 +94,7 @@ user() -> handler_ticket_by_id, handler_tickets, handler_user_bookings, + handler_user_booking_requests, 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 bfe6c95..0bce0f1 100755 --- a/test/unit/logic_booking_tests.erl +++ b/test/unit/logic_booking_tests.erl @@ -39,7 +39,10 @@ logic_booking_test_() -> {"List event bookings", fun test_list_event_bookings/0}, {"List event bookings as owner", fun test_list_event_bookings_owner/0}, {"List event bookings non-owner denied", fun test_list_event_bookings_non_owner/0}, - {"List user bookings", fun test_list_user_bookings/0} + {"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} ]}. %% Вспомогательные функции @@ -301,3 +304,54 @@ test_list_user_bookings() -> {ok, Bookings} = logic_booking:list_user_bookings(ParticipantId), ?assertEqual(2, length(Bookings)). + +test_list_booking_requests_owner() -> + 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, Items} = logic_booking:list_user_booking_requests(OwnerId), + ?assertEqual(1, length(Items)), + [{B, Event, Role}] = Items, + ?assertEqual(Booking#booking.id, B#booking.id), + ?assertEqual(EventId, Event#event.id), + ?assertEqual(owner, Role), + + %% participant inbox is separate — no manage requests for own booking + {ok, []} = logic_booking:list_user_booking_requests(ParticipantId). + +test_list_booking_requests_specialist() -> + OwnerId = create_test_user(user), + SpecId = create_test_user(user), + ParticipantId = create_test_user(user), + CalendarId = create_test_calendar(OwnerId, manual), + {ok, _} = core_calendar_specialist:create(CalendarId, SpecId, <<"Spec">>, []), + SpecEventId = create_test_event(CalendarId), + OtherEventId = create_test_event(CalendarId), + {ok, _} = core_event:update(SpecEventId, [{specialist_id, SpecId}]), + + {ok, SpecBooking} = logic_booking:create_booking(ParticipantId, SpecEventId), + OtherParticipant = create_test_user(user), + {ok, _} = logic_booking:create_booking(OtherParticipant, OtherEventId), + + {ok, SpecItems} = logic_booking:list_user_booking_requests(SpecId), + ?assertEqual(1, length(SpecItems)), + [{B, Event, Role}] = SpecItems, + ?assertEqual(SpecBooking#booking.id, B#booking.id), + ?assertEqual(SpecEventId, Event#event.id), + ?assertEqual(specialist, Role), + + %% owner sees both pending + {ok, OwnerItems} = logic_booking:list_user_booking_requests(OwnerId), + ?assertEqual(2, length(OwnerItems)). + +test_list_booking_requests_stranger() -> + OwnerId = create_test_user(user), + ParticipantId = create_test_user(user), + StrangerId = create_test_user(user), + CalendarId = create_test_calendar(OwnerId, manual), + EventId = create_test_event(CalendarId), + {ok, _} = logic_booking:create_booking(ParticipantId, EventId), + {ok, []} = logic_booking:list_user_booking_requests(StrangerId). diff --git a/test/unit/logic_search_tests.erl b/test/unit/logic_search_tests.erl index 0aa6e04..4783038 100755 --- a/test/unit/logic_search_tests.erl +++ b/test/unit/logic_search_tests.erl @@ -25,6 +25,7 @@ logic_search_test_() -> {"Search events by location", fun test_search_events_by_location/0}, {"Combined search", fun test_combined_search/0}, {"Search calendars", fun test_search_calendars/0}, + {"Search calendars include image_url", fun test_search_calendars_image_url/0}, {"Search all", fun test_search_all/0}, {"Pagination", fun test_pagination/0}, {"Sorting", fun test_sorting/0}, @@ -175,6 +176,14 @@ test_search_calendars() -> {Total2, _} = calendars_from(logic_search:search(<<"calendar">>, <<"Calendar">>, OwnerId, #{})), ?assertEqual(2, Total2). +test_search_calendars_image_url() -> + OwnerId = create_test_user(user), + CalendarId = create_test_calendar(OwnerId, commercial, [<<"cover">>]), + {ok, _} = core_calendar:update(CalendarId, [{image_url, <<"https://cdn.example/cal.jpg">>}]), + {_, Cals} = calendars_from(logic_search:search(<<"calendar">>, <<"Calendar">>, OwnerId, #{})), + [Hit | _] = [C || C <- Cals, maps:get(id, C) =:= CalendarId], + ?assertEqual(<<"https://cdn.example/cal.jpg">>, maps:get(image_url, Hit)). + test_search_all() -> OwnerId = create_test_user(user), CalendarId = create_test_calendar(OwnerId, personal, []),