feat(waitlist): optional event waitlist with FIFO promote.
Commercial calendars opt in via settings.waitlist_enabled; join when full; auto-promote on cancel/decline/expire with email and in-app notify. Refs EventHub/EventHubBack#72
This commit is contained in:
+11
-1
@@ -200,6 +200,16 @@
|
||||
updated_at :: calendar:datetime()
|
||||
}).
|
||||
|
||||
%% ------------------- Лист ожидания (commercial, settings.waitlist_enabled) ----
|
||||
-record(waitlist_entry, {
|
||||
id :: binary(),
|
||||
event_id :: binary(),
|
||||
user_id :: binary(),
|
||||
status :: waiting | promoted | left,
|
||||
created_at :: calendar:datetime(),
|
||||
updated_at :: calendar:datetime()
|
||||
}).
|
||||
|
||||
%% ------------------- Отзывы ------------------------------------------
|
||||
-record(review, {
|
||||
id :: binary(),
|
||||
@@ -319,7 +329,7 @@
|
||||
id :: binary(),
|
||||
user_id :: binary(),
|
||||
type :: booking_confirmed | event_reminder | event_cancelled |
|
||||
specialist_invite | custom,
|
||||
specialist_invite | waitlist_promoted | custom,
|
||||
title :: binary(),
|
||||
body :: binary(),
|
||||
is_read :: boolean(),
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
-module(core_waitlist).
|
||||
-include("records.hrl").
|
||||
-export([create/2, get_by_id/1, update/2,
|
||||
list_by_event/1, list_waiting_by_event/1,
|
||||
get_waiting/2]).
|
||||
|
||||
-spec create(EventId :: binary(), UserId :: binary()) ->
|
||||
{ok, #waitlist_entry{}} | {error, term()}.
|
||||
create(EventId, UserId) ->
|
||||
Id = infra_utils:generate_id(16),
|
||||
Now = calendar:universal_time(),
|
||||
Entry = #waitlist_entry{
|
||||
id = Id,
|
||||
event_id = EventId,
|
||||
user_id = UserId,
|
||||
status = waiting,
|
||||
created_at = Now,
|
||||
updated_at = Now
|
||||
},
|
||||
case mnesia:dirty_write(Entry) of
|
||||
ok -> {ok, Entry};
|
||||
Error -> {error, Error}
|
||||
end.
|
||||
|
||||
-spec get_by_id(binary()) -> {ok, #waitlist_entry{}} | {error, not_found}.
|
||||
get_by_id(Id) ->
|
||||
case mnesia:dirty_read(waitlist_entry, Id) of
|
||||
[E] -> {ok, E};
|
||||
[] -> {error, not_found}
|
||||
end.
|
||||
|
||||
-spec update(binary(), [{atom(), term()}]) ->
|
||||
{ok, #waitlist_entry{}} | {error, not_found | term()}.
|
||||
update(Id, Updates) ->
|
||||
case get_by_id(Id) of
|
||||
{ok, E0} ->
|
||||
E1 = lists:foldl(fun apply_upd/2, E0, Updates),
|
||||
E2 = E1#waitlist_entry{updated_at = calendar:universal_time()},
|
||||
case mnesia:dirty_write(E2) of
|
||||
ok -> {ok, E2};
|
||||
Err -> {error, Err}
|
||||
end;
|
||||
{error, _} = Err ->
|
||||
Err
|
||||
end.
|
||||
|
||||
apply_upd({status, V}, E) -> E#waitlist_entry{status = V};
|
||||
apply_upd(_, E) -> E.
|
||||
|
||||
-spec list_by_event(binary()) -> [#waitlist_entry{}].
|
||||
list_by_event(EventId) ->
|
||||
mnesia:dirty_index_read(waitlist_entry, EventId, #waitlist_entry.event_id).
|
||||
|
||||
-spec list_waiting_by_event(binary()) -> [#waitlist_entry{}].
|
||||
list_waiting_by_event(EventId) ->
|
||||
[E || E <- list_by_event(EventId), E#waitlist_entry.status =:= waiting].
|
||||
|
||||
-spec get_waiting(EventId :: binary(), UserId :: binary()) ->
|
||||
{ok, #waitlist_entry{}} | {error, not_found}.
|
||||
get_waiting(EventId, UserId) ->
|
||||
case [E || E <- list_by_event(EventId),
|
||||
E#waitlist_entry.user_id =:= UserId,
|
||||
E#waitlist_entry.status =:= waiting] of
|
||||
[E | _] -> {ok, E};
|
||||
[] -> {error, not_found}
|
||||
end.
|
||||
@@ -118,6 +118,7 @@ start_http() ->
|
||||
{"/v1/events/:id/occurrences", handler_event_occurrences, []},
|
||||
{"/v1/events/:id/occurrences/:start_time", handler_event_occurrences, []},
|
||||
{"/v1/events/:id/bookings", handler_bookings, []},
|
||||
{"/v1/events/:id/waitlist", handler_event_waitlist, []},
|
||||
{"/v1/bookings/:id", handler_booking_by_id, []},
|
||||
{"/v1/reviews", handler_reviews, []},
|
||||
{"/v1/reviews/:id", handler_review_by_id, []},
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
%%%-------------------------------------------------------------------
|
||||
%%% @doc Waitlist for events (Back#72).
|
||||
%%% POST/DELETE/GET /v1/events/:id/waitlist
|
||||
%%% @end
|
||||
%%%-------------------------------------------------------------------
|
||||
-module(handler_event_waitlist).
|
||||
-behaviour(cowboy_handler).
|
||||
-export([init/2, trails/0]).
|
||||
|
||||
-spec init(cowboy_req:req(), any()) -> {ok, cowboy_req:req(), any()}.
|
||||
init(Req, _Opts) ->
|
||||
case cowboy_req:method(Req) of
|
||||
<<"POST">> -> join(Req);
|
||||
<<"DELETE">> -> leave(Req);
|
||||
<<"GET">> -> get_status(Req);
|
||||
_ -> handler_utils:send_error(Req, 405, <<"Method not allowed">>)
|
||||
end.
|
||||
|
||||
-spec trails() -> [map()].
|
||||
trails() ->
|
||||
[
|
||||
#{
|
||||
path => <<"/v1/events/:id/waitlist">>,
|
||||
method => <<"POST">>,
|
||||
description => <<"Join event waitlist (commercial, waitlist_enabled)">>,
|
||||
tags => [<<"Waitlist">>],
|
||||
responses => #{
|
||||
200 => #{description => <<"Joined">>},
|
||||
400 => #{description => <<"not_full / already_* / waitlist_disabled">>},
|
||||
401 => #{description => <<"Unauthorized">>},
|
||||
403 => #{description => <<"Forbidden">>},
|
||||
404 => #{description => <<"Not found">>}
|
||||
}
|
||||
},
|
||||
#{
|
||||
path => <<"/v1/events/:id/waitlist">>,
|
||||
method => <<"DELETE">>,
|
||||
description => <<"Leave event waitlist">>,
|
||||
tags => [<<"Waitlist">>],
|
||||
responses => #{
|
||||
200 => #{description => <<"Left">>},
|
||||
401 => #{description => <<"Unauthorized">>},
|
||||
404 => #{description => <<"Not on waitlist">>}
|
||||
}
|
||||
},
|
||||
#{
|
||||
path => <<"/v1/events/:id/waitlist">>,
|
||||
method => <<"GET">>,
|
||||
description => <<"Own waitlist status, or full list for owner/specialist">>,
|
||||
tags => [<<"Waitlist">>],
|
||||
responses => #{
|
||||
200 => #{description => <<"Status">>},
|
||||
401 => #{description => <<"Unauthorized">>},
|
||||
404 => #{description => <<"Not found">>}
|
||||
}
|
||||
}
|
||||
].
|
||||
|
||||
join(Req) ->
|
||||
EventId = cowboy_req:binding(id, Req),
|
||||
case handler_utils:auth_user(Req) of
|
||||
{ok, UserId, Req1} ->
|
||||
case logic_waitlist:join(UserId, EventId) of
|
||||
{ok, Body} ->
|
||||
handler_utils:send_json(Req1, 200, Body);
|
||||
{error, not_found} ->
|
||||
handler_utils:send_error(Req1, 404, <<"Event not found">>);
|
||||
{error, personal_calendar} ->
|
||||
handler_utils:send_error(Req1, 403, <<"personal_calendar">>);
|
||||
{error, subscription_inactive} ->
|
||||
handler_utils:send_error(Req1, 403, <<"subscription_inactive">>);
|
||||
{error, own_event} ->
|
||||
handler_utils:send_error(Req1, 403, <<"own_event">>);
|
||||
{error, waitlist_disabled} ->
|
||||
handler_utils:send_error(Req1, 400, <<"waitlist_disabled">>);
|
||||
{error, not_full} ->
|
||||
handler_utils:send_error(Req1, 400, <<"not_full">>);
|
||||
{error, already_booked} ->
|
||||
handler_utils:send_error(Req1, 400, <<"already_booked">>);
|
||||
{error, already_on_waitlist} ->
|
||||
handler_utils:send_error(Req1, 400, <<"already_on_waitlist">>);
|
||||
{error, event_not_active} ->
|
||||
handler_utils:send_error(Req1, 400, <<"event_not_active">>);
|
||||
{error, Reason} when is_atom(Reason) ->
|
||||
handler_utils:send_error(Req1, 400, atom_to_binary(Reason, utf8));
|
||||
{error, _} ->
|
||||
handler_utils:send_error(Req1, 400, <<"Invalid request">>)
|
||||
end;
|
||||
{error, Code, Message, Req1} ->
|
||||
handler_utils:send_error(Req1, Code, Message)
|
||||
end.
|
||||
|
||||
leave(Req) ->
|
||||
EventId = cowboy_req:binding(id, Req),
|
||||
case handler_utils:auth_user(Req) of
|
||||
{ok, UserId, Req1} ->
|
||||
case logic_waitlist:leave(UserId, EventId) of
|
||||
ok ->
|
||||
handler_utils:send_json(Req1, 200, #{ok => true});
|
||||
{error, not_found} ->
|
||||
handler_utils:send_error(Req1, 404, <<"Not on waitlist">>)
|
||||
end;
|
||||
{error, Code, Message, Req1} ->
|
||||
handler_utils:send_error(Req1, Code, Message)
|
||||
end.
|
||||
|
||||
get_status(Req) ->
|
||||
EventId = cowboy_req:binding(id, Req),
|
||||
case handler_utils:auth_user(Req) of
|
||||
{ok, UserId, Req1} ->
|
||||
case logic_waitlist:list_for_manager(UserId, EventId) of
|
||||
{ok, Body} ->
|
||||
handler_utils:send_json(Req1, 200, Body);
|
||||
{error, access_denied} ->
|
||||
case logic_waitlist:status_for_user(UserId, EventId) of
|
||||
{ok, Body} ->
|
||||
handler_utils:send_json(Req1, 200, Body);
|
||||
{error, not_found} ->
|
||||
handler_utils:send_error(Req1, 404, <<"Event not found">>);
|
||||
{error, _} ->
|
||||
handler_utils:send_error(Req1, 403, <<"Forbidden">>)
|
||||
end;
|
||||
{error, not_found} ->
|
||||
handler_utils:send_error(Req1, 404, <<"Event not found">>)
|
||||
end;
|
||||
{error, Code, Message, Req1} ->
|
||||
handler_utils:send_error(Req1, Code, Message)
|
||||
end.
|
||||
@@ -16,7 +16,7 @@
|
||||
user, session, verification, password_reset, admin, admin_session, auth_session,
|
||||
calendar, calendar_share, calendar_follow, calendar_specialist, specialist_invite,
|
||||
event, recurrence_exception,
|
||||
booking,
|
||||
booking, waitlist_entry,
|
||||
review, review_vote, report, banned_word, automod_settings, automod_hit,
|
||||
ticket, subscription,
|
||||
admin_audit, notification,
|
||||
@@ -326,6 +326,7 @@ table_opts(event) -> [{disc_copies, [node()]}, {attributes, record_info(fields,
|
||||
table_opts(recurrence_exception) ->
|
||||
[{disc_copies, [node()]}, {type, bag}, {attributes, record_info(fields, recurrence_exception)}];
|
||||
table_opts(booking) -> [{disc_copies, [node()]}, {attributes, record_info(fields, booking)}];
|
||||
table_opts(waitlist_entry) -> [{disc_copies, [node()]}, {attributes, record_info(fields, waitlist_entry)}];
|
||||
table_opts(review) -> [{disc_copies, [node()]}, {attributes, record_info(fields, review)}];
|
||||
table_opts(review_vote) -> [{disc_copies, [node()]}, {attributes, record_info(fields, review_vote)}];
|
||||
table_opts(report) -> [{disc_copies, [node()]}, {attributes, record_info(fields, report)}];
|
||||
@@ -362,6 +363,9 @@ create_indices() ->
|
||||
mnesia:add_table_index(booking, event_id),
|
||||
mnesia:add_table_index(booking, user_id),
|
||||
mnesia:add_table_index(booking, status),
|
||||
mnesia:add_table_index(waitlist_entry, event_id),
|
||||
mnesia:add_table_index(waitlist_entry, user_id),
|
||||
mnesia:add_table_index(waitlist_entry, status),
|
||||
mnesia:add_table_index(review_vote, review_id),
|
||||
mnesia:add_table_index(review_vote, user_id),
|
||||
mnesia:add_table_index(calendar, owner_id),
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
list_bookings_admin/0, get_booking_admin/1,
|
||||
list_event_bookings/1, list_event_bookings/2,
|
||||
process_timeout_bookings/0, process_reminders/0, cancel_pending_for_owner/1,
|
||||
cancel_pending_for_calendar/1]).
|
||||
cancel_pending_for_calendar/1, can_manage_event_bookings/2]).
|
||||
|
||||
%%%-------------------------------------------------------------------
|
||||
%%% @doc Создание бронирования с учётом commercial / confirmation / capacity.
|
||||
@@ -133,7 +133,13 @@ confirm_booking(UserId, BookingId, decline) ->
|
||||
true ->
|
||||
case ensure_pending_actionable(Booking) of
|
||||
{ok, _} ->
|
||||
core_booking:update(BookingId, [{status, cancelled}]);
|
||||
case core_booking:update(BookingId, [{status, cancelled}]) of
|
||||
{ok, _} = Ok ->
|
||||
_ = logic_waitlist:maybe_promote(Booking#booking.event_id),
|
||||
Ok;
|
||||
Err ->
|
||||
Err
|
||||
end;
|
||||
{error, Reason} ->
|
||||
{error, Reason}
|
||||
end;
|
||||
@@ -155,7 +161,14 @@ cancel_booking(BookingId, UserId) ->
|
||||
{ok, Booking};
|
||||
_ ->
|
||||
case Booking#booking.user_id =:= UserId of
|
||||
true -> core_booking:update(BookingId, [{status, cancelled}]);
|
||||
true ->
|
||||
case core_booking:update(BookingId, [{status, cancelled}]) of
|
||||
{ok, _} = Ok ->
|
||||
_ = logic_waitlist:maybe_promote(Booking#booking.event_id),
|
||||
Ok;
|
||||
Err ->
|
||||
Err
|
||||
end;
|
||||
false -> {error, access_denied}
|
||||
end
|
||||
end;
|
||||
@@ -462,7 +475,12 @@ maybe_timeout(#booking{id = Id, event_id = EventId, created_at = Created} = Book
|
||||
Now = calendar:universal_time(),
|
||||
_ = core_booking:update(Id, [{status, confirmed}, {confirmed_at, Now}]);
|
||||
false ->
|
||||
_ = core_booking:update(Id, [{status, cancelled}])
|
||||
case core_booking:update(Id, [{status, cancelled}]) of
|
||||
{ok, _} ->
|
||||
_ = logic_waitlist:maybe_promote(EventId);
|
||||
_ ->
|
||||
ok
|
||||
end
|
||||
end;
|
||||
false ->
|
||||
ok
|
||||
@@ -553,7 +571,18 @@ ensure_not_past_pending(B) ->
|
||||
|
||||
-spec mark_expired(binary()) -> {ok, #booking{}} | {error, term()}.
|
||||
mark_expired(BookingId) ->
|
||||
core_booking:update(BookingId, [{status, expired}]).
|
||||
case core_booking:get_by_id(BookingId) of
|
||||
{ok, #booking{event_id = EventId}} ->
|
||||
case core_booking:update(BookingId, [{status, expired}]) of
|
||||
{ok, _} = Ok ->
|
||||
_ = logic_waitlist:maybe_promote(EventId),
|
||||
Ok;
|
||||
Err ->
|
||||
Err
|
||||
end;
|
||||
{error, _} = Err ->
|
||||
Err
|
||||
end.
|
||||
|
||||
-spec event_started(#event{}, non_neg_integer()) -> boolean().
|
||||
event_started(#event{start_time = Start}, NowSec) ->
|
||||
|
||||
@@ -391,6 +391,10 @@ validate_settings_key(<<"default_duration_minutes">>, Val) ->
|
||||
validate_default_duration(Val);
|
||||
validate_settings_key(<<"default_recurrence">>, Val) ->
|
||||
validate_default_recurrence(Val);
|
||||
validate_settings_key(<<"waitlist_enabled">>, Val) when is_boolean(Val) ->
|
||||
ok;
|
||||
validate_settings_key(<<"waitlist_enabled">>, _) ->
|
||||
{error, {invalid_settings, <<"waitlist_enabled">>}};
|
||||
validate_settings_key(_Unknown, _Val) ->
|
||||
ok.
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
-module(logic_email).
|
||||
-export([send_verification_email/2, send_specialist_invite/2, send_password_reset/2,
|
||||
send_booking_reminder/4]).
|
||||
send_booking_reminder/4, send_waitlist_promoted/3]).
|
||||
|
||||
%% SMTP via env (variant A / Back#68):
|
||||
%% SMTP_HOST empty → log-only (local/tests)
|
||||
@@ -29,6 +29,13 @@ send_booking_reminder(Email, EventTitle, StartTime, EventPath) ->
|
||||
logic_email_templates:render_reminder(Url, to_bin(EventTitle), WhenText),
|
||||
send(to_bin(Email), Subject, Plain, Html).
|
||||
|
||||
-spec send_waitlist_promoted(binary(), binary(), binary()) -> ok.
|
||||
send_waitlist_promoted(Email, EventTitle, EventPath) ->
|
||||
Url = abs_path(EventPath),
|
||||
{Subject, Plain, Html} =
|
||||
logic_email_templates:render_waitlist_promoted(Url, to_bin(EventTitle)),
|
||||
send(to_bin(Email), Subject, Plain, Html).
|
||||
|
||||
send_kind(Kind, To0, Url) ->
|
||||
{Subject, Plain, Html} = logic_email_templates:render(Kind, Url),
|
||||
send(to_bin(To0), Subject, Plain, Html).
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
-module(logic_email_templates).
|
||||
-export([render/2, render_reminder/3]).
|
||||
-export([render/2, render_reminder/3, render_waitlist_promoted/2]).
|
||||
|
||||
%% Kind: verify | invite | reset
|
||||
-spec render(verify | invite | reset, binary()) ->
|
||||
@@ -22,6 +22,20 @@ render_reminder(Url, Title, WhenText) ->
|
||||
Pre = <<"Напоминание о записи в CalenTIQ">>,
|
||||
assemble(Subject, Heading, Lead, Url, Cta, Foot, Pre).
|
||||
|
||||
-spec render_waitlist_promoted(binary(), binary()) ->
|
||||
{Subject :: binary(), Plain :: binary(), Html :: binary()}.
|
||||
render_waitlist_promoted(Url, Title) ->
|
||||
Subject = <<"CalenTIQ: место в листе ожидания">>,
|
||||
Heading = <<"Для вас освободилось место">>,
|
||||
Lead = iolist_to_binary([
|
||||
<<"Вы были в листе ожидания на «">>, Title,
|
||||
<<"». Мы записали вас — откройте событие в CalenTIQ.">>
|
||||
]),
|
||||
Cta = <<"Открыть событие">>,
|
||||
Foot = <<"Если запись вам не нужна, отмените её в приложении.">>,
|
||||
Pre = <<"Место из листа ожидания CalenTIQ">>,
|
||||
assemble(Subject, Heading, Lead, Url, Cta, Foot, Pre).
|
||||
|
||||
assemble(Subject, Heading, Lead, Url, Cta, Foot, Pre) ->
|
||||
Plain = iolist_to_binary([Heading, <<"\n\n">>, Lead, <<"\n\n">>, Url, <<"\n">>]),
|
||||
Html = fill(layout(), [
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
-export([notify_admin/2]).
|
||||
-export([notify_specialist_invite/2]).
|
||||
-export([notify_event_reminder/2]).
|
||||
-export([notify_waitlist_promoted/2]).
|
||||
|
||||
%% Уведомление о бронировании
|
||||
notify_booking(UserId, Booking) ->
|
||||
@@ -53,6 +54,12 @@ notify_event_reminder(UserId, #{title := Title, when_text := WhenText} = Data) -
|
||||
_ = core_notification:create(UserId, event_reminder, NTitle, Body),
|
||||
broadcast_to_user(UserId, event_reminder, Data#{user_id => UserId}).
|
||||
|
||||
notify_waitlist_promoted(UserId, #{title := Title} = Data) ->
|
||||
NTitle = <<"Место из листа ожидания">>,
|
||||
Body = iolist_to_binary([<<"Вас записали на «">>, Title, <<"».">>]),
|
||||
_ = core_notification:create(UserId, waitlist_promoted, NTitle, Body),
|
||||
broadcast_to_user(UserId, waitlist_promoted, Data#{user_id => UserId}).
|
||||
|
||||
%% Уведомление для администраторов
|
||||
notify_admin(Type, Data) ->
|
||||
Message = {admin_notification, Type, Data},
|
||||
|
||||
@@ -0,0 +1,303 @@
|
||||
%%%-------------------------------------------------------------------
|
||||
%%% @doc Event waitlist (Back#72). Requires commercial calendar
|
||||
%%% settings.waitlist_enabled = true. FIFO promote on free slot.
|
||||
%%% @end
|
||||
%%%-------------------------------------------------------------------
|
||||
-module(logic_waitlist).
|
||||
-include("records.hrl").
|
||||
|
||||
-export([join/2, leave/2, status_for_user/2, list_for_manager/2,
|
||||
maybe_promote/1, waitlist_enabled/1]).
|
||||
|
||||
-spec waitlist_enabled(#calendar{}) -> boolean().
|
||||
waitlist_enabled(#calendar{type = commercial, settings = Settings}) when is_map(Settings) ->
|
||||
case maps:get(<<"waitlist_enabled">>, Settings, maps:get(waitlist_enabled, Settings, false)) of
|
||||
true -> true;
|
||||
<<"true">> -> true;
|
||||
_ -> false
|
||||
end;
|
||||
waitlist_enabled(_) ->
|
||||
false.
|
||||
|
||||
-spec join(UserId :: binary(), EventId :: binary()) ->
|
||||
{ok, map()} |
|
||||
{error, not_found | access_denied | waitlist_disabled | not_full |
|
||||
already_booked | already_on_waitlist | personal_calendar |
|
||||
subscription_inactive | event_not_active | own_event}.
|
||||
join(UserId, EventId) ->
|
||||
case core_event:get_by_id(EventId) of
|
||||
{ok, #event{status = active} = Event} ->
|
||||
case core_calendar:get_by_id(Event#event.calendar_id) of
|
||||
{ok, Cal} ->
|
||||
join_checked(UserId, Event, Cal);
|
||||
{error, not_found} ->
|
||||
{error, not_found}
|
||||
end;
|
||||
{ok, _} ->
|
||||
{error, event_not_active};
|
||||
{error, not_found} ->
|
||||
{error, not_found}
|
||||
end.
|
||||
|
||||
join_checked(UserId, Event, Cal) ->
|
||||
case Cal#calendar.type of
|
||||
personal ->
|
||||
{error, personal_calendar};
|
||||
commercial ->
|
||||
case logic_calendar:booking_open(Cal) of
|
||||
false ->
|
||||
{error, subscription_inactive};
|
||||
true ->
|
||||
case waitlist_enabled(Cal) of
|
||||
false ->
|
||||
{error, waitlist_disabled};
|
||||
true ->
|
||||
join_ready(UserId, Event, Cal)
|
||||
end
|
||||
end
|
||||
end.
|
||||
|
||||
join_ready(UserId, #event{id = EventId, capacity = Cap} = Event, _Cal) ->
|
||||
case owner_of_event(UserId, Event) of
|
||||
true ->
|
||||
{error, own_event};
|
||||
false ->
|
||||
case logic_booking_occupied(EventId, UserId) of
|
||||
true ->
|
||||
{error, already_booked};
|
||||
false ->
|
||||
case core_waitlist:get_waiting(EventId, UserId) of
|
||||
{ok, _} ->
|
||||
{error, already_on_waitlist};
|
||||
{error, not_found} ->
|
||||
case is_full(EventId, Cap) of
|
||||
false ->
|
||||
{error, not_full};
|
||||
true ->
|
||||
case core_waitlist:create(EventId, UserId) of
|
||||
{ok, Entry} ->
|
||||
{ok, entry_public(Entry, EventId)};
|
||||
{error, _} = E ->
|
||||
E
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end.
|
||||
|
||||
owner_of_event(UserId, Event) ->
|
||||
case core_calendar:get_by_id(Event#event.calendar_id) of
|
||||
{ok, #calendar{owner_id = UserId}} -> true;
|
||||
_ -> false
|
||||
end.
|
||||
|
||||
-spec leave(UserId :: binary(), EventId :: binary()) ->
|
||||
ok | {error, not_found}.
|
||||
leave(UserId, EventId) ->
|
||||
case core_waitlist:get_waiting(EventId, UserId) of
|
||||
{ok, #waitlist_entry{id = Id}} ->
|
||||
_ = core_waitlist:update(Id, [{status, left}]),
|
||||
ok;
|
||||
{error, not_found} ->
|
||||
{error, not_found}
|
||||
end.
|
||||
|
||||
-spec status_for_user(UserId :: binary(), EventId :: binary()) ->
|
||||
{ok, map()} | {error, not_found | access_denied}.
|
||||
status_for_user(UserId, EventId) ->
|
||||
case core_event:get_by_id(EventId) of
|
||||
{ok, Event} ->
|
||||
case core_calendar:get_by_id(Event#event.calendar_id) of
|
||||
{ok, Cal} ->
|
||||
Enabled = waitlist_enabled(Cal),
|
||||
Waiting = sort_waiting(core_waitlist:list_waiting_by_event(EventId)),
|
||||
case core_waitlist:get_waiting(EventId, UserId) of
|
||||
{ok, Entry} ->
|
||||
Pos = position_of(Entry#waitlist_entry.id, Waiting),
|
||||
{ok, #{
|
||||
enabled => Enabled,
|
||||
joined => true,
|
||||
position => Pos,
|
||||
total => length(Waiting),
|
||||
entry => entry_json(Entry)
|
||||
}};
|
||||
{error, not_found} ->
|
||||
{ok, #{
|
||||
enabled => Enabled,
|
||||
joined => false,
|
||||
position => null,
|
||||
total => length(Waiting)
|
||||
}}
|
||||
end;
|
||||
{error, not_found} ->
|
||||
{error, not_found}
|
||||
end;
|
||||
{error, not_found} ->
|
||||
{error, not_found}
|
||||
end.
|
||||
|
||||
-spec list_for_manager(UserId :: binary(), EventId :: binary()) ->
|
||||
{ok, map()} | {error, not_found | access_denied}.
|
||||
list_for_manager(UserId, EventId) ->
|
||||
case can_manage(UserId, EventId) of
|
||||
true ->
|
||||
case core_event:get_by_id(EventId) of
|
||||
{ok, Event} ->
|
||||
{ok, Cal} = core_calendar:get_by_id(Event#event.calendar_id),
|
||||
Waiting = sort_waiting(core_waitlist:list_waiting_by_event(EventId)),
|
||||
{ok, #{
|
||||
enabled => waitlist_enabled(Cal),
|
||||
total => length(Waiting),
|
||||
items => [entry_json(E) || E <- Waiting]
|
||||
}};
|
||||
{error, not_found} ->
|
||||
{error, not_found}
|
||||
end;
|
||||
false ->
|
||||
{error, access_denied}
|
||||
end.
|
||||
|
||||
%%%-------------------------------------------------------------------
|
||||
%%% Promote first waiter when a booking slot frees.
|
||||
%%%-------------------------------------------------------------------
|
||||
-spec maybe_promote(EventId :: binary()) -> ok.
|
||||
maybe_promote(EventId) ->
|
||||
case core_event:get_by_id(EventId) of
|
||||
{ok, #event{status = active, capacity = Cap, calendar_id = CalId} = Event} ->
|
||||
case core_calendar:get_by_id(CalId) of
|
||||
{ok, Cal} ->
|
||||
case waitlist_enabled(Cal) andalso logic_calendar:booking_open(Cal)
|
||||
andalso has_free_slot(EventId, Cap) of
|
||||
true ->
|
||||
promote_one(Event, Cal);
|
||||
false ->
|
||||
ok
|
||||
end;
|
||||
_ ->
|
||||
ok
|
||||
end;
|
||||
_ ->
|
||||
ok
|
||||
end.
|
||||
|
||||
promote_one(Event, Cal) ->
|
||||
case sort_waiting(core_waitlist:list_waiting_by_event(Event#event.id)) of
|
||||
[] ->
|
||||
ok;
|
||||
[#waitlist_entry{id = Wid, user_id = UserId} = _Entry | _] ->
|
||||
case logic_booking_occupied(Event#event.id, UserId) of
|
||||
true ->
|
||||
_ = core_waitlist:update(Wid, [{status, left}]),
|
||||
maybe_promote(Event#event.id);
|
||||
false ->
|
||||
Status = initial_status(Cal#calendar.confirmation),
|
||||
case core_booking:create(Event#event.id, UserId, Status) of
|
||||
{ok, Booking} ->
|
||||
_ = core_waitlist:update(Wid, [{status, promoted}]),
|
||||
notify_promoted(UserId, Event, Booking),
|
||||
ok;
|
||||
{error, _} ->
|
||||
ok
|
||||
end
|
||||
end
|
||||
end.
|
||||
|
||||
initial_status(auto) -> confirmed;
|
||||
initial_status(_) -> pending.
|
||||
|
||||
notify_promoted(UserId, Event, Booking) ->
|
||||
Title = Event#event.title,
|
||||
Path = <<"/c/", (Event#event.calendar_id)/binary, "/e/", (Event#event.id)/binary>>,
|
||||
case core_user:get_by_id(UserId) of
|
||||
{ok, #user{email = Email}} when is_binary(Email), Email =/= <<>> ->
|
||||
_ = logic_email:send_waitlist_promoted(Email, Title, Path);
|
||||
_ ->
|
||||
ok
|
||||
end,
|
||||
_ = logic_notification:notify_waitlist_promoted(UserId, #{
|
||||
event_id => Event#event.id,
|
||||
calendar_id => Event#event.calendar_id,
|
||||
booking_id => Booking#booking.id,
|
||||
title => Title,
|
||||
status => Booking#booking.status
|
||||
}),
|
||||
ok.
|
||||
|
||||
%%%===================================================================
|
||||
%%% INTERNAL
|
||||
%%%===================================================================
|
||||
|
||||
is_full(_EventId, Cap) when Cap =:= undefined; Cap =:= 0 ->
|
||||
false;
|
||||
is_full(EventId, Cap) when is_integer(Cap), Cap > 0 ->
|
||||
occupied(EventId) >= Cap;
|
||||
is_full(_, _) ->
|
||||
false.
|
||||
|
||||
has_free_slot(_EventId, Cap) when Cap =:= undefined; Cap =:= 0 ->
|
||||
true;
|
||||
has_free_slot(EventId, Cap) when is_integer(Cap), Cap > 0 ->
|
||||
occupied(EventId) < Cap;
|
||||
has_free_slot(_, _) ->
|
||||
true.
|
||||
|
||||
occupied(EventId) ->
|
||||
{ok, Bookings} = core_booking:list_by_event(EventId),
|
||||
length([B || B <- Bookings,
|
||||
B#booking.status =:= pending orelse B#booking.status =:= confirmed]).
|
||||
|
||||
logic_booking_occupied(EventId, UserId) ->
|
||||
Match = #booking{event_id = EventId, user_id = UserId, _ = '_'},
|
||||
case [B || B <- mnesia:dirty_match_object(Match),
|
||||
B#booking.status =:= pending orelse B#booking.status =:= confirmed] of
|
||||
[_ | _] -> true;
|
||||
[] -> false
|
||||
end.
|
||||
|
||||
sort_waiting(List) ->
|
||||
lists:sort(fun(A, B) -> A#waitlist_entry.created_at =< B#waitlist_entry.created_at end, List).
|
||||
|
||||
position_of(Id, Waiting) ->
|
||||
position_of(Id, Waiting, 1).
|
||||
|
||||
position_of(_Id, [], _) -> null;
|
||||
position_of(Id, [#waitlist_entry{id = Id} | _], N) -> N;
|
||||
position_of(Id, [_ | Rest], N) -> position_of(Id, Rest, N + 1).
|
||||
|
||||
entry_public(Entry, EventId) ->
|
||||
Waiting = sort_waiting(core_waitlist:list_waiting_by_event(EventId)),
|
||||
#{
|
||||
entry => entry_json(Entry),
|
||||
position => position_of(Entry#waitlist_entry.id, Waiting),
|
||||
total => length(Waiting)
|
||||
}.
|
||||
|
||||
entry_json(#waitlist_entry{} = E) ->
|
||||
#{
|
||||
id => E#waitlist_entry.id,
|
||||
event_id => E#waitlist_entry.event_id,
|
||||
user_id => E#waitlist_entry.user_id,
|
||||
status => E#waitlist_entry.status,
|
||||
created_at => handler_utils:datetime_to_iso8601(E#waitlist_entry.created_at),
|
||||
updated_at => handler_utils:datetime_to_iso8601(E#waitlist_entry.updated_at)
|
||||
}.
|
||||
|
||||
can_manage(UserId, EventId) ->
|
||||
case core_event:get_by_id(EventId) of
|
||||
{ok, Event} ->
|
||||
case core_calendar:get_by_id(Event#event.calendar_id) of
|
||||
{ok, #calendar{owner_id = UserId}} ->
|
||||
true;
|
||||
{ok, Calendar} ->
|
||||
SpecOk = is_binary(Event#event.specialist_id)
|
||||
andalso Event#event.specialist_id =/= <<>>
|
||||
andalso Event#event.specialist_id =:= UserId
|
||||
andalso core_calendar_specialist:is_active_specialist(
|
||||
Calendar#calendar.id, UserId),
|
||||
SpecOk orelse admin_utils:is_admin(UserId);
|
||||
_ ->
|
||||
admin_utils:is_admin(UserId)
|
||||
end;
|
||||
_ ->
|
||||
false
|
||||
end.
|
||||
@@ -0,0 +1,38 @@
|
||||
%% @doc Create waitlist_entry table and indexes (Back#72).
|
||||
-module('20260814193000_waitlist_entry').
|
||||
|
||||
-export([up/0, down/0]).
|
||||
|
||||
-include("records.hrl").
|
||||
|
||||
up() ->
|
||||
ensure_table(waitlist_entry, record_info(fields, waitlist_entry)),
|
||||
ensure_index(waitlist_entry, event_id),
|
||||
ensure_index(waitlist_entry, user_id),
|
||||
ensure_index(waitlist_entry, status),
|
||||
ok.
|
||||
|
||||
down() ->
|
||||
_ = mnesia:delete_table(waitlist_entry),
|
||||
ok.
|
||||
|
||||
ensure_table(Table, Attrs) ->
|
||||
case lists:member(Table, mnesia:system_info(tables)) of
|
||||
true ->
|
||||
ok;
|
||||
false ->
|
||||
case mnesia:create_table(Table, [{disc_copies, [node()]}, {attributes, Attrs}]) of
|
||||
{atomic, ok} -> ok;
|
||||
{aborted, {already_exists, Table}} -> ok;
|
||||
{aborted, Reason} -> error({create_table_failed, Table, Reason})
|
||||
end
|
||||
end.
|
||||
|
||||
ensure_index(Table, Attr) ->
|
||||
case mnesia:add_table_index(Table, Attr) of
|
||||
{atomic, ok} -> ok;
|
||||
{aborted, {already_exists, Table, _Pos}} -> ok;
|
||||
{aborted, {already_exists, Table, Attr}} -> ok;
|
||||
{aborted, {already_exists, _}} -> ok;
|
||||
{aborted, Reason} -> error({add_index_failed, Table, Attr, Reason})
|
||||
end.
|
||||
@@ -87,6 +87,7 @@ user() ->
|
||||
handler_event_by_id,
|
||||
handler_event_occurrences,
|
||||
handler_events,
|
||||
handler_event_waitlist,
|
||||
handler_reports,
|
||||
handler_review_by_id,
|
||||
handler_review_vote,
|
||||
|
||||
@@ -87,6 +87,7 @@ ensure_indexes(Tables) when is_list(Tables) ->
|
||||
{event, [calendar_id, title, created_at, start_time, event_type,
|
||||
master_id, specialist_id, status]},
|
||||
{booking, [event_id, user_id, status]},
|
||||
{waitlist_entry, [event_id, user_id, status]},
|
||||
{review_vote, [review_id, user_id]},
|
||||
{calendar, [owner_id, status, short_name, category]},
|
||||
{calendar_specialist, [calendar_id, user_id]},
|
||||
@@ -156,6 +157,8 @@ table_opts(recurrence_exception) ->
|
||||
[{ram_copies, [node()]}, {type, bag}, {attributes, record_info(fields, recurrence_exception)}];
|
||||
table_opts(booking) ->
|
||||
[{ram_copies, [node()]}, {attributes, record_info(fields, booking)}];
|
||||
table_opts(waitlist_entry) ->
|
||||
[{ram_copies, [node()]}, {attributes, record_info(fields, waitlist_entry)}];
|
||||
table_opts(review) ->
|
||||
[{ram_copies, [node()]}, {attributes, record_info(fields, review)}];
|
||||
table_opts(review_vote) ->
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
-module(logic_waitlist_tests).
|
||||
-include_lib("eunit/include/eunit.hrl").
|
||||
-include("records.hrl").
|
||||
|
||||
-define(TABLES, [user, calendar, event, booking, waitlist_entry, subscription,
|
||||
notification, calendar_specialist]).
|
||||
|
||||
setup() ->
|
||||
eh_test_support:start_mnesia(),
|
||||
eh_test_support:ensure_tables(?TABLES),
|
||||
ok.
|
||||
|
||||
cleanup(_) ->
|
||||
eh_test_support:delete_tables(?TABLES),
|
||||
eh_test_support:stop_mnesia(),
|
||||
ok.
|
||||
|
||||
logic_waitlist_test_() ->
|
||||
{foreach, fun setup/0, fun cleanup/1, [
|
||||
{"join rejected when waitlist disabled", fun test_disabled/0},
|
||||
{"join when full and enabled", fun test_join_full/0},
|
||||
{"join rejected when not full", fun test_not_full/0},
|
||||
{"promote on cancel", fun test_promote_on_cancel/0},
|
||||
{"leave waitlist", fun test_leave/0}
|
||||
]}.
|
||||
|
||||
create_user() ->
|
||||
Id = infra_utils:generate_id(8),
|
||||
U = #user{
|
||||
id = Id, email = <<Id/binary, "@t.com">>, password_hash = <<"h">>,
|
||||
role = user, status = active,
|
||||
created_at = calendar:universal_time(),
|
||||
updated_at = calendar:universal_time()
|
||||
},
|
||||
mnesia:dirty_write(U),
|
||||
Id.
|
||||
|
||||
ensure_sub(OwnerId) ->
|
||||
{ok, _} = core_subscription:create(OwnerId, monthly, true),
|
||||
ok.
|
||||
|
||||
create_cal(OwnerId, Waitlist) ->
|
||||
ensure_sub(OwnerId),
|
||||
Settings = #{<<"waitlist_enabled">> => Waitlist},
|
||||
{ok, Cal} = core_calendar:create(OwnerId, <<"Studio">>, <<"">>, auto, commercial),
|
||||
{ok, Cal2} = core_calendar:update(Cal#calendar.id, [{settings, Settings}]),
|
||||
Cal2.
|
||||
|
||||
create_event(CalId, Cap) ->
|
||||
Start = eh_test_support:future_start(),
|
||||
{ok, Ev} = core_event:create(CalId, <<"Slot">>, Start, 60),
|
||||
{ok, Ev2} = core_event:update(Ev#event.id, [{capacity, Cap}]),
|
||||
Ev2#event.id.
|
||||
|
||||
test_disabled() ->
|
||||
Owner = create_user(),
|
||||
Guest = create_user(),
|
||||
Cal = create_cal(Owner, false),
|
||||
EventId = create_event(Cal#calendar.id, 1),
|
||||
{ok, _} = logic_booking:create_booking(Guest, EventId),
|
||||
Other = create_user(),
|
||||
?assertEqual({error, waitlist_disabled}, logic_waitlist:join(Other, EventId)).
|
||||
|
||||
test_join_full() ->
|
||||
Owner = create_user(),
|
||||
Guest = create_user(),
|
||||
Cal = create_cal(Owner, true),
|
||||
EventId = create_event(Cal#calendar.id, 1),
|
||||
{ok, _} = logic_booking:create_booking(Guest, EventId),
|
||||
Other = create_user(),
|
||||
{ok, Body} = logic_waitlist:join(Other, EventId),
|
||||
?assertEqual(1, maps:get(position, Body)).
|
||||
|
||||
test_not_full() ->
|
||||
Owner = create_user(),
|
||||
Cal = create_cal(Owner, true),
|
||||
EventId = create_event(Cal#calendar.id, 2),
|
||||
?assertEqual({error, not_full}, logic_waitlist:join(create_user(), EventId)).
|
||||
|
||||
test_promote_on_cancel() ->
|
||||
application:set_env(eventhub, smtp_host, undefined),
|
||||
Owner = create_user(),
|
||||
Guest = create_user(),
|
||||
Waiter = create_user(),
|
||||
Cal = create_cal(Owner, true),
|
||||
EventId = create_event(Cal#calendar.id, 1),
|
||||
{ok, Booking} = logic_booking:create_booking(Guest, EventId),
|
||||
{ok, _} = logic_waitlist:join(Waiter, EventId),
|
||||
{ok, _} = logic_booking:cancel_booking(Booking#booking.id, Guest),
|
||||
{ok, WaiterBookings} = core_booking:list_by_user(Waiter),
|
||||
Active = [B || B <- WaiterBookings,
|
||||
B#booking.event_id =:= EventId,
|
||||
B#booking.status =:= confirmed orelse B#booking.status =:= pending],
|
||||
?assertEqual(1, length(Active)),
|
||||
?assertEqual({error, not_found}, core_waitlist:get_waiting(EventId, Waiter)).
|
||||
|
||||
test_leave() ->
|
||||
Owner = create_user(),
|
||||
Guest = create_user(),
|
||||
Waiter = create_user(),
|
||||
Cal = create_cal(Owner, true),
|
||||
EventId = create_event(Cal#calendar.id, 1),
|
||||
{ok, _} = logic_booking:create_booking(Guest, EventId),
|
||||
{ok, _} = logic_waitlist:join(Waiter, EventId),
|
||||
?assertEqual(ok, logic_waitlist:leave(Waiter, EventId)),
|
||||
?assertEqual({error, not_found}, core_waitlist:get_waiting(EventId, Waiter)).
|
||||
Reference in New Issue
Block a user