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:
+