Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
225 changes: 225 additions & 0 deletions drivers/place/booking_notifier.cr
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,19 @@ class Place::BookingNotifier < PlaceOS::Driver
poll_bookings: false,
poll_every_minutes: 5,

# Reminders emailed to the booking owner before their booking starts.
# One reminder per entry, each entry is the number of minutes before the
# booking start time. An empty list disables reminders.
# i.e. [4320, 60, 15] => 3 days, 1 hour and 15 minutes before the booking
reminders_before_booking: [] of Int32,
# Cron schedule for checking bookings against the reminder offsets above.
# Run it more often than reminder_grace_minutes so no reminder is missed.
reminder_schedule: "*/15 * * * *",
# How long after an offset a reminder may still be sent. Covers the sweep
# interval and any downtime (a reminder outside this window is skipped
# rather than sent late).
reminder_grace_minutes: 30,

notify: {
zone_id1: {
name: "Sydney Building 1",
Expand Down Expand Up @@ -99,6 +112,12 @@ class Place::BookingNotifier < PlaceOS::Driver
@poll_bookings : Bool = false
@poll_every_minutes : Int32 = 5

# minutes before a booking start, sorted ascending, empty disables reminders
@reminders_before_booking : Array(Int32) = [] of Int32
@reminder_schedule : String? = nil
@reminder_grace_minutes : Int32 = 30
@reminders_sent_count : UInt64 = 0_u64

# Zone_id => notify settings
@notify_lookup : Hash(String, SiteDetails) = {} of String => SiteDetails

Expand Down Expand Up @@ -142,8 +161,21 @@ class Place::BookingNotifier < PlaceOS::Driver
@poll_bookings = setting(Bool, :poll_bookings)
@poll_every_minutes = setting(Int32, :poll_every_minutes)

@reminders_before_booking = (setting?(Array(Int32), :reminders_before_booking) || [] of Int32)
.reject { |minutes| minutes <= 0 }
.uniq
.sort
@reminder_schedule = setting?(String, :reminder_schedule).presence
@reminder_grace_minutes = (setting?(Int32, :reminder_grace_minutes) || 30).clamp(1, 10_080)

schedule.clear
schedule.every(@poll_every_minutes.minutes) { check_bookings } if @poll_bookings

if !@reminders_before_booking.empty? && (cron = @reminder_schedule)
# immediate: the sweep runs as soon as the driver starts or its settings
# change, so a restart cannot push reminders past their grace window
schedule.cron(cron, @time_zone, immediate: true) { send_booking_reminders }
end
end

def template_fields : Array(TemplateFields)
Expand Down Expand Up @@ -201,6 +233,16 @@ class Place::BookingNotifier < PlaceOS::Driver
description: "Notification when a booking is cancelled",
fields: common_fields
),
TemplateFields.new(
trigger: {"bookings", "booking_reminder#{@template_suffix}"},
name: "Booking reminder#{@template_fields_suffix}",
description: "Reminder sent to the booking owner ahead of the booking starting",
# no network credentials - a reminder must not rotate the password that
# the booking notification already handed out
fields: common_fields.reject { |field| {"network_username", "network_password"}.includes?(field[:name]) } + [
{name: "reminder_offset_minutes", description: "Minutes before the booking start this reminder was sent (e.g., 60)"},
]
),
]
end

Expand Down Expand Up @@ -542,6 +584,189 @@ class Place::BookingNotifier < PlaceOS::Driver
end
end

@reminders_mutex = Mutex.new

# Emails a reminder to the booking owner for every configured offset that the
# booking is currently inside of. Scheduled by on_update, and can be invoked
# directly to check for reminders without waiting for the schedule.
@[Security(Level::Support)]
def send_booking_reminders
offsets = @reminders_before_booking
return if offsets.empty?

# serialise sweeps (cron, the immediate sweep after a settings change and
# manual execs) so each one sees the reminders the previous one sent
@reminders_mutex.synchronize { sweep_booking_reminders(offsets) }
end

protected def sweep_booking_reminders(offsets : Array(Int32))
now_unix = Time.utc.to_unix
grace_seconds = @reminder_grace_minutes.to_i64 * 60
query_end = now_unix + offsets.max.to_i64 * 60 + grace_seconds

sent = load_sent_reminders
upcoming = Set(String).new
sent_this_sweep = 0_u64
# entries are only forgotten when every zone was queried successfully,
# otherwise a failed zone would look like its bookings were cancelled
all_zones_queried = true

