diff --git a/drivers/place/booking_notifier.cr b/drivers/place/booking_notifier.cr index 4a7cb2aca7..106f122f7a 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,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) @@ -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 @@ -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 diff --git a/drivers/place/booking_notifier_readme.md b/drivers/place/booking_notifier_readme.md index 65ccf81936..6d0e1ec111 100644 --- a/drivers/place/booking_notifier_readme.md +++ b/drivers/place/booking_notifier_readme.md @@ -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_` (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: @@ -82,6 +140,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 +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`. diff --git a/drivers/place/booking_notifier_spec.cr b/drivers/place/booking_notifier_spec.cr index 50ff9d6e3c..e8ab18440f 100644 --- a/drivers/place/booking_notifier_spec.cr +++ b/drivers/place/booking_notifier_spec.cr @@ -1,11 +1,42 @@ 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, + type : String = "desk", + asset_id : String? = nil, +) + starting = Time.utc + starts_in + { + id: id, + booking_type: type, + booking_start: starting.to_unix, + booking_end: (starting + 8.hours).to_unix, + asset_id: 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}, - StaffAPI: {StaffAPIMock}, + Mailer: {MailerMock}, + Calendar: {CalendarMock}, + StaffAPI: {StaffAPIMock}, + LocationServices: {LocationServicesMock}, }) exec(:check_bookings).get @@ -16,12 +47,204 @@ 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, + attachments: {"welcome.pdf" => "https://s3.example.com/welcome.pdf"}, + }, + }, + } + + # starts 45 minutes from now, inside the 60 minute offset and its 30 minute + # grace window => reminder due + due = reminder_booking(id: 11, starts_in: 45.minutes, email: "due@org.com") + # starts outside the 60 minute offset => no reminder yet + not_yet_due = reminder_booking(id: 12, starts_in: 100.minutes, email: "later@org.com") + # booked 10 minutes ago for a start 45 minutes 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.minutes, + email: "late@org.com", + created: Time.utc - 10.minutes + ) + # already on site => skipped + on_site = reminder_booking(id: 14, starts_in: 45.minutes, email: "onsite@org.com", checked_in: true) + # the 60 minute offset passed 40 minutes ago, beyond the 30 minute grace + # window (i.e. the driver was offline) => skipped rather than sent late + stale = reminder_booking(id: 19, starts_in: 20.minutes, email: "stale@org.com") + + system(:StaffAPI_1).as(StaffAPIMock).set_reminders( + [due, not_yet_due, booked_too_late, on_site, stale].map { |booking| JSON.parse(booking.to_json) } + ) + + # arming the schedule in settings sweeps immediately, so the reminders due + # right after a restart are picked up without waiting for the cron interval + settings(reminder_settings.merge({reminder_schedule: "*/15 * * * *"})) + + 50.times do + break if status["reminders_sent_count"]?.try(&.as_i) == 1 + sleep 100.milliseconds + end + + 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 + + # every merge field advertised for the reminder is supplied + reminder_args = system(:Mailer)[:args].as_h + reminder_args["reminder_offset_minutes"].as_i.should eq 60 + reminder_args["attachment_name"].as_s.should eq "welcome.pdf" + reminder_args["attachment_url"].as_s.should eq "https://s3.example.com/welcome.pdf" + reminder_args["approver_name"].raw.should be_nil + # zone attachments follow disable_attachments (default true) => no files attached + system(:Mailer)[:attachments].as_a.should be_empty + + # disarm the schedule so only exec drives the sweeps below, and a manual + # sweep after the scheduled one does not send the same reminder again + settings(reminder_settings) + 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 bookings that were skipped are still skipped + settings(reminder_settings.merge({reminders_before_booking: [120]})) + 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 + + # a zone that does not notify the booking owner gets no reminders either + ownerless_due = reminder_booking(id: 15, starts_in: 45.minutes, email: "no-flag@org.com") + system(:StaffAPI_1).as(StaffAPIMock).set_reminders([ownerless_due].map { |booking| JSON.parse(booking.to_json) }) + settings(reminder_settings.merge({ + notify: {zone_id1: {name: "Sydney Building 1", email: ["concierge@place.com"]}}, + })) + exec(:send_booking_reminders).get + system(:Mailer)[:reminders].should eq 2 + status[:reminders_sent_count].should eq 2 + + # a visitor booking is queried as a visitor booking and reminded at the + # host's address, using the booking type specific template name + visitor_due = reminder_booking( + id: 16, + starts_in: 45.minutes, + email: "host@org.com", + type: "visitor", + asset_id: "guest@visitor.com" + ) + system(:StaffAPI_1).as(StaffAPIMock).set_reminders([visitor_due].map { |booking| JSON.parse(booking.to_json) }) + settings(reminder_settings.merge({booking_type: "visitor", unique_templates: true})) + exec(:send_booking_reminders).get + + system(:StaffAPI)[:last_type].should eq "visitor" + system(:Mailer)[:reminders].should eq 3 + system(:Mailer)[:template].should eq ["bookings", "booking_reminder_visitor"] + system(:Mailer)[:to].should eq "host@org.com" + system(:Mailer)[:reply_to].should eq "booker-16@org.com" + status[:reminders_sent_count].should eq 3 + + # the reminder trigger is advertised to the template mailer + triggers = exec(:template_fields).get.not_nil!.as_a + trigger_names = triggers.map { |fields| fields["trigger"].as_a.map(&.as_s).join(".") } + trigger_names.should contain "bookings.booking_notify_visitor" + trigger_names.should contain "bookings.booking_reminder_visitor" + + reminder_fields = triggers.find { |fields| fields["trigger"].as_a.map(&.as_s).join(".") == "bookings.booking_reminder_visitor" } + field_names = reminder_fields.not_nil!["fields"].as_a.map { |field| field["name"].as_s } + field_names.should contain "reminder_offset_minutes" + field_names.should contain "attachment_name" + # a reminder must never rotate the network password handed out by the booking email + field_names.includes?("network_username").should be_false + field_names.includes?("network_password").should be_false + # every merge field advertised for the reminder is present in the arguments + # that were actually sent, so no placeholder can reach the email + # unsubstituted (the field list is shared by every booking type) + field_names.sort.should eq system(:Mailer)[:args].as_h.keys.sort + + # two offsets open at the same time on one booking => one reminder each, + # each carrying its own offset, sent in ascending order of the offsets + multi_due = reminder_booking(id: 17, starts_in: 40.minutes, email: "multi@org.com") + system(:StaffAPI_1).as(StaffAPIMock).set_reminders([multi_due].map { |booking| JSON.parse(booking.to_json) }) + settings(reminder_settings.merge({reminders_before_booking: [60, 45]})) + exec(:send_booking_reminders).get + + system(:Mailer)[:reminders].should eq 5 + system(:Mailer)[:to].should eq "multi@org.com" + system(:Mailer)[:offsets].as_a.last(2).map(&.as_i).should eq [45, 60] + status[:reminders_sent_count].should eq 5 + + # a locker booking is queried as a locker booking and reminded at the + # owner's address with the locker specific template name + locker_due = reminder_booking(id: 18, starts_in: 45.minutes, email: "locker@org.com", type: "locker", asset_id: "lock-1") + system(:StaffAPI_1).as(StaffAPIMock).set_reminders([locker_due].map { |booking| JSON.parse(booking.to_json) }) + settings(reminder_settings.merge({ + booking_type: "locker", + unique_templates: true, + reminders_before_booking: [60], + })) + exec(:send_booking_reminders).get + + system(:StaffAPI)[:last_type].should eq "locker" + system(:Mailer)[:reminders].should eq 6 + system(:Mailer)[:template].should eq ["bookings", "booking_reminder_locker"] + system(:Mailer)[:to].should eq "locker@org.com" + system(:Mailer)[:offsets].as_a.last.as_i.should eq 60 + status[:reminders_sent_count].should eq 6 + + # the locker booking moved to a new start time => reminded about the new time + locker_moved = reminder_booking(id: 18, starts_in: 50.minutes, email: "locker@org.com", type: "locker", asset_id: "lock-1") + system(:StaffAPI_1).as(StaffAPIMock).set_reminders([locker_moved].map { |booking| JSON.parse(booking.to_json) }) + exec(:send_booking_reminders).get + system(:Mailer)[:reminders].should eq 7 + system(:Mailer)[:args].as_h["starting_unix"].as_i64.should eq locker_moved[:booking_start] + exec(:send_booking_reminders).get + system(:Mailer)[:reminders].should eq 7 + + # a zone whose bookings cannot be queried does not stop the other zones + # being reminded, and the reminders sent are remembered for the next sweep + failing_due = reminder_booking(id: 20, starts_in: 45.minutes, email: "failing@org.com") + system(:StaffAPI_1).as(StaffAPIMock).set_reminders([failing_due].map { |booking| JSON.parse(booking.to_json) }) + failing_zones = reminder_settings.merge({ + notify: { + zone_id1: {name: "Sydney Building 1", notify_booking_owner: true}, + zone_offline: {name: "Offline Building", notify_booking_owner: true}, + }, + }) + settings(failing_zones) + exec(:send_booking_reminders).get + system(:Mailer)[:reminders].should eq 8 + system(:Mailer)[:to].should eq "failing@org.com" + status[:reminders_sent].as_h.keys.should contain "20.0.#{failing_due[:booking_start]}" + exec(:send_booking_reminders).get + system(:Mailer)[:reminders].should eq 8 end # :nodoc: class MailerMock < DriverSpecs::MockDriver include PlaceOS::Driver::Interface::Mailer + @reminders : Int32 = 0 + @offsets : Array(Int64) = [] of Int64 + # need this for the interface def send_mail( to : String | Array(String), @@ -53,6 +276,16 @@ class MailerMock < DriverSpecs::MockDriver self[:template] = template self[:to] = to self[:reply_to] = reply_to + self[:args] = args + self[:attachments] = attachments + + if template[1].starts_with?("booking_reminder") + @reminders += 1 + self[:reminders] = @reminders + offset = args["reminder_offset_minutes"]? + @offsets << offset.as(Int64) if offset.is_a?(Int64) + self[:offsets] = @offsets + end end end @@ -65,9 +298,23 @@ class CalendarMock < DriverSpecs::MockDriver end end +# :nodoc: +class LocationServicesMock < DriverSpecs::MockDriver + # the locker asset name lookup resolves the building through locations + def building_id : String + "zone-building" + end +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, @@ -85,8 +332,18 @@ class StaffAPIMock < DriverSpecs::MockDriver ) logger.debug { "Querying desk bookings!" } + self[:last_type] = type @called += 1 self[:queries] = @called + + # a reminder sweep queries each zone twice (approved and not yet approved), + # so the fixtures come back on the not yet approved query + unless @reminders.empty? + raise "staff api unavailable for #{zones}" if zones.includes?("zone_offline") + return @reminders if approved == false + return [] of JSON::Any + end + return [] of String if @called >= 2 now = Time.local @@ -113,4 +370,10 @@ class StaffAPIMock < DriverSpecs::MockDriver self[:booking_state] = "#{booking_id}--#{state}" true end + + # no lockers are configured for these tests, so the locker asset name lookup + # falls back to the asset id just as it does for desks + def metadata(id : String, key : String? = nil) + Hash(String, JSON::Any).new + end end