diff --git a/drivers/place/auto_release.cr b/drivers/place/auto_release.cr index f5060780a11..08219e90237 100644 --- a/drivers/place/auto_release.cr +++ b/drivers/place/auto_release.cr @@ -7,13 +7,19 @@ class Place::AutoRelease < PlaceOS::Driver generic_name :AutoRelease description %(emails visitors to confirm automatic release of their booking when they have indicated they are not on-site and releases the booking if they do not confirm) + # time_window_hours: The number of hours to check for bookings pending release + # + # release_locations: Locations to release bookings for + # available locations: + # - wfh: Work From Home + # - aol: Away on Leave + # - wfo: Work From Office default_settings({ - timezone: "GMT", - send_emails: "15 */1 * * *", - email_template: "auto_release", - # release_url: "https://example.com/release", - time_window_hours: 2, - release_locations: ["wfh"], + timezone: "GMT", + send_emails: "*/5 * * * *", + email_template: "auto_release", + time_window_hours: 4, + release_locations: ["wfh", "aol"], }) accessor staff_api : StaffAPI_1 @@ -149,45 +155,69 @@ class Place::AutoRelease < PlaceOS::Driver end def release_bookings - released_bookings = [] of Booking - bookings = Array(Booking).from_json self[:pending_release].to_json + released_booking_ids = [] of Int64 + bookings = self[:pending_release]? ? Array(Booking).from_json(self[:pending_release].to_json) : [] of Booking + + previously_released = self[:released_booking_ids]? ? Array(Int64).from_json(self[:released_booking_ids].to_json) : [] of Int64 + # remove previously released bookings that are no longer pending release + previously_released -= previously_released - bookings.map(&.id) + # add previously released bookings that are still pending release + released_booking_ids += previously_released bookings.each do |booking| - if @auto_release.time_after > 0 && Time.utc.to_unix - booking.booking_start < @auto_release.time_after / 60 + next if previously_released.includes? booking.id + + if @auto_release.time_after > 0 && Time.utc.to_unix - booking.booking_start > @auto_release.time_after / 60 logger.debug { "rejecting booking #{booking.id} as it is within the time_after window" } staff_api.reject(booking.id).get - released_bookings << booking + released_booking_ids << booking.id end end - logger.debug { "released #{released_bookings.size} bookings" } + logger.debug { "released #{released_booking_ids.size} bookings" } - released_bookings + self[:released_booking_ids] = released_booking_ids rescue error logger.warn(exception: error) { "unable to release bookings" } - [] of Booking + self[:released_booking_ids] = [] of Int64 end @[Security(Level::Support)] def send_release_emails - bookings = Array(Booking).from_json self[:pending_release].to_json + emailed_booking_ids = [] of Int64 + bookings = self[:pending_release]? ? Array(Booking).from_json(self[:pending_release].to_json) : [] of Booking + + previously_emailed = self[:emailed_booking_ids]? ? Array(Int64).from_json(self[:emailed_booking_ids].to_json) : [] of Int64 + # remove previously emailed bookings that are no longer pending release + previously_emailed -= previously_emailed - bookings.map(&.id) + # add previously emailed bookings that are still pending release + emailed_booking_ids += previously_emailed bookings.each do |booking| - begin - mailer.send_template( - to: booking.user_email, - template: {@email_template, "auto_release"}, - args: { - booking_id: booking.id, - user_email: booking.user_email, - user_name: booking.user_name, - booking_start: booking.booking_start, - booking_end: booking.booking_end, - }) - rescue error - logger.warn(exception: error) { "failed to send release email to #{booking.user_email}" } + next if previously_emailed.includes? booking.id + + if @auto_release.time_before > 0 && + (booking.booking_start - Time.utc.to_unix < @auto_release.time_before / 60) && + (Time.utc.to_unix - booking.booking_start < @auto_release.time_after / 60) + logger.debug { "sending release email to #{booking.user_email} for booking #{booking.id} as it is withing the time_before window" } + begin + mailer.send_template( + to: booking.user_email, + template: {@email_template, "auto_release"}, + args: { + booking_id: booking.id, + user_email: booking.user_email, + user_name: booking.user_name, + booking_start: booking.booking_start, + booking_end: booking.booking_end, + }) + emailed_booking_ids << booking.id + rescue error + logger.warn(exception: error) { "failed to send release email to #{booking.user_email}" } + end end end + self[:emailed_booking_ids] = emailed_booking_ids end # time_before and time_after are in minutes diff --git a/drivers/place/auto_release_spec.cr b/drivers/place/auto_release_spec.cr index ef7ff3bd75c..ff2a4b8cf7c 100644 --- a/drivers/place/auto_release_spec.cr +++ b/drivers/place/auto_release_spec.cr @@ -2,6 +2,14 @@ require "placeos-driver/spec" require "placeos-driver/interface/mailer" class StaffAPI < DriverSpecs::MockDriver + def on_load + self[:rejected] = 0 + end + + def reject(booking_id : String | Int64, utm_source : String? = nil) + self[:rejected] = self[:rejected].as_i + 1 + end + def query_bookings( type : String? = nil, period_start : Int64? = nil, @@ -19,37 +27,129 @@ class StaffAPI < DriverSpecs::MockDriver bookings = [ { id: 1, - user_id: "user-wYLBwmC7GFbupt", + user_id: "user-wfh", user_email: "user_one@example.com", user_name: "User One", asset_id: "desk_001", - zones: ["zone_one"], + zones: ["zone-1234"], booking_type: "desk", booking_start: (Time.utc + 1.hour).to_unix, booking_end: (Time.utc + 2.hours).to_unix, timezone: "Australia/Darwin", - title: "Booking", - description: "desk one", + title: "ignore", + description: "", + checked_in: false, + rejected: false, + approved: true, + booked_by_id: "user-wfh", + booked_by_email: "user_one@example.com", + booked_by_name: "User One", + process_state: "approved", + last_changed: Time.utc.to_unix, + created: Time.utc.to_unix, + }, + { + id: 2, + user_id: "user-wfh", + user_email: "user_one@example.com", + user_name: "User One", + asset_id: "desk_002", + zones: ["zone-1234"], + booking_type: "desk", + booking_start: (Time.utc).to_unix, + booking_end: (Time.utc + 1.hour).to_unix, + timezone: "Australia/Darwin", + title: "notify", + description: "", checked_in: false, rejected: false, approved: true, - booked_by_id: "user-wYLBwmC7GFbupt", + booked_by_id: "user-wfh", booked_by_email: "user_one@example.com", booked_by_name: "User One", process_state: "approved", last_changed: Time.utc.to_unix, created: Time.utc.to_unix, }, + { + id: 3, + user_id: "user-wfh", + user_email: "user_one@example.com", + user_name: "User One", + asset_id: "desk_003", + zones: ["zone-1234"], + booking_type: "desk", + booking_start: (Time.utc - 11.minutes).to_unix, + booking_end: (Time.utc + 1.hour).to_unix, + timezone: "Australia/Darwin", + title: "reject", + description: "", + checked_in: false, + rejected: false, + approved: true, + booked_by_id: "user-wfh", + booked_by_email: "user_one@example.com", + booked_by_name: "User One", + process_state: "approved", + last_changed: Time.utc.to_unix, + created: Time.utc.to_unix, + }, + { + id: 4, + user_id: "user-wfh", + user_email: "user_one@example.com", + user_name: "User One", + asset_id: "desk_004", + zones: ["zone-1234"], + booking_type: "desk", + booking_start: (Time.utc + 5.hours).to_unix, + booking_end: (Time.utc + 6.hours).to_unix, + timezone: "Australia/Darwin", + title: "ignore_after_hours", + description: "", + checked_in: false, + rejected: false, + approved: true, + booked_by_id: "user-wfh", + booked_by_email: "user_one@example.com", + booked_by_name: "User One", + process_state: "approved", + last_changed: Time.utc.to_unix, + created: Time.utc.to_unix, + }, + { + id: 5, + user_id: "user-wfo", + user_email: "user_two@example.com", + user_name: "User Two", + asset_id: "desk_003", + zones: ["zone-1234"], + booking_type: "desk", + booking_start: (Time.utc - 11.minutes).to_unix, + booking_end: (Time.utc + 1.hour).to_unix, + timezone: "Australia/Darwin", + title: "ignore_wfo", + description: "", + checked_in: false, + rejected: false, + approved: true, + booked_by_id: "user-wfo", + booked_by_email: "user_two@example.com", + booked_by_name: "User Two", + process_state: "approved", + last_changed: Time.utc.to_unix, + created: Time.utc.to_unix, + }, ] JSON.parse(bookings.to_json) end def user(id : String) - user = { + user_wfh = { created_at: Time.utc.to_unix, id: id, - email_digest: "5acf9dfa861dbeabde3dc0a2148a0f2b", + email_digest: "not_real_digest", name: "User One", first_name: "User", last_name: "One", @@ -57,53 +157,57 @@ class StaffAPI < DriverSpecs::MockDriver country: "Australia", building: "", image: "", - authority_id: "authority-wYLBwmC7GFbupt", + authority_id: "authority-wfh", deleted: false, department: "", - work_preferences: [ + work_preferences: 7.times.map do |i| { - day_of_week: 0, - start_time: 9, - end_time: 17, - location: "wfo", - }, - { - day_of_week: 1, - start_time: 9, - end_time: 17, - location: "wfo", - }, - { - day_of_week: 2, - start_time: 9, - end_time: 17, - location: "wfh", - }, - { - day_of_week: 3, - start_time: 9, - end_time: 17, + day_of_week: i, + start_time: (Time.utc - 4.hours).hour, + end_time: (Time.utc + 4.hours).hour, location: "wfh", - }, - { + } + end, + work_overrides: { + "2024-02-15": { day_of_week: 4, start_time: 9, end_time: 17, - location: "wfh", - }, - { - day_of_week: 5, - start_time: 9, - end_time: 17, - location: "wfh", + location: "wfo", }, + }, + sys_admin: false, + support: false, + email: "user_one@example.com", + phone: "", + ui_theme: "light", + login_name: "", + staff_id: "", + card_number: "", + } + + user_wfo = { + created_at: Time.utc.to_unix, + id: id, + email_digest: "not_real_digest", + name: "User Two", + first_name: "User", + last_name: "Two", + groups: [] of String, + country: "Australia", + building: "", + image: "", + authority_id: "authority-wfo", + deleted: false, + department: "", + work_preferences: 7.times.map do |i| { - day_of_week: 6, - start_time: 9, - end_time: 17, + day_of_week: i, + start_time: (Time.utc - 4.hours).hour, + end_time: (Time.utc + 4.hours).hour, location: "wfo", - }, - ], + } + end, work_overrides: { "2024-02-15": { day_of_week: 4, @@ -114,7 +218,7 @@ class StaffAPI < DriverSpecs::MockDriver }, sys_admin: false, support: false, - email: "user_one@example.com", + email: "user_two@example.com", phone: "", ui_theme: "light", login_name: "", @@ -122,7 +226,46 @@ class StaffAPI < DriverSpecs::MockDriver card_number: "", } - JSON.parse(user.to_json) + case id + when "user-wfh" + JSON.parse(user_wfh.to_json) + # when "user-aol" + # JSON.parse(user_wfh.to_json) + when "user-wfo" + JSON.parse(user_wfo.to_json) + else + JSON.parse(user_wfh.to_json) + end + end + + def zones(q : String? = nil, + limit : Int32 = 1000, + offset : Int32 = 0, + parent : String? = nil, + tags : Array(String) | String? = nil) + zones = [ + { + created_at: 1660537814, + updated_at: 1681800971, + id: "zone-1234", + name: "Test Zone", + display_name: "Test Zone", + location: "", + description: "", + code: "", + type: "", + count: 0, + capacity: 0, + map_id: "", + tags: [ + "building", + ], + triggers: [] of String, + parent_id: "zone-0000", + }, + ] + + JSON.parse(zones.to_json) end end @@ -161,14 +304,60 @@ class Mailer < DriverSpecs::MockDriver end end -DriverSpecs.mock_driver "Place::StaffAPI" do +DriverSpecs.mock_driver "Place::AutoRelease" do system({ StaffAPI: {StaffAPI}, Mailer: {Mailer}, }) - _resp = exec(:pending_release).get - # _resp = exec(:get_user_preferences, "user-wYLBwmC7GFbupt").get + settings({ + time_window_hours: 8, + auto_release: { + time_before: 10, + time_after: 10, + resources: ["desk"], + }, + }) + + resp = exec(:get_building_id).get + resp.should eq "zone-1234" + + resp = exec(:enabled?).get + resp.should eq true + + resp = exec(:get_pending_bookings).get + resp.not_nil!.as_a.size.should eq 5 + + resp = exec(:get_user_preferences?, "user-wfh").get + resp.not_nil!.as_h.keys.should eq ["work_preferences", "work_overrides"] + + # Should only have 3 pending releases (ignore, notify, reject) + resp = exec(:pending_release).get + pending_release = resp.not_nil!.as_a.map(&.as_h["title"]) + pending_release.size.should eq 3 + pending_release.should eq ["ignore", "notify", "reject"] + + # Should only reject one booking (booking_id: 3, title: reject) + resp = exec(:release_bookings).get + resp.should eq [3] + system(:StaffAPI_1)[:rejected].should eq 1 + + # Don't try to reject bookings that have already been rejected + resp = exec(:release_bookings).get + resp.should eq [3] + system(:StaffAPI_1)[:rejected].should eq 1 + + # Send email once booking is past the time_before window, + # but before the time_after window + # (booking_id: 2, title: notify) + resp = exec(:send_release_emails).get + resp.should eq [2] + system(:Mailer_1)[:sent].should eq 1 + + # Spam protection, should not send email again + resp = exec(:send_release_emails).get + resp.should eq [2] + system(:Mailer_1)[:sent].should eq 1 - # system(:Mailer_1)[:sent].should eq 1 + # TODO: test work_overrides end