begin
@notify_lookup.each do |building_zone, details|
# a zone that never emails the booking owner gets no reminders either
next unless details.notify_booking_owner

attachments = details.attachments.compact_map { |n, l| get_attachment(n, l) }
attach = attachments.first?
attachments.clear if @disable_attachments

begin
bookings = query_reminder_bookings(building_zone, now_unix, query_end)
rescue error
all_zones_queried = false
logger.error(exception: error) { "while querying reminder bookings in #{building_zone}" }
next
end

bookings.each do |booking_details|
# already on site, so there is nothing left to remind them about
next if booking_details.checked_in || booking_details.user_email.presence.nil?

# the start time is part of the key so a rescheduled booking is
# reminded about its new time
booking_key = "#{booking_details.id}.#{booking_details.instance || 0}.#{booking_details.booking_start}"
upcoming << booking_key
seconds_before = booking_details.booking_start - now_unix

offsets.each do |offset|
offset_seconds = offset.to_i64 * 60

# the booking has started, or this offset is still in the future
next unless seconds_before > 0 && seconds_before <= offset_seconds

# the offset passed without this driver running - sending it now
# would be a stale reminder
next if seconds_before <= offset_seconds - grace_seconds

# booked after the reminder was meant to go out
if created = booking_details.created
next if created > booking_details.booking_start - offset_seconds
end

next if sent[booking_key]?.try(&.includes?(offset))

begin
mailer.send_template(
to: booking_details.user_email,
template: {"bookings", "booking_reminder#{@template_suffix}"},
args: reminder_args(booking_details, building_zone, details.name, offset, attach),
attachments: attachments,
reply_to: booking_details.booked_by_email.presence,
).get
rescue error
logger.error(exception: error) { "while sending reminder for booking #{booking_details.id}" }
next
end

logger.debug { "sent #{offset} minute reminder for booking #{booking_details.id} to #{booking_details.user_email}" }
(sent[booking_key] ||= [] of Int32) << offset
sent_this_sweep += 1
end
end
end
ensure
# forget reminders for bookings that are no longer upcoming (cancelled,
# rescheduled or started). Always persisted, so reminders that went out
# before an error are not sent again by the next sweep
sent.select! { |booking_key, _| upcoming.includes?(booking_key) } if all_zones_queried
self[:reminders_sent] = sent

@reminders_sent_count += sent_this_sweep
self[:reminders_sent_count] = @reminders_sent_count
logger.debug { "sent #{sent_this_sweep} booking reminders, #{sent.size} bookings tracked" }
end
rescue error
logger.error { error.inspect_with_backtrace }
self[:last_error] = {
error: error.message,
time: Time.local.to_s,
function: "send_booking_reminders",
}
end

protected def query_reminder_bookings(building_zone : String, period_start : Int64, period_end : Int64) : Array(Booking)
unapproved = staff_api.query_bookings(
type: @booking_type,
period_start: period_start,
period_end: period_end,
zones: [building_zone],
approved: false,
rejected: false
).get.as_a

approved = staff_api.query_bookings(
type: @booking_type,
period_start: period_start,
period_end: period_end,
zones: [building_zone],
approved: true,
rejected: false
).get.as_a

Array(Booking).from_json((unapproved + approved).to_json)
end

protected def reminder_args(
booking_details : Booking,
building_zone : String,
building_name : String,
offset : Int32,
attach : NamedTuple(file_name: String, content: String, uri: String)? = nil,
)
timezone = booking_details.timezone.presence || @time_zone.name
location = Time::Location.load(timezone)
starting = Time.unix(booking_details.booking_start).in(location)
ending = Time.unix(booking_details.booking_end).in(location)

{
booking_id: booking_details.id,
start_time: starting.to_s(@time_format),
start_date: starting.to_s(@date_format),
start_datetime: starting.to_s(@date_time_format),
end_time: ending.to_s(@time_format),
end_date: ending.to_s(@date_format),
end_datetime: ending.to_s(@date_time_format),
starting_unix: booking_details.booking_start,

asset_id: booking_details.asset_id,
asset_name: lookup_asset(asset_id: booking_details.asset_id, type: booking_details.booking_type, zones: booking_details.zones),
user_id: booking_details.user_id,
user_email: booking_details.user_email,
user_name: booking_details.user_name,
reason: booking_details.title,

level_zone: booking_details.zones.reject { |z| z == building_zone }.first?,
building_zone: building_zone,
building_name: building_name,

approver_name: booking_details.approver_name,
approver_email: booking_details.approver_email,

booked_by_name: booking_details.booked_by_name,
booked_by_email: booking_details.booked_by_email,

attachment_name: attach.try &.[](:file_name),
attachment_url: attach.try &.[](:uri),

reminder_offset_minutes: offset.to_i64,
}
end

