feat(email): send booking reminders before event start.
Job process_reminders in subscription_worker; REMINDER_LEAD_HOURS window; CalenTIQ email + in-app event_reminder; idempotent reminder_sent. Refs EventHub/EventHubBack#70
This commit is contained in:
@@ -26,6 +26,7 @@ handle_cast(_Msg, State) ->
|
||||
handle_info(tick, State) ->
|
||||
_ = catch logic_subscription:handle_expired_subscriptions(),
|
||||
_ = catch logic_booking:process_timeout_bookings(),
|
||||
_ = catch logic_booking:process_reminders(),
|
||||
erlang:send_after(?INTERVAL_MS, self(), tick),
|
||||
{noreply, State};
|
||||
handle_info(_Info, State) ->
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
delete_booking/2,
|
||||
list_bookings_admin/0, get_booking_admin/1,
|
||||
list_event_bookings/1, list_event_bookings/2,
|
||||
process_timeout_bookings/0, cancel_pending_for_owner/1,
|
||||
process_timeout_bookings/0, process_reminders/0, cancel_pending_for_owner/1,
|
||||
cancel_pending_for_calendar/1]).
|
||||
|
||||
%%%-------------------------------------------------------------------
|
||||
@@ -367,6 +367,84 @@ process_timeout_bookings() ->
|
||||
lists:foreach(fun(B) -> maybe_timeout(B, NowSec) end, Pending),
|
||||
ok.
|
||||
|
||||
%%%-------------------------------------------------------------------
|
||||
%%% @doc Email + in-app reminder before event start (Back#70).
|
||||
%%% Confirmed bookings with reminder_sent=false whose event starts within
|
||||
%%% REMINDER_LEAD_HOURS (default 24). Flag set before send (once).
|
||||
%%% @end
|
||||
%%%-------------------------------------------------------------------
|
||||
-spec process_reminders() -> ok.
|
||||
process_reminders() ->
|
||||
NowSec = calendar:datetime_to_gregorian_seconds(calendar:universal_time()),
|
||||
LeadSec = reminder_lead_hours() * 3600,
|
||||
Horizon = NowSec + LeadSec,
|
||||
Candidates = mnesia:dirty_match_object(
|
||||
#booking{status = confirmed, reminder_sent = false, _ = '_'}),
|
||||
lists:foreach(fun(B) -> maybe_remind(B, NowSec, Horizon) end, Candidates),
|
||||
ok.
|
||||
|
||||
maybe_remind(#booking{id = Id, event_id = EventId, user_id = UserId} = Booking,
|
||||
NowSec, Horizon) ->
|
||||
case core_event:get_by_id(EventId) of
|
||||
{ok, #event{status = active, start_time = Start, title = Title,
|
||||
calendar_id = CalId} = _Event} ->
|
||||
StartSec = calendar:datetime_to_gregorian_seconds(Start),
|
||||
case StartSec > NowSec andalso StartSec =< Horizon of
|
||||
true ->
|
||||
case core_booking:update(Id, [{reminder_sent, true}]) of
|
||||
{ok, _} ->
|
||||
send_reminder(UserId, Title, Start, CalId, EventId),
|
||||
ok;
|
||||
_ ->
|
||||
ok
|
||||
end;
|
||||
false ->
|
||||
ok
|
||||
end;
|
||||
_ ->
|
||||
ok
|
||||
end,
|
||||
Booking.
|
||||
|
||||
send_reminder(UserId, Title, Start, CalId, EventId) ->
|
||||
Path = <<"/c/", CalId/binary, "/e/", EventId/binary>>,
|
||||
WhenText = format_when(Start),
|
||||
case core_user:get_by_id(UserId) of
|
||||
{ok, #user{email = Email}} when is_binary(Email), Email =/= <<>> ->
|
||||
_ = logic_email:send_booking_reminder(Email, Title, Start, Path);
|
||||
_ ->
|
||||
ok
|
||||
end,
|
||||
_ = logic_notification:notify_event_reminder(UserId, #{
|
||||
event_id => EventId,
|
||||
calendar_id => CalId,
|
||||
title => Title,
|
||||
start_time => Start,
|
||||
when_text => WhenText
|
||||
}),
|
||||
ok.
|
||||
|
||||
format_when({{Y, Mo, D}, {H, Mi, _S}}) ->
|
||||
iolist_to_binary(io_lib:format("~4..0B-~2..0B-~2..0B ~2..0B:~2..0B",
|
||||
[Y, Mo, D, H, Mi])).
|
||||
|
||||
reminder_lead_hours() ->
|
||||
case application:get_env(eventhub, reminder_lead_hours) of
|
||||
{ok, N} when is_integer(N), N > 0 -> N;
|
||||
_ ->
|
||||
case os:getenv("REMINDER_LEAD_HOURS") of
|
||||
false -> 24;
|
||||
"" -> 24;
|
||||
S ->
|
||||
try list_to_integer(S) of
|
||||
N when N > 0 -> N;
|
||||
_ -> 24
|
||||
catch
|
||||
_:_ -> 24
|
||||
end
|
||||
end
|
||||
end.
|
||||
|
||||
maybe_timeout(#booking{id = Id, event_id = EventId, created_at = Created} = Booking, NowSec) ->
|
||||
case core_event:get_by_id(EventId) of
|
||||
{ok, Event} ->
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
-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]).
|
||||
|
||||
%% SMTP via env (variant A / Back#68):
|
||||
%% SMTP_HOST empty → log-only (local/tests)
|
||||
@@ -19,6 +20,15 @@ send_specialist_invite(Email, Token) ->
|
||||
send_password_reset(Email, Token) ->
|
||||
send_kind(reset, Email, abs_url(<<"/reset-password?token=">>, Token)).
|
||||
|
||||
%% Reminder before event start (Back#70).
|
||||
-spec send_booking_reminder(binary(), binary(), calendar:datetime(), binary()) -> ok.
|
||||
send_booking_reminder(Email, EventTitle, StartTime, EventPath) ->
|
||||
Url = abs_path(EventPath),
|
||||
WhenText = format_utc(StartTime),
|
||||
{Subject, Plain, Html} =
|
||||
logic_email_templates:render_reminder(Url, to_bin(EventTitle), WhenText),
|
||||
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).
|
||||
@@ -99,6 +109,18 @@ abs_url(PathPrefix, Token) ->
|
||||
TokenB = to_bin(Token),
|
||||
<<Base/binary, PathPrefix/binary, TokenB/binary>>.
|
||||
|
||||
abs_path(Path0) ->
|
||||
Base = strip_slash(env_bin("PUBLIC_APP_URL", <<"https://stage.calentiq.com">>)),
|
||||
Path = case to_bin(Path0) of
|
||||
<<$/, Rest/binary>> -> <<$/, Rest/binary>>;
|
||||
Rest -> <<$/, Rest/binary>>
|
||||
end,
|
||||
<<Base/binary, Path/binary>>.
|
||||
|
||||
format_utc({{Y, Mo, D}, {H, Mi, _S}}) ->
|
||||
iolist_to_binary(io_lib:format("~4..0B-~2..0B-~2..0B ~2..0B:~2..0B",
|
||||
[Y, Mo, D, H, Mi])).
|
||||
|
||||
strip_slash(Url) ->
|
||||
case binary:last(Url) of
|
||||
$/ -> binary:part(Url, 0, byte_size(Url) - 1);
|
||||
|
||||
@@ -1,11 +1,28 @@
|
||||
-module(logic_email_templates).
|
||||
-export([render/2]).
|
||||
-export([render/2, render_reminder/3]).
|
||||
|
||||
%% Kind: verify | invite | reset
|
||||
-spec render(verify | invite | reset, binary()) ->
|
||||
{Subject :: binary(), Plain :: binary(), Html :: binary()}.
|
||||
render(Kind, Url) ->
|
||||
{Subject, Heading, Lead, Cta, Foot, Pre} = copy(Kind),
|
||||
assemble(Subject, Heading, Lead, Url, Cta, Foot, Pre).
|
||||
|
||||
%% Booking reminder (Back#70): title + when text in lead.
|
||||
-spec render_reminder(binary(), binary(), binary()) ->
|
||||
{Subject :: binary(), Plain :: binary(), Html :: binary()}.
|
||||
render_reminder(Url, Title, WhenText) ->
|
||||
Subject = <<"CalenTIQ: напоминание о записи">>,
|
||||
Heading = <<"Напоминание о записи">>,
|
||||
Lead = iolist_to_binary([
|
||||
<<"Скоро: «">>, Title, <<"» — ">>, WhenText, <<" (UTC).">>
|
||||
]),
|
||||
Cta = <<"Открыть запись">>,
|
||||
Foot = <<"Вы получили письмо, потому что записаны на это событие в CalenTIQ.">>,
|
||||
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(), [
|
||||
{<<"{{preheader}}">>, Pre},
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
-export([notify_event_update/1]).
|
||||
-export([notify_admin/2]).
|
||||
-export([notify_specialist_invite/2]).
|
||||
-export([notify_event_reminder/2]).
|
||||
|
||||
%% Уведомление о бронировании
|
||||
notify_booking(UserId, Booking) ->
|
||||
@@ -45,6 +46,13 @@ notify_specialist_invite(UserId, Invite) ->
|
||||
},
|
||||
broadcast_to_user(UserId, specialist_invite, Data).
|
||||
|
||||
%% In-app / WS: напоминание о записи (Back#70)
|
||||
notify_event_reminder(UserId, #{title := Title, when_text := WhenText} = Data) ->
|
||||
NTitle = <<"Напоминание о записи">>,
|
||||
Body = iolist_to_binary([<<"Скоро: «">>, Title, <<"» — ">>, WhenText, <<" (UTC)">>]),
|
||||
_ = core_notification:create(UserId, event_reminder, NTitle, Body),
|
||||
broadcast_to_user(UserId, event_reminder, Data#{user_id => UserId}).
|
||||
|
||||
%% Уведомление для администраторов
|
||||
notify_admin(Type, Data) ->
|
||||
Message = {admin_notification, Type, Data},
|
||||
|
||||
Reference in New Issue
Block a user