feat: GET /v1/user/booking-requests + search image_url. Fixes EventHub/EventHubBack#59
CI / test (push) Successful in 7m1s
CI / deploy-ift (push) Successful in 5m59s
CI / e2e-ift (push) Successful in 1m34s
CI / deploy-stage (push) Successful in 2m47s
CI / e2e-stage (push) Successful in 1m18s

This commit is contained in:
2026-07-27 14:53:25 +03:00
parent b25c0447cd
commit 1d9752bb69
8 changed files with 263 additions and 8 deletions
+5 -1
View File
@@ -6,7 +6,7 @@
-include("records.hrl"). -include("records.hrl").
-export([create/4, get_by_calendar_and_user/2, list_by_calendar/1, -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(), -spec create(CalendarId :: binary(), UserId :: binary(), Name :: binary(),
Specs :: [binary()]) -> Specs :: [binary()]) ->
@@ -51,6 +51,10 @@ get_by_calendar_and_user(CalendarId, UserId) ->
list_by_calendar(CalendarId) -> list_by_calendar(CalendarId) ->
mnesia:dirty_match_object(#calendar_specialist{calendar_id = 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()}]) -> -spec update(CalendarId :: binary(), UserId :: binary(), Updates :: [{atom(), term()}]) ->
{ok, #calendar_specialist{}} | {error, not_found | term()}. {ok, #calendar_specialist{}} | {error, not_found | term()}.
update(CalendarId, UserId, Updates) -> update(CalendarId, UserId, Updates) ->
+1
View File
@@ -91,6 +91,7 @@ start_http() ->
{"/v1/refresh", handler_refresh, []}, {"/v1/refresh", handler_refresh, []},
{"/v1/user/me", handler_user_me, []}, {"/v1/user/me", handler_user_me, []},
{"/v1/user/bookings", handler_user_bookings, []}, {"/v1/user/bookings", handler_user_bookings, []},
{"/v1/user/booking-requests", handler_user_booking_requests, []},
{"/v1/user/reviews", handler_user_reviews, []}, {"/v1/user/reviews", handler_user_reviews, []},
{"/v1/user/following", handler_user_following, []}, {"/v1/user/following", handler_user_following, []},
{"/v1/user/specialist-invites", handler_specialist_invites, []}, {"/v1/user/specialist-invites", handler_specialist_invites, []},
@@ -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
}.
+67 -1
View File
@@ -2,7 +2,8 @@
-include("records.hrl"). -include("records.hrl").
-export([create_booking/2, confirm_booking/2, confirm_booking/3, -export([create_booking/2, confirm_booking/2, confirm_booking/3,
cancel_booking/2, cancel_booking/3, get_booking/2, 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_bookings_admin/0, get_booking_admin/1,
list_event_bookings/1, list_event_bookings/2, list_event_bookings/1, list_event_bookings/2,
process_timeout_bookings/0, cancel_pending_for_owner/1, process_timeout_bookings/0, cancel_pending_for_owner/1,
@@ -165,6 +166,71 @@ list_bookings(EventId, UserId) ->
list_user_bookings(UserId) -> list_user_bookings(UserId) ->
core_booking:list_by_user(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()) -> -spec delete_booking(BookingId :: binary(), UserId :: binary()) ->
ok | {error, not_found | access_denied}. ok | {error, not_found | access_denied}.
delete_booking(BookingId, UserId) -> delete_booking(BookingId, UserId) ->
+12 -4
View File
@@ -325,9 +325,11 @@ format_event(Event) ->
#location{address = Addr, lat = Lat, lon = Lon} -> #location{address = Addr, lat = Lat, lon = Lon} ->
#{address => Addr, lat => Lat, lon => Lon} #{address => Addr, lat => Lat, lon => Lon}
end, end,
CalendarTitle = case core_calendar:get_by_id(Event#event.calendar_id) of {CalendarTitle, ImageUrl} = case core_calendar:get_by_id(Event#event.calendar_id) of
{ok, Cal} -> Cal#calendar.title; {ok, #calendar{title = T, image_url = <<>>}} -> {T, null};
_ -> null {ok, #calendar{title = T, image_url = undefined}} -> {T, null};
{ok, #calendar{title = T, image_url = Url}} -> {T, Url};
_ -> {null, null}
end, end,
#{ #{
id => Event#event.id, id => Event#event.id,
@@ -343,7 +345,8 @@ format_event(Event) ->
capacity => Event#event.capacity, capacity => Event#event.capacity,
rating_avg => Event#event.rating_avg, rating_avg => Event#event.rating_avg,
rating_count => Event#event.rating_count, rating_count => Event#event.rating_count,
status => Event#event.status status => Event#event.status,
image_url => ImageUrl
}. }.
-spec format_calendars([#calendar{}]) -> [map()]. -spec format_calendars([#calendar{}]) -> [map()].
@@ -359,6 +362,11 @@ format_calendar(Calendar) ->
description => Calendar#calendar.description, description => Calendar#calendar.description,
type => Calendar#calendar.type, type => Calendar#calendar.type,
booking_open => logic_calendar:booking_open(Calendar), 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, tags => Calendar#calendar.tags,
rating_avg => Calendar#calendar.rating_avg, rating_avg => Calendar#calendar.rating_avg,
rating_count => Calendar#calendar.rating_count, rating_count => Calendar#calendar.rating_count,
+1
View File
@@ -94,6 +94,7 @@ user() ->
handler_ticket_by_id, handler_ticket_by_id,
handler_tickets, handler_tickets,
handler_user_bookings, handler_user_bookings,
handler_user_booking_requests,
handler_user_following, handler_user_following,
handler_user_me, handler_user_me,
handler_user_reviews handler_user_reviews
+55 -1
View File
@@ -39,7 +39,10 @@ logic_booking_test_() ->
{"List event bookings", fun test_list_event_bookings/0}, {"List event bookings", fun test_list_event_bookings/0},
{"List event bookings as owner", fun test_list_event_bookings_owner/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 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), {ok, Bookings} = logic_booking:list_user_bookings(ParticipantId),
?assertEqual(2, length(Bookings)). ?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).
+9
View File
@@ -25,6 +25,7 @@ logic_search_test_() ->
{"Search events by location", fun test_search_events_by_location/0}, {"Search events by location", fun test_search_events_by_location/0},
{"Combined search", fun test_combined_search/0}, {"Combined search", fun test_combined_search/0},
{"Search calendars", fun test_search_calendars/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}, {"Search all", fun test_search_all/0},
{"Pagination", fun test_pagination/0}, {"Pagination", fun test_pagination/0},
{"Sorting", fun test_sorting/0}, {"Sorting", fun test_sorting/0},
@@ -175,6 +176,14 @@ test_search_calendars() ->
{Total2, _} = calendars_from(logic_search:search(<<"calendar">>, <<"Calendar">>, OwnerId, #{})), {Total2, _} = calendars_from(logic_search:search(<<"calendar">>, <<"Calendar">>, OwnerId, #{})),
?assertEqual(2, Total2). ?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() -> test_search_all() ->
OwnerId = create_test_user(user), OwnerId = create_test_user(user),
CalendarId = create_test_calendar(OwnerId, personal, []), CalendarId = create_test_calendar(OwnerId, personal, []),