protected def load_sent_reminders : Hash(String, Array(Int32))
raw = self[:reminders_sent]?
raw ? Hash(String, Array(Int32)).from_json(raw.to_json) : Hash(String, Array(Int32)).new
end

@[Security(Level::Support)]
def get_manager(staff_email : String)
manager = calendar.get_user_manager(staff_email).get
Expand Down
72 changes: 71 additions & 1 deletion drivers/place/booking_notifier_readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,12 +66,70 @@ mail (the `reply_to` setting on the SMTP Mailer). See the Template Mailer readme
for the full precedence cascade.


## Booking reminders

The driver can email the booking owner a reminder before their booking starts.
Reminders are driven by a list of offsets and a scheduled sweep, because a plain
cron entry cannot express "3 days before a booking that starts on Thursday".

```yaml
# Minutes before the booking start time to email the booking owner.
# One reminder is sent per entry, an empty list disables reminders.
# i.e. [4320, 60, 15] => 3 days, 1 hour and 15 minutes before the booking
reminders_before_booking: [4320, 60]

# Cron schedule for the sweep that checks bookings against the offsets above.
# Run it more often than reminder_grace_minutes so no reminder is missed.
reminder_schedule: "*/15 * * * *"

# How long after an offset a reminder may still be sent. Covers the sweep
# interval and any driver downtime - a reminder outside this window is
# skipped rather than sent late.
reminder_grace_minutes: 30
```

* Recipients: only the booking owner (`user_email`), replies go to the booking
creator as per the Reply-To section above. A zone without
`notify_booking_owner: true` gets no reminders, matching the booking
notification emails.
* The sweep queries bookings of the driver's `booking_type`, so a reminder
reaches whichever kind of booking the instance is configured for - deploy an
instance per type (`desk`, `locker`, `visitor`, ...). For a visitor booking
`user_email` is the host, so the reminder goes to the host. With
`unique_templates: true` the trigger and template become
`bookings.booking_reminder_<booking_type>` (e.g. `booking_reminder_visitor`),
so each type can have its own template.
* Already checked-in bookings are skipped, as are bookings created after their
reminder time had passed (booking them would not have triggered a reminder).
* Each reminder is sent once per booking start time. The sent state is stored
in the driver status under `reminders_sent`, so a restart of the driver does
not re-send reminders. A booking moved to a new start time is reminded again
about the new time.
* The schedule sweeps immediately when the driver starts or its settings
change, then on the cron interval. A restart therefore checks for due
reminders straight away instead of waiting up to `reminder_schedule`.
* Reminder windows are calculated from the booking's Unix start time, so the
booking timezone does not change when a reminder fires.
* Zone `attachments` behave as they do on booking notifications: the file is
attached when `disable_attachments` is false, and `attachment_name` /
`attachment_url` merge fields are always available for linking to it.
* The reminder template does not provide `network_username` /
`network_password` - a reminder must not rotate the password that the
booking notification already handed out.

A sweep can also be triggered manually with the `send_booking_reminders`
function (level `Support`) while configuring or troubleshooting.


## Template configuration on Mailer

There are two templates that are expected:
The templates expected are:

* `booking_notify` (the booking owner booked the asset)
* `booked_by_notify` (someone booked on the owners behalf)
* `rejected` (the booking was rejected)
* `cancelled` (the booking was cancelled)
* `booking_reminder` (reminder ahead of the booking starting)

```yaml
email_templates:
Expand All @@ -82,6 +140,12 @@ email_templates:
<html><body>
your desk %{asset_id} has been booked for %{start_date}
</body></html>
booking_reminder:
subject: Reminder - your desk booking
html: >
<html><body>
your desk %{asset_id} starts at %{start_time} on %{start_date}
</body></html>
```

The variables available to mix into the email template are:
Expand All @@ -107,3 +171,9 @@ The variables available to mix into the email template are:
booked_by_email
attachment_name
attachment_url
reminder_offset_minutes (booking_reminder template only - the offset
in minutes that triggered this reminder)

`network_username` and `network_password` are available to the booking
notification templates when the zone enables `include_network_credentials`,
but are deliberately not offered on `booking_reminder`.
Loading
Loading