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()
|
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, {
|
-record(review, {
|
||||||
id :: binary(),
|
id :: binary(),
|
||||||
@@ -319,7 +329,7 @@
|
|||||||
id :: binary(),
|
id :: binary(),
|
||||||
user_id :: binary(),
|
user_id :: binary(),
|
||||||
type :: booking_confirmed | event_reminder | event_cancelled |
|
type :: booking_confirmed | event_reminder | event_cancelled |
|
||||||
specialist_invite | custom,
|
specialist_invite | waitlist_promoted | custom,
|
||||||
title :: binary(),
|
title :: binary(),
|
||||||
body :: binary(),
|
body :: binary(),
|
||||||
is_read :: boolean(),
|
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", handler_event_occurrences, []},
|
||||||
{"/v1/events/:id/occurrences/:start_time", handler_event_occurrences, []},
|
{"/v1/events/:id/occurrences/:start_time", handler_event_occurrences, []},
|
||||||
{"/v1/events/:id/bookings", handler_bookings, []},
|
{"/v1/events/:id/bookings", handler_bookings, []},
|
||||||
|
{"/v1/events/:id/waitlist", handler_event_waitlist, []},
|
||||||
{"/v1/bookings/:id", handler_booking_by_id, []},
|
{"/v1/bookings/:id", handler_booking_by_id, []},
|
||||||
{"/v1/reviews", handler_reviews, []},
|
{"/v1/reviews", handler_reviews, []},
|
||||||
{"/v1/reviews/:id", handler_review_by_id, []},
|
{"/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,
|
user, session, verification, password_reset, admin, admin_session, auth_session,
|
||||||
calendar, calendar_share, calendar_follow, calendar_specialist, specialist_invite,
|
calendar, calendar_share, calendar_follow, calendar_specialist, specialist_invite,
|
||||||
event, recurrence_exception,
|
event, recurrence_exception,
|
||||||
booking,
|
booking, waitlist_entry,
|
||||||
review, review_vote, report, banned_word, automod_settings, automod_hit,
|
review, review_vote, report, banned_word, automod_settings, automod_hit,
|
||||||
ticket, subscription,
|
ticket, subscription,
|
||||||
admin_audit, notification,
|
admin_audit, notification,
|
||||||
@@ -326,6 +326,7 @@ table_opts(event) -> [{disc_copies, [node()]}, {attributes, record_info(fields,
|
|||||||
table_opts(recurrence_exception) ->
|
table_opts(recurrence_exception) ->
|
||||||
[{disc_copies, [node()]}, {type, bag}, {attributes, record_info(fields, 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(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) -> [{disc_copies, [node()]}, {attributes, record_info(fields, review)}];
|
||||||
table_opts(review_vote) -> [{disc_copies, [node()]}, {attributes, record_info(fields, review_vote)}];
|
table_opts(review_vote) -> [{disc_copies, [node()]}, {attributes, record_info(fields, review_vote)}];
|
||||||
table_opts(report) -> [{disc_copies, [node()]}, {attributes, record_info(fields, report)}];
|
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, event_id),
|
||||||
mnesia:add_table_index(booking, user_id),
|
mnesia:add_table_index(booking, user_id),
|
||||||
mnesia:add_table_index(booking, status),
|
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, review_id),
|
||||||
mnesia:add_table_index(review_vote, user_id),
|
mnesia:add_table_index(review_vote, user_id),
|
||||||
mnesia:add_table_index(calendar, owner_id),
|
mnesia:add_table_index(calendar, owner_id),
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
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, process_reminders/0, cancel_pending_for_owner/1,
|
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.
|
%%% @doc Создание бронирования с учётом commercial / confirmation / capacity.
|
||||||
@@ -133,7 +133,13 @@ confirm_booking(UserId, BookingId, decline) ->
|
|||||||
true ->
|
true ->
|
||||||
case ensure_pending_actionable(Booking) of
|
case ensure_pending_actionable(Booking) of
|
||||||
{ok, _} ->
|
{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} ->
|
||||||
{error, Reason}
|
{error, Reason}
|
||||||
end;
|
end;
|
||||||
@@ -155,7 +161,14 @@ cancel_booking(BookingId, UserId) ->
|
|||||||
{ok, Booking};
|
{ok, Booking};
|
||||||
_ ->
|
_ ->
|
||||||
case Booking#booking.user_id =:= UserId of
|
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}
|
false -> {error, access_denied}
|
||||||
end
|
end
|
||||||
end;
|
end;
|
||||||
@@ -462,7 +475,12 @@ maybe_timeout(#booking{id = Id, event_id = EventId, created_at = Created} = Book
|
|||||||
Now = calendar:universal_time(),
|
Now = calendar:universal_time(),
|
||||||
_ = core_booking:update(Id, [{status, confirmed}, {confirmed_at, Now}]);
|
_ = core_booking:update(Id, [{status, confirmed}, {confirmed_at, Now}]);
|
||||||
false ->
|
false ->
|
||||||
_ = core_booking:update(Id, [{status, cancelled}])
|
case core_booking:update(Id, [{status, cancelled}]) of
|
||||||
|
{ok, _} ->
|
||||||
|
_ = logic_waitlist:maybe_promote(EventId);
|
||||||
|
_ ->
|
||||||
|
ok
|
||||||
|
end
|
||||||
end;
|
end;
|
||||||
false ->
|
false ->
|
||||||
ok
|
ok
|
||||||
@@ -553,7 +571,18 @@ ensure_not_past_pending(B) ->
|
|||||||
|
|
||||||
-spec mark_expired(binary()) -> {ok, #booking{}} | {error, term()}.
|
-spec mark_expired(binary()) -> {ok, #booking{}} | {error, term()}.
|
||||||
mark_expired(BookingId) ->
|
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().
|
-spec event_started(#event{}, non_neg_integer()) -> boolean().
|
||||||
event_started(#event{start_time = Start}, NowSec) ->
|
event_started(#event{start_time = Start}, NowSec) ->
|
||||||
|
|||||||
@@ -391,6 +391,10 @@ validate_settings_key(<<"default_duration_minutes">>, Val) ->
|
|||||||
validate_default_duration(Val);
|
validate_default_duration(Val);
|
||||||
validate_settings_key(<<"default_recurrence">>, Val) ->
|
validate_settings_key(<<"default_recurrence">>, Val) ->
|
||||||
validate_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) ->
|
validate_settings_key(_Unknown, _Val) ->
|
||||||
ok.
|
ok.
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
-module(logic_email).
|
-module(logic_email).
|
||||||
-export([send_verification_email/2, send_specialist_invite/2, send_password_reset/2,
|
-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 via env (variant A / Back#68):
|
||||||
%% SMTP_HOST empty → log-only (local/tests)
|
%% 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),
|
logic_email_templates:render_reminder(Url, to_bin(EventTitle), WhenText),
|
||||||
send(to_bin(Email), Subject, Plain, Html).
|
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) ->
|
send_kind(Kind, To0, Url) ->
|
||||||
{Subject, Plain, Html} = logic_email_templates:render(Kind, Url),
|
{Subject, Plain, Html} = logic_email_templates:render(Kind, Url),
|
||||||
send(to_bin(To0), Subject, Plain, Html).
|
send(to_bin(To0), Subject, Plain, Html).
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
-module(logic_email_templates).
|
-module(logic_email_templates).
|
||||||
-export([render/2, render_reminder/3]).
|
-export([render/2, render_reminder/3, render_waitlist_promoted/2]).
|
||||||
|
|
||||||
%% Kind: verify | invite | reset
|
%% Kind: verify | invite | reset
|
||||||
-spec render(verify | invite | reset, binary()) ->
|
-spec render(verify | invite | reset, binary()) ->
|
||||||
@@ -22,6 +22,20 @@ render_reminder(Url, Title, WhenText) ->
|
|||||||
Pre = <<"Напоминание о записи в CalenTIQ">>,
|
Pre = <<"Напоминание о записи в CalenTIQ">>,
|
||||||
assemble(Subject, Heading, Lead, Url, Cta, Foot, Pre).
|
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) ->
|
assemble(Subject, Heading, Lead, Url, Cta, Foot, Pre) ->
|
||||||
Plain = iolist_to_binary([Heading, <<"\n\n">>, Lead, <<"\n\n">>, Url, <<"\n">>]),
|
Plain = iolist_to_binary([Heading, <<"\n\n">>, Lead, <<"\n\n">>, Url, <<"\n">>]),
|
||||||
Html = fill(layout(), [
|
Html = fill(layout(), [
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
-export([notify_admin/2]).
|
-export([notify_admin/2]).
|
||||||
-export([notify_specialist_invite/2]).
|
-export([notify_specialist_invite/2]).
|
||||||
-export([notify_event_reminder/2]).
|
-export([notify_event_reminder/2]).
|
||||||
|
-export([notify_waitlist_promoted/2]).
|
||||||
|
|
||||||
%% Уведомление о бронировании
|
%% Уведомление о бронировании
|
||||||
notify_booking(UserId, Booking) ->
|
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),
|
_ = core_notification:create(UserId, event_reminder, NTitle, Body),
|
||||||
broadcast_to_user(UserId, event_reminder, Data#{user_id => UserId}).
|
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) ->
|
notify_admin(Type, Data) ->
|
||||||
Message = {admin_notification, 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_by_id,
|
||||||
handler_event_occurrences,
|
handler_event_occurrences,
|
||||||
handler_events,
|
handler_events,
|
||||||
|
handler_event_waitlist,
|
||||||
handler_reports,
|
handler_reports,
|
||||||
handler_review_by_id,
|
handler_review_by_id,
|
||||||
handler_review_vote,
|
handler_review_vote,
|
||||||
|
|||||||
@@ -87,6 +87,7 @@ ensure_indexes(Tables) when is_list(Tables) ->
|
|||||||
{event, [calendar_id, title, created_at, start_time, event_type,
|
{event, [calendar_id, title, created_at, start_time, event_type,
|
||||||
master_id, specialist_id, status]},
|
master_id, specialist_id, status]},
|
||||||
{booking, [event_id, user_id, status]},
|
{booking, [event_id, user_id, status]},
|
||||||
|
{waitlist_entry, [event_id, user_id, status]},
|
||||||
{review_vote, [review_id, user_id]},
|
{review_vote, [review_id, user_id]},
|
||||||
{calendar, [owner_id, status, short_name, category]},
|
{calendar, [owner_id, status, short_name, category]},
|
||||||
{calendar_specialist, [calendar_id, user_id]},
|
{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)}];
|
[{ram_copies, [node()]}, {type, bag}, {attributes, record_info(fields, recurrence_exception)}];
|
||||||
table_opts(booking) ->
|
table_opts(booking) ->
|
||||||
[{ram_copies, [node()]}, {attributes, record_info(fields, 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) ->
|
table_opts(review) ->
|
||||||
[{ram_copies, [node()]}, {attributes, record_info(fields, review)}];
|
[{ram_copies, [node()]}, {attributes, record_info(fields, review)}];
|
||||||
table_opts(review_vote) ->
|
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