diff --git a/drivers/place/auto_release.cr b/drivers/place/auto_release.cr index 5d7b435dd9f..d80f0cd796c 100644 --- a/drivers/place/auto_release.cr +++ b/drivers/place/auto_release.cr @@ -106,6 +106,44 @@ class Place::AutoRelease < PlaceOS::Driver end end + # Explain why a booking was released or not released + @[Security(Level::Support)] + def explain_state + pending_bookings = self[:pending_bookings]? ? Array(Booking).from_json(self[:pending_bookings].to_json) : [] of Booking + pending_release = self[:pending_release]? ? Array(Booking).from_json(self[:pending_release].to_json) : [] of Booking + emailed_booking_ids = self[:emailed_booking_ids]? ? Array(Int64).from_json(self[:emailed_booking_ids].to_json) : [] of Int64 + released_booking_ids = self[:released_booking_ids]? ? Array(Int64).from_json(self[:released_booking_ids].to_json) : [] of Int64 + + counters = {:bookings => pending_bookings.size} + counters[:pending_release] = pending_release.size + counters[:emails_sent] = emailed_booking_ids.size + counters[:released] = released_booking_ids.size + + bookings = Hash(Int64, Array(String)).new + + # Add booking information details + pending_bookings.each do |booking| + bookings[booking.id] ||= Array(String).new + bookings[booking.id] << "checked_in: #{booking.checked_in}, booking_start: #{booking.booking_start}, booking_type: #{booking.booking_type}, all_day: #{booking.all_day}, user_id: #{booking.user_id}, asset_ids: #{booking.asset_ids}" + end + + # Add reasons from explain_pending_release + explain_pending_release = self[:explain_pending_release]? ? Hash(Int64, String).from_json(self[:explain_pending_release].to_json) : Hash(Int64, String).new + explain_pending_release.each do |booking_id, reason| + bookings[booking_id] ||= Array(String).new + bookings[booking_id] << reason + end + + # Add reasons from explain_release_bookings + explain_release_bookings = self[:explain_release_bookings]? ? Hash(Int64, String).from_json(self[:explain_release_bookings].to_json) : Hash(Int64, String).new + explain_release_bookings.each do |booking_id, reason| + bookings[booking_id] ||= Array(String).new + bookings[booking_id] << reason + end + + {enabled: enabled?, counters: counters, bookings: bookings} + end + # Finds the building zone for the current location services object def get_building_zone? : Zone? zones = Array(Zone).from_json staff_api.zones(tags: "building").get.to_json @@ -236,20 +274,20 @@ class Place::AutoRelease < PlaceOS::Driver if (override = overrides[booking_start.to_s(format: "%F")]?) && in_preference?(override, event_time, @release_locations) - explain[booking.id] = "release due to override matching time and location" + explain[booking.id] = "pending release due to override matching time and location" results << booking elsif (override = overrides[booking_start.to_s(format: "%F")]?) && in_preference?(override, event_time, @release_locations, false) explain[booking.id] = "skipped due to override matching time but not location" elsif (preference = preferences[:work_preferences].find { |pref| pref.day_of_week == day_of_week }) && in_preference?(preference, event_time, @release_locations) - explain[booking.id] = "release due to matching time and location" + explain[booking.id] = "pending release due to matching time and location" results << booking elsif (preference = preferences[:work_preferences].find { |pref| pref.day_of_week == day_of_week }) && in_preference?(preference, event_time, @release_locations, false) explain[booking.id] = "skipped due to matching time but not location" elsif @auto_release.release_outside_hours - explain[booking.id] = "release due to outside work hours" + explain[booking.id] = "pending release due to outside work hours" results << booking end end @@ -281,6 +319,9 @@ class Place::AutoRelease < PlaceOS::Driver # add previously released bookings that are still pending release released_booking_ids += previously_released + previously_explained = self[:explain_release_bookings]? ? Hash(Int64, String).from_json(self[:explain_release_bookings].to_json) : Hash(Int64, String).new + explain = previously_explained.select { |key, _| released_booking_ids.includes?(key) } + bookings.each do |booking| next if previously_released.includes? booking.id @@ -293,8 +334,12 @@ class Place::AutoRelease < PlaceOS::Driver # convert minutes (time_after) to seconds for comparison with unix timestamps (booking_start) if Time.utc.to_unix - booking_start > @auto_release.time_after(booking.booking_type) * 60 # skip if there's been changes to the cached bookings checked_in status or booking_start time - next if skip_release?(booking) + if skip_release?(booking) + explain[booking.id] = "skip release due to changes to checked_in status or booking_start time" + next + end + explain[booking.id] = "rejecting booking as it is within the time_after window" logger.debug { "rejecting booking #{booking.id} as it is within the time_after window" } staff_api.reject(booking.id, "auto_release", booking.instance).get released_booking_ids << booking.id @@ -303,6 +348,7 @@ class Place::AutoRelease < PlaceOS::Driver logger.debug { "released #{released_booking_ids.size} bookings" } + self[:explain_release_bookings] = explain self[:released_booking_ids] = released_booking_ids rescue error logger.error(exception: error) { "unable to release bookings" } diff --git a/drivers/place/auto_release_readme.md b/drivers/place/auto_release_readme.md new file mode 100644 index 00000000000..d1b499c5ddb --- /dev/null +++ b/drivers/place/auto_release_readme.md @@ -0,0 +1,266 @@ +# Auto Release Readme + +Docs on how to configure the Auto Release driver. +This driver automatically releases bookings when users have indicated they are not on-site (work from home or away on leave) and haven't checked in to their booking. + +* The driver monitors bookings for configured resource types (e.g., desks) +* Checks user work location preferences and overrides against release locations +* Sends notification emails to users before automatically releasing their bookings +* Releases bookings after a configurable time period if users don't confirm attendance + + +## Requirements + +Requires the following drivers in the system: + +* StaffAPI - for querying bookings and user preferences +* Mailer - for sending notification emails, this also will be where the templates are configured + +**CRITICAL:** The building zone must have its timezone configured. The Auto Release driver always operates using the building's timezone for all time-based calculations and evaluations. + + +## Auto Release Configuration + +```yaml + # How do we want dates to be formatted in the email template + date_time_format: "%c" + time_format: "%l:%M%p" + date_format: "%A, %-d %B" + + # Cron schedule for sending notification emails (default every 5 minutes) + email_schedule: "*/5 * * * *" + + # Email template name to use for notifications + email_template: "auto_release" + + # Use unique templates per booking type (e.g. auto_release_desk, auto_release_parking) + unique_templates: false + + # Hours ahead to check for bookings that may need to be released + time_window_hours: 4 + + # User work locations that trigger auto-release + # Available locations: wfh (Work From Home), aol (Away on Leave), wfo (Work From Office) + release_locations: ["wfh", "aol"] + + # Skip bookings created after their start time + skip_created_after_start: true + + # Skip bookings created on the same day as the booking + skip_same_day: false + + # Skip all-day bookings from auto-release + skip_all_day: false + + # Cache timeout for asset name lookups (in seconds) + asset_cache_timeout: 3600 +``` + +## Zone Configuration Requirements + +**CRITICAL:** The `auto_release` configuration must be set on the building zone as an **Unencrypted** setting. This is absolutely required for both the driver to function properly and for the Concierge UI to manage auto-release settings. + +**STRONGLY RECOMMENDED:** Use the Concierge UI to configure auto-release settings. The UI provides a user-friendly interface for managing all auto-release configuration options and ensures proper validation of settings. + +The building zone configuration should look like this: + +```yaml +# This must be configured as Unencrypted on the building zone +auto_release: + # Time before booking start to send notification email (minutes) + # Can be negative to send notifications after booking start + time_before: 10 + + # Time after booking start to automatically release booking (minutes) + time_after: 15 + + # Resource types to monitor for auto-release + resources: ["desk", "parking"] + + # Default work preferences for users without configured preferences + default_work_preferences: [] + + # Release bookings outside of configured work hours + # Set to true for 24/7 automatic release capability + release_outside_hours: false + + # Start time for all-day bookings (24-hour format, e.g. 8.0 = 8:00 AM) + # See detailed explanation below + all_day_start: 8.0 + + # Per-resource timing overrides (optional) + # desk_time_before: 5 # Override time_before for desk bookings + # desk_time_after: 20 # Override time_after for desk bookings +``` + +### Important Configuration Notes + +- **Negative time_before values**: When `time_before` is negative, notification emails are sent AFTER the booking has started. For example, `time_before: -5` means emails are sent 5 minutes after the booking start time. + +- **24/7 Release**: For buildings that need automatic release at any time of day, set `release_outside_hours: true`. This is particularly useful for flexible workspaces. + +- **Resource-specific timing**: You can override `time_before` and `time_after` for specific resource types by adding `{resource}_time_before` and `{resource}_time_after` settings. + +- **All-day booking start time (`all_day_start`)**: All-day bookings technically start at midnight (00:00), but checking work preferences against midnight doesn't make practical sense. The `all_day_start` setting defines a virtual "work day start time" that is used ONLY for evaluating whether an all-day booking should be released based on the user's work location preferences. + + For example, if `all_day_start: 8.0` (8:00 AM) and a user has work preferences indicating they work from home from 8:00 AM to 5:00 PM, the system will check the user's 8:00 AM work location preference to determine if the all-day booking should be released. This makes the evaluation meaningful in the context of a normal work day rather than checking against midnight when most people wouldn't be expected to be at work anyway. + + The actual booking times remain unchanged - this setting only affects the work preference evaluation logic for all-day bookings. + + +## Default Work Preferences Configuration + +The `default_work_preferences` setting provides fallback work location preferences for users who haven't configured their preferences in the Workplace app. This is particularly useful during initial rollout or for users who haven't yet set up their work schedules. + +**RECOMMENDED:** Use the Concierge UI to configure default work preferences. The UI provides validation and makes it easier to set up complex schedules without manually writing YAML. + +```yaml +auto_release: + default_work_preferences: + # Monday (day 1) - User works from home 9 AM to 5 PM + - day_of_week: 1 + blocks: + - start_time: 9.0 # 9:00 AM + end_time: 17.0 # 5:00 PM + location: "wfh" # Work From Home + + # Tuesday (day 2) - User works from office 8:30 AM to 4:30 PM + - day_of_week: 2 + blocks: + - start_time: 8.5 # 8:30 AM + end_time: 16.5 # 4:30 PM + location: "wfo" # Work From Office + + # Wednesday (day 3) - Split day: morning WFH, afternoon WFO + - day_of_week: 3 + blocks: + - start_time: 9.0 # 9:00 AM + end_time: 13.0 # 1:00 PM + location: "wfh" + - start_time: 14.0 # 2:00 PM + end_time: 18.0 # 6:00 PM + location: "wfo" + + # Thursday (day 4) - Away on leave all day + - day_of_week: 4 + blocks: + - start_time: 0.0 # All day + end_time: 24.0 + location: "aol" # Away on Leave + + # Friday through Sunday can be omitted if no default schedule needed +``` + +### Day of Week Values +- `0` = Sunday +- `1` = Monday +- `2` = Tuesday +- `3` = Wednesday +- `4` = Thursday +- `5` = Friday +- `6` = Saturday + +### Time Format +Times are specified in 24-hour format as decimal numbers: +- `9.0` = 9:00 AM +- `9.5` = 9:30 AM +- `17.0` = 5:00 PM +- `17.75` = 5:45 PM + +### Location Values +- `"wfh"` = Work From Home (triggers auto-release) +- `"wfo"` = Work From Office (prevents auto-release) +- `"aol"` = Away on Leave (triggers auto-release) + +**Note**: These defaults are only used for users who haven't set their own preferences in the Workplace app. Once a user configures their preferences, these defaults are ignored for that user. + +**Important**: If `release_outside_hours: true` is set, configuring `default_work_preferences` may be unnecessary. When `release_outside_hours` is enabled, any booking that doesn't match the user's configured work preferences (or default preferences) will automatically be flagged for release anyway. This makes `default_work_preferences` primarily useful when you want more granular control over release timing rather than blanket 24/7 release behavior. + + +## Template Configuration on Mailer + +The driver expects an email template for notifying users about pending releases: + +* `auto_release` (default template name, configurable via email_template setting) +* `auto_release_desk` (if unique_templates is true and desk is a configured resource) +* `auto_release_parking` (if unique_templates is true and parking is a configured resource) + +```yaml +email_templates: + bookings: + auto_release: + subject: "Your booking may be released - %{asset_name} on %{start_date}" + html: > + +

Hello %{user_name},

+

Your booking for %{asset_name} on %{start_datetime} may be automatically released + because your work location preferences indicate you are working from home or away on leave.

+ +

If you plan to use this booking, please check in when you arrive.

+ +

Booking Details:

+ + +``` + +## Template Variables + +The following variables are available for use in email templates: + +* `booking_id` - Unique identifier for the booking that may be released +* `booking_start` - Unix timestamp of when the booking begins +* `booking_end` - Unix timestamp of when the booking ends +* `start_time` - Formatted start time (e.g., "9:00AM") +* `start_date` - Formatted start date (e.g., "Monday, 15 January") +* `start_datetime` - Formatted start date and time (e.g., "Mon Jan 15 09:00:00 2024") +* `end_time` - Formatted end time (e.g., "5:00PM") +* `end_date` - Formatted end date (e.g., "Monday, 15 January") +* `end_datetime` - Formatted end date and time (e.g., "Mon Jan 15 17:00:00 2024") +* `asset_id` - Identifier of the booked resource +* `asset_name` - Name of the booked resource +* `user_id` - Identifier of the person who has the booking +* `user_email` - Email address of the person who has the booking +* `user_name` - Full name of the person who has the booking +* `reason` - Title or purpose of the booking +* `approver_name` - Name of the person who approved the booking +* `approver_email` - Email of the person who approved the booking +* `booked_by_name` - Name of the person who made the booking +* `booked_by_email` - Email of the person who made the booking + + +## How It Works + +1. **Monitoring**: The driver periodically checks for bookings in the configured time window that haven't been checked in +2. **User Preferences**: For each booking, it retrieves the user's work location preferences and any daily overrides +3. **Location Matching**: If the user's work location preference matches a release location (e.g., "wfh", "aol") during the booking time, the booking is flagged for potential release +4. **Notification**: An email is sent to the user before the booking start time (configurable via `time_before`) +5. **Release**: If the user doesn't check in after the booking start time, the booking is automatically rejected after the configured delay (`time_after`) + +The driver respects user work preferences and overrides, only releasing bookings when users have indicated they will be working from home or away on leave during the booking period. + + +## User Work Preferences + +Users can set their work location preferences through the **Workplace app**. These preferences are stored in the Staff API and include: + +* **Work Preferences**: Regular weekly schedule indicating work location by day and time +* **Work Overrides**: Specific date overrides for the regular schedule (e.g., working from office on a normally WFH day) + +The driver checks both preferences and overrides to determine if a user will be on-site for their booking. Users should ensure their work location preferences are kept up-to-date in the Workplace app to avoid unnecessary booking releases. + + +## Debugging and Monitoring + +The driver provides a `explain_state` function that returns detailed information about: + +* Current pending bookings and why they are or aren't flagged for release +* Bookings that have been emailed +* Bookings that have been released +* Detailed reasoning for each booking's status + +This can be helpful for troubleshooting why certain bookings are or aren't being processed. \ No newline at end of file diff --git a/drivers/place/auto_release_spec.cr b/drivers/place/auto_release_spec.cr index c530816f4e8..8297bf3b09e 100644 --- a/drivers/place/auto_release_spec.cr +++ b/drivers/place/auto_release_spec.cr @@ -474,7 +474,7 @@ class StaffAPI < DriverSpecs::MockDriver created_after : Int64? = nil, approved : Bool? = nil, rejected : Bool? = nil, - checked_in : Bool? = nil + checked_in : Bool? = nil, ) JSON.parse(BOOKINGS.to_json) end @@ -788,7 +788,7 @@ class Mailer < DriverSpecs::MockDriver cc : String | Array(String) = [] of String, bcc : String | Array(String) = [] of String, from : String | Array(String) | Nil = nil, - reply_to : String | Array(String) | Nil = nil + reply_to : String | Array(String) | Nil = nil, ) self[:sent] = self[:sent].as_i + 1 end @@ -803,7 +803,7 @@ class Mailer < DriverSpecs::MockDriver cc : String | Array(String) = [] of String, bcc : String | Array(String) = [] of String, from : String | Array(String) | Nil = nil, - reply_to : String | Array(String) | Nil = nil + reply_to : String | Array(String) | Nil = nil, ) : Bool true end @@ -1231,32 +1231,32 @@ DriverSpecs.mock_driver "Place::AutoRelease" do # normal work hours, event in range # start at 8am, end at 4pm, event at 3pm resp = exec(:in_preference_hours?, 8.0, 16.0, 15.0).get - resp.should eq true + resp.should be_true # normal work hours, event out of range (after) # start at 8am, end at 4pm, event at 5pm resp = exec(:in_preference_hours?, 8.0, 16.0, 17.0).get - resp.should eq false + resp.should be_false # normal work hours, event out of range (before) # start at 8am, end at 4pm, event at 6am resp = exec(:in_preference_hours?, 8.0, 16.0, 6.0).get - resp.should eq false + resp.should be_false # work hours crosses midnight, event in range # start at 10pm, end at 6am, event at 3am resp = exec(:in_preference_hours?, 22.0, 6.0, 3.0).get - resp.should eq true + resp.should be_true # work hours crosses midnight, event out of range (after) # start at 10pm, end at 6am, event at 7am resp = exec(:in_preference_hours?, 22.0, 6.0, 7.0).get - resp.should eq false + resp.should be_false # work hours crosses midnight, event out of range (before) # start at 10pm, end at 6am, event at 8pm resp = exec(:in_preference_hours?, 22.0, 6.0, 20.0).get - resp.should eq false + resp.should be_false ######################################### # End of tests for: #in_preference_hours? @@ -1273,11 +1273,11 @@ DriverSpecs.mock_driver "Place::AutoRelease" do }, }) resp = exec(:enabled?).get - resp.should eq true + resp.should be_true # disabled when resources is empty resp = exec(:enabled?).get - resp.should eq true + resp.should be_true settings({ auto_release: { time_before: 10, @@ -1286,7 +1286,7 @@ DriverSpecs.mock_driver "Place::AutoRelease" do }, }) resp = exec(:enabled?).get - resp.should eq false + resp.should be_false ############################# # End of tests for: #enabled?