From a7d60ae3988d76774cb748174faaba9f75b2d2de Mon Sep 17 00:00:00 2001 From: KesterJJ <62260424+camreeves@users.noreply.github.com> Date: Tue, 6 Oct 2026 17:45:57 +0100 Subject: [PATCH] feat(place/booking-notifier): email booking reminders before booking start Adds a bookings.booking_reminder template trigger plus a scheduled sweep that emails the booking owner ahead of the booking starting. Reminders are configured as a list of offsets in minutes before the start, checked by a cron sweep, since a cron entry alone cannot express "3 days before an arbitrary booking start". A grace window stops stale reminders being sent after downtime, bookings created after their reminder moment and checked-in bookings are skipped, and each reminder is sent once with the state persisted in the driver status. --- drivers/place/booking_notifier.cr | 177 +++++++++++++++++++++++ drivers/place/booking_notifier_readme.md | 49 ++++++- drivers/place/booking_notifier_spec.cr | 120 ++++++++++++++- 3 files changed, 344 insertions(+), 2 deletions(-) diff --git a/drivers/place/booking_notifier.cr b/drivers/place/booking_notifier.cr index 4a7cb2aca7..7451e0010c 100644 --- a/drivers/place/booking_notifier.cr +++ b/drivers/place/booking_notifier.cr @@ -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", @@ -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 @@ -142,8 +161,19 @@ 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) + schedule.cron(cron, @time_zone) { send_booking_reminders } + end end def template_fields : Array(TemplateFields) @@ -201,6 +231,14 @@ 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", + fields: common_fields + [ + {name: "reminder_offset_minutes", description: "Minutes before the booking start this reminder was sent (e.g., 60)"}, + ] + ), ] end @@ -542,6 +580,145 @@ class Place::BookingNotifier < PlaceOS::Driver end end + # 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? + + now = Time.utc + now_unix = now.to_unix + query_end = (now + (offsets.max + @reminder_grace_minutes).minutes).to_unix + + sent = load_sent_reminders + upcoming = Set(String).new + sent_this_sweep = 0_u64 + + @check_bookings_mutex.synchronize do + @notify_lookup.each do |building_zone, details| + query_reminder_bookings(building_zone, now_unix, query_end).each do |booking_details| + # already on site, so there is nothing left to remind them about + next if booking_details.checked_in + + booking_key = "#{booking_details.id}.#{booking_details.instance || 0}" + upcoming << booking_key + + offsets.each do |offset| + seconds_before = booking_details.booking_start - now_unix + + # the booking has started, or this offset is still in the future + next unless seconds_before > 0 && seconds_before <= offset + + # the offset passed without this driver running - sending it now + # would be a stale reminder + next if seconds_before <= offset - @reminder_grace_minutes + + # booked after the reminder was meant to go out + if created = booking_details.created + next if created > booking_details.booking_start - offset + 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), + 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 + end + + # forget reminders for bookings that are no longer upcoming (cancelled, or started) + sent.select! { |booking_key, _| upcoming.includes?(booking_key) } + 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} reminders tracked" } + 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) + 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, + + booked_by_name: booking_details.booked_by_name, + booked_by_email: booking_details.booked_by_email, + + 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 diff --git a/drivers/place/booking_notifier_readme.md b/drivers/place/booking_notifier_readme.md index 65ccf81936..6e218c94b5 100644 --- a/drivers/place/booking_notifier_readme.md +++ b/drivers/place/booking_notifier_readme.md @@ -66,12 +66,51 @@ 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. +* 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. The sent state is stored in the + driver status under `reminders_sent`, so a restart of the driver does not + re-send reminders. +* Reminder windows are calculated from the booking's Unix start time, so the + booking timezone does not change when a reminder fires. + +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: @@ -82,6 +121,12 @@ email_templates: your desk %{asset_id} has been booked for %{start_date} + booking_reminder: + subject: Reminder - your desk booking + html: > + + your desk %{asset_id} starts at %{start_time} on %{start_date} + ``` The variables available to mix into the email template are: @@ -107,3 +152,5 @@ 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) diff --git a/drivers/place/booking_notifier_spec.cr b/drivers/place/booking_notifier_spec.cr index 50ff9d6e3c..e96f43db90 100644 --- a/drivers/place/booking_notifier_spec.cr +++ b/drivers/place/booking_notifier_spec.cr @@ -1,7 +1,35 @@ require "placeos-driver/spec" require "placeos-driver/interface/mailer" -DriverSpecs.mock_driver "Place::BookingCheckInHelper" do +# Desk booking fixture anchored to the current time, so the reminder offsets +# (which the driver compares against Time.utc) can be exercised deterministically +def reminder_booking( + id : Int32, + starts_in : Time::Span, + email : String, + created : Time? = nil, + checked_in : Bool = false, +) + starting = Time.utc + starts_in + { + id: id, + booking_type: "desk", + booking_start: starting.to_unix, + booking_end: (starting + 8.hours).to_unix, + asset_id: "desk-#{id}", + user_id: "user-#{id}", + user_email: email, + user_name: "Person #{id}", + zones: ["zone-building"], + checked_in: checked_in, + rejected: false, + created: created.try(&.to_unix), + booked_by_name: "Booker #{id}", + booked_by_email: "booker-#{id}@org.com", + } +end + +DriverSpecs.mock_driver "Place::BookingNotifier" do system({ Mailer: {MailerMock}, Calendar: {CalendarMock}, @@ -16,12 +44,83 @@ DriverSpecs.mock_driver "Place::BookingCheckInHelper" do system(:Mailer)[:to].should eq ["concierge@place.com", "user1234@org.com", "manager@site.com"] # replies should go to the person who created the booking, not the PlaceOS sender system(:Mailer)[:reply_to].should eq "user1234@org.com" + + # ============================ booking reminders ============================ + + reminder_settings = { + booking_type: "desk", + poll_bookings: false, + poll_every_minutes: 5, + reminder_schedule: "", # driven by exec below rather than the cron + reminder_grace_minutes: 30, + reminders_before_booking: [60], + notify: { + zone_id1: { + name: "Sydney Building 1", + email: ["concierge@place.com"], + notify_manager: true, + notify_booking_owner: true, + }, + }, + } + + # starts inside the 60 minute offset (45 seconds before the start, the grace + # window is 30 minutes) => reminder due + due = reminder_booking(id: 11, starts_in: 45.seconds, email: "due@org.com") + # starts outside the 60 minute offset => no reminder yet + not_yet_due = reminder_booking(id: 12, starts_in: 2.hours, email: "later@org.com") + # booked 10 seconds ago for a start 45 seconds away, so the 60 minute + # reminder moment had already passed when it was booked => skipped + booked_too_late = reminder_booking( + id: 13, + starts_in: 45.seconds, + email: "late@org.com", + created: Time.utc - 10.seconds + ) + # already on site => skipped + on_site = reminder_booking(id: 14, starts_in: 45.seconds, email: "onsite@org.com", checked_in: true) + + system(:StaffAPI_1).as(StaffAPIMock).set_reminders( + [due, not_yet_due, booked_too_late, on_site].map { |booking| JSON.parse(booking.to_json) } + ) + + settings(reminder_settings) + + exec(:send_booking_reminders).get + + system(:Mailer)[:reminders].should eq 1 + system(:Mailer)[:template].should eq ["bookings", "booking_reminder"] + system(:Mailer)[:to].should eq "due@org.com" + # replies should go to the person who created the booking, not the PlaceOS sender + system(:Mailer)[:reply_to].should eq "booker-11@org.com" + status[:reminders_sent_count].should eq 1 + + # a second sweep does not send the same reminder again + exec(:send_booking_reminders).get + system(:Mailer)[:reminders].should eq 1 + status[:reminders_sent_count].should eq 1 + + # moving the offset out to 2 hours catches the booking that wasn't due yet, + # and the booking that was skipped is still skipped + settings(reminder_settings.merge({reminders_before_booking: [7200]})) + exec(:send_booking_reminders).get + system(:Mailer)[:reminders].should eq 2 + system(:Mailer)[:to].should eq "later@org.com" + system(:Mailer)[:reply_to].should eq "booker-12@org.com" + status[:reminders_sent_count].should eq 2 + + # the reminder trigger is advertised to the template mailer + triggers = exec(:template_fields).get.not_nil!.as_a.map { |fields| fields["trigger"].as_a.map(&.as_s).join(".") } + triggers.should contain "bookings.booking_notify" + triggers.should contain "bookings.booking_reminder" end # :nodoc: class MailerMock < DriverSpecs::MockDriver include PlaceOS::Driver::Interface::Mailer + @reminders : Int32 = 0 + # need this for the interface def send_mail( to : String | Array(String), @@ -53,6 +152,11 @@ class MailerMock < DriverSpecs::MockDriver self[:template] = template self[:to] = to self[:reply_to] = reply_to + + if template[1].starts_with?("booking_reminder") + @reminders += 1 + self[:reminders] = @reminders + end end end @@ -68,6 +172,12 @@ end # :nodoc: class StaffAPIMock < DriverSpecs::MockDriver @called : Int32 = 0 + @reminders : Array(JSON::Any) = [] of JSON::Any + + # bookings returned by query_bookings once reminders have been configured + def set_reminders(bookings : Array(JSON::Any)) + @reminders = bookings + end def query_bookings( type : String, @@ -87,6 +197,14 @@ class StaffAPIMock < DriverSpecs::MockDriver @called += 1 self[:queries] = @called + + # a reminder sweep queries each zone twice (approved and not yet approved), + # so the fixtures come back on the first query of each pair + unless @reminders.empty? + return @reminders if @called.odd? + return [] of JSON::Any + end + return [] of String if @called >= 2 now = Time.local