From a931cb2492e41fbe9e86c0ee87c708515b213279 Mon Sep 17 00:00:00 2001 From: Mia Bennett Date: Thu, 30 Apr 2026 11:46:43 +0930 Subject: [PATCH 01/11] test(visitor_mailer): fill test gaps --- drivers/place/visitor_mailer_spec.cr | 114 +++++++++++++++++++++++++++ 1 file changed, 114 insertions(+) diff --git a/drivers/place/visitor_mailer_spec.cr b/drivers/place/visitor_mailer_spec.cr index 84f5c3db754..bcd0103d276 100644 --- a/drivers/place/visitor_mailer_spec.cr +++ b/drivers/place/visitor_mailer_spec.cr @@ -346,4 +346,118 @@ DriverSpecs.mock_driver "Place::VisitorMailer" do sleep 0.5 system(:Mailer)[:send_count].should eq 3 + + # ================================================================== + # booking_host_changed_event tests + # ================================================================== + + # ------------------------------------------------------------------ + # Test 7: booking_host_changed — sends email to previous host + # ------------------------------------------------------------------ + + host_changed_payload = { + action: "host_changed", + booking_id: 200_i64, + resource_id: "desk-1", + resource_ids: ["desk-1"], + event_title: "Team Standup", + event_summary: "Team Standup Description", + event_starting: now + 3600, + previous_host_email: "old-host@example.com", + new_host_email: "new-host@example.com", + zones: ["zone-building", "zone-room"], + }.to_json + + publish("staff/booking/host_changed", host_changed_payload) + sleep 1.5 + + # Email should be sent to the previous host + system(:Mailer)[:send_count].should eq 4 + system(:Mailer)[:last_to].should eq "old-host@example.com" + system(:Mailer)[:last_template].should eq ["visitor_invited", "notify_original_host"] + + # Verify all template args + args7 = system(:Mailer)[:last_args] + args7["previous_host_email"].should eq "old-host@example.com" + args7["previous_host_name"].should eq "Host User" + args7["new_host_email"].should eq "new-host@example.com" + args7["new_host_name"].should eq "Host User" + args7["building_name"].should eq "Main Building" + args7["event_title"].should eq "Team Standup" + args7["event_date"].should_not be_nil + args7["event_time"].should_not be_nil + + # ------------------------------------------------------------------ + # Test 8: booking_host_changed — wrong zone is ignored + # ------------------------------------------------------------------ + + host_changed_wrong_zone = { + action: "host_changed", + booking_id: 201_i64, + resource_id: "desk-1", + resource_ids: ["desk-1"], + event_title: "Wrong Zone Meeting", + event_summary: "Wrong Zone Meeting", + event_starting: now + 3600, + previous_host_email: "old-host@example.com", + new_host_email: "new-host@example.com", + zones: ["zone-other-building"], + }.to_json + + publish("staff/booking/host_changed", host_changed_wrong_zone) + sleep 0.5 + + # Count should not have increased — event was for a different building + system(:Mailer)[:send_count].should eq 4 + + # ------------------------------------------------------------------ + # Test 9: booking_host_changed — nil zones skips zone filter + # ------------------------------------------------------------------ + + host_changed_no_zones = { + action: "host_changed", + booking_id: 202_i64, + resource_id: "desk-1", + resource_ids: ["desk-1"], + event_title: "No Zone Meeting", + event_summary: "No Zone Meeting", + event_starting: now + 7200, + previous_host_email: "old-host2@example.com", + new_host_email: "new-host2@example.com", + }.to_json + + publish("staff/booking/host_changed", host_changed_no_zones) + sleep 1.5 + + # When zones are nil, zone filtering is skipped — email should be sent + system(:Mailer)[:send_count].should eq 5 + system(:Mailer)[:last_to].should eq "old-host2@example.com" + system(:Mailer)[:last_template].should eq ["visitor_invited", "notify_original_host"] + + # ------------------------------------------------------------------ + # Test 10: booking_host_changed — event_title nil falls back to + # event_summary + # ------------------------------------------------------------------ + + host_changed_no_title = { + action: "host_changed", + booking_id: 203_i64, + resource_id: "desk-1", + resource_ids: ["desk-1"], + event_summary: "Fallback Summary Title", + event_starting: now + 3600, + previous_host_email: "old-host3@example.com", + new_host_email: "new-host3@example.com", + zones: ["zone-building"], + }.to_json + + publish("staff/booking/host_changed", host_changed_no_title) + sleep 1.5 + + system(:Mailer)[:send_count].should eq 6 + system(:Mailer)[:last_to].should eq "old-host3@example.com" + + args10 = system(:Mailer)[:last_args] + # event_title is nil in the payload, so it falls back to event_summary + args10["event_title"].should eq "Fallback Summary Title" end From 740e6f06e2365f8be00136c435348f4f7bc19b33 Mon Sep 17 00:00:00 2001 From: Mia Bennett Date: Thu, 30 Apr 2026 12:12:22 +0930 Subject: [PATCH 02/11] feat(visitor_mailor): [PPT-2375] notify on event changes --- drivers/place/staff_api.cr | 7 + drivers/place/visitor_mailer.cr | 94 ++++++++++++++ drivers/place/visitor_mailer_spec.cr | 185 +++++++++++++++++++++++++++ drivers/place/visitor_models.cr | 23 ++++ 4 files changed, 309 insertions(+) diff --git a/drivers/place/staff_api.cr b/drivers/place/staff_api.cr index d080b8f497c..5f2cf29f19d 100644 --- a/drivers/place/staff_api.cr +++ b/drivers/place/staff_api.cr @@ -993,6 +993,13 @@ class Place::StaffAPI < PlaceOS::Driver JSON.parse(response.body) end + def event_guests(event_id : String, system_id : String) + logger.debug { "getting guests for event #{event_id} in system #{system_id}" } + response = get("/api/staff/v1/events/#{event_id}/guests?system_id=#{system_id}", headers: authentication) + raise "issue getting guests for event #{event_id}: #{response.status_code}" unless response.success? + JSON.parse(response.body) + end + # lists asset IDs based on the parameters provided # # booking_type is required unless event_id or ical_uid is present diff --git a/drivers/place/visitor_mailer.cr b/drivers/place/visitor_mailer.cr index 409ce05e719..eb513c49282 100644 --- a/drivers/place/visitor_mailer.cr +++ b/drivers/place/visitor_mailer.cr @@ -83,6 +83,9 @@ class Place::VisitorMailer < PlaceOS::Driver # Booking details have changed — notify all visitors if relevant fields changed monitor("staff/booking/changed") { |_subscription, payload| booking_changed_event(payload.gsub(/[^[:print:]]/, "")) } + # Calendar event details have changed — notify visitors / previous host + monitor("staff/event/changed") { |_subscription, payload| event_changed_event(payload.gsub(/[^[:print:]]/, "")) } + on_update end @@ -652,6 +655,97 @@ class Place::VisitorMailer < PlaceOS::Driver } end + protected def event_changed_event(payload) + logger.debug { "received event changed payload: #{payload}" } + details = EventChanged.from_json payload + + # only respond to updates, not creates or cancellations + return unless details.action == "update" + + # ensure the event is for this building + if zones = details.zones + check = [building_zone.id] + @parent_zone_ids + + if (check & zones).empty? + logger.debug { "ignoring event_changed as does not match any zones: #{check}" } + return + end + end + + # --- Host change notification (ticket #1) --- + if prev_host = details.previous_host_email + if prev_host.downcase != details.host.downcase + send_original_host_email( + @notify_original_host_template, + prev_host, + details.host, + details.title, + details.event_start, + ) + end + end + + # --- Date / time / location change notification (tickets #3, #4, #5) --- + fields_changed = false + + # Date or time changed + if prev_start = details.previous_event_start + fields_changed = true if prev_start != details.event_start + end + + # Location changed (system_id represents the room) + if prev_sys = details.previous_system_id + fields_changed = true if prev_sys != details.system_id + end + + return unless fields_changed + + guests = staff_api.event_guests(details.event_id, details.system_id).get.as_a + guests.each do |guest| + visitor_email = guest["email"].as_s + visitor_name = guest["name"].as_s? + + # don't email staff members + next if !@host_domain_filter.empty? && visitor_email.split('@', 2)[1].downcase.in?(@host_domain_filter) + + local_start_time = Time.unix(details.event_start).in(@time_zone) + + previous_date = details.previous_event_start.try { |timestamp| Time.unix(timestamp).in(@time_zone).to_s(@date_format) } + previous_time = details.previous_event_start.try { |timestamp| Time.unix(timestamp).in(@time_zone).to_s(@time_format) } + + mailer.send_template( + visitor_email, + {"visitor_invited", @booking_changed_template}, + { + visitor_email: visitor_email, + visitor_name: visitor_name, + host_name: get_host_name(details.host), + host_email: details.host, + room_name: @booking_space_name, + building_name: building_zone.display_name.presence || building_zone.name, + event_title: details.title, + event_start: local_start_time.to_s(@time_format), + event_date: local_start_time.to_s(@date_format), + event_time: local_start_time.to_s(@time_format), + previous_event_date: previous_date, + previous_event_time: previous_time, + previous_room_name: @booking_space_name, + previous_building_name: building_zone.display_name.presence || building_zone.name, + } + ) + rescue error + logger.warn(exception: error) { "failed to send event_changed email to #{visitor_email}" } + end + rescue error + logger.error { error.inspect_with_backtrace } + self[:error_count] = @error_count += 1 + self[:last_error] = { + error: error.message, + time: Time.local.to_s, + user: payload, + } + end + @[Security(Level::Support)] def send_visitor_qr_email( template : String, diff --git a/drivers/place/visitor_mailer_spec.cr b/drivers/place/visitor_mailer_spec.cr index bcd0103d276..661266882ab 100644 --- a/drivers/place/visitor_mailer_spec.cr +++ b/drivers/place/visitor_mailer_spec.cr @@ -130,6 +130,17 @@ class StaffAPIMock < DriverSpecs::MockDriver }, ] end + + def event_guests(event_id : String, system_id : String) + [ + { + email: "visitor@external.com", + name: "Visitor One", + checked_in: false, + visit_expected: true, + }, + ] + end end DriverSpecs.mock_driver "Place::VisitorMailer" do @@ -460,4 +471,178 @@ DriverSpecs.mock_driver "Place::VisitorMailer" do args10 = system(:Mailer)[:last_args] # event_title is nil in the payload, so it falls back to event_summary args10["event_title"].should eq "Fallback Summary Title" + + # ================================================================== + # event_changed_event tests (staff/event/changed) + # ================================================================== + + # ------------------------------------------------------------------ + # Test 11: event_changed with time change — sends booking_changed + # emails to all visitors on the event + # ------------------------------------------------------------------ + + event_changed_time = { + action: "update", + system_id: "sys-room1", + event_id: "evt-100", + event_ical_uid: "ical-100", + host: "host@example.com", + resource: "room1@example.com", + title: "Quarterly Review", + event_start: now + 7200, + event_end: now + 10800, + zones: ["zone-building", "zone-room"], + previous_event_start: now + 3600, + previous_event_end: now + 7200, + }.to_json + + publish("staff/event/changed", event_changed_time) + sleep 1.5 + + # Visitor should receive a booking_changed email + system(:Mailer)[:send_count].should eq 7 + system(:Mailer)[:last_to].should eq "visitor@external.com" + system(:Mailer)[:last_template].should eq ["visitor_invited", "booking_changed"] + + args11 = system(:Mailer)[:last_args] + args11["host_name"].should eq "Host User" + args11["host_email"].should eq "host@example.com" + args11["event_title"].should eq "Quarterly Review" + args11["building_name"].should eq "Main Building" + # previous dates should be present + args11["previous_event_date"].should_not be_nil + args11["previous_event_time"].should_not be_nil + + # ------------------------------------------------------------------ + # Test 12: event_changed with location change (system_id differs) — + # sends booking_changed emails to visitors + # ------------------------------------------------------------------ + + event_changed_location = { + action: "update", + system_id: "sys-room1", + event_id: "evt-101", + event_ical_uid: "ical-101", + host: "host@example.com", + resource: "room1@example.com", + title: "Sprint Planning", + event_start: now + 3600, + event_end: now + 7200, + zones: ["zone-building", "zone-room"], + previous_system_id: "sys-old-room", + }.to_json + + publish("staff/event/changed", event_changed_location) + sleep 1.5 + + system(:Mailer)[:send_count].should eq 8 + system(:Mailer)[:last_to].should eq "visitor@external.com" + system(:Mailer)[:last_template].should eq ["visitor_invited", "booking_changed"] + + args12 = system(:Mailer)[:last_args] + args12["event_title"].should eq "Sprint Planning" + + # ------------------------------------------------------------------ + # Test 13: event_changed with host change — sends host-change + # notification to the previous host + # ------------------------------------------------------------------ + + event_changed_host = { + action: "update", + system_id: "sys-room1", + event_id: "evt-102", + event_ical_uid: "ical-102", + host: "new-organiser@example.com", + resource: "room1@example.com", + title: "Design Review", + event_start: now + 3600, + event_end: now + 7200, + zones: ["zone-building"], + previous_host_email: "old-organiser@example.com", + }.to_json + + publish("staff/event/changed", event_changed_host) + sleep 1.5 + + system(:Mailer)[:send_count].should eq 9 + system(:Mailer)[:last_to].should eq "old-organiser@example.com" + system(:Mailer)[:last_template].should eq ["visitor_invited", "notify_original_host"] + + args13 = system(:Mailer)[:last_args] + args13["previous_host_email"].should eq "old-organiser@example.com" + args13["new_host_email"].should eq "new-organiser@example.com" + args13["event_title"].should eq "Design Review" + + # ------------------------------------------------------------------ + # Test 14: event_changed — action "create" is ignored (no previous + # values to compare) + # ------------------------------------------------------------------ + + event_created_payload = { + action: "create", + system_id: "sys-room1", + event_id: "evt-103", + event_ical_uid: "ical-103", + host: "host@example.com", + resource: "room1@example.com", + title: "New Meeting", + event_start: now + 3600, + event_end: now + 7200, + zones: ["zone-building"], + }.to_json + + publish("staff/event/changed", event_created_payload) + sleep 0.5 + + # No email — create events have no previous state to diff against + system(:Mailer)[:send_count].should eq 9 + + # ------------------------------------------------------------------ + # Test 15: event_changed — wrong zone is ignored + # ------------------------------------------------------------------ + + event_changed_wrong_zone = { + action: "update", + system_id: "sys-room1", + event_id: "evt-104", + event_ical_uid: "ical-104", + host: "host@example.com", + resource: "room1@example.com", + title: "Offsite Meeting", + event_start: now + 7200, + event_end: now + 10800, + zones: ["zone-other-building"], + previous_event_start: now + 3600, + previous_event_end: now + 7200, + }.to_json + + publish("staff/event/changed", event_changed_wrong_zone) + sleep 0.5 + + system(:Mailer)[:send_count].should eq 9 + + # ------------------------------------------------------------------ + # Test 16: event_changed — no actual changes (previous == current) + # does not send email + # ------------------------------------------------------------------ + + event_changed_no_diff = { + action: "update", + system_id: "sys-room1", + event_id: "evt-105", + event_ical_uid: "ical-105", + host: "host@example.com", + resource: "room1@example.com", + title: "Unchanged Meeting", + event_start: now + 3600, + event_end: now + 7200, + zones: ["zone-building"], + previous_event_start: now + 3600, + previous_event_end: now + 7200, + }.to_json + + publish("staff/event/changed", event_changed_no_diff) + sleep 0.5 + + system(:Mailer)[:send_count].should eq 9 end diff --git a/drivers/place/visitor_models.cr b/drivers/place/visitor_models.cr index 6681374dc75..70fcdb1e595 100644 --- a/drivers/place/visitor_models.cr +++ b/drivers/place/visitor_models.cr @@ -126,4 +126,27 @@ module Place property previous_booking_end : Int64? property previous_zones : Array(String)? end + + # Standalone model for the staff/event/changed channel. + # Used to notify visitors when calendar event details they care about have changed. + class EventChanged + include JSON::Serializable + + property action : String + property system_id : String + property event_id : String + property event_ical_uid : String? + property host : String + property resource : String? + property title : String? + property event_start : Int64 + property event_end : Int64 + property zones : Array(String)? + + # Previous values — only present when action is "update" and the meta was persisted. + property previous_event_start : Int64? + property previous_event_end : Int64? + property previous_system_id : String? + property previous_host_email : String? + end end From 3c18220c75772f5226a4293c44dbc98a12d3ad8e Mon Sep 17 00:00:00 2001 From: Mia Bennett Date: Thu, 30 Apr 2026 12:42:35 +0930 Subject: [PATCH 03/11] fix(visitor_mailor): [PPT-2375] event notifications --- drivers/place/visitor_mailer.cr | 12 +++-- drivers/place/visitor_mailer_spec.cr | 80 ++++++++++++++++++++++++---- drivers/place/visitor_models.cr | 2 +- 3 files changed, 79 insertions(+), 15 deletions(-) diff --git a/drivers/place/visitor_mailer.cr b/drivers/place/visitor_mailer.cr index eb513c49282..d6e428b662c 100644 --- a/drivers/place/visitor_mailer.cr +++ b/drivers/place/visitor_mailer.cr @@ -153,8 +153,8 @@ class Place::VisitorMailer < PlaceOS::Driver @event_template = setting?(String, :event_template) || "event" @booking_template = setting?(String, :booking_template) || "booking" @notify_checkin_template = setting?(String, :notify_checkin_template) || "notify_checkin" - @notify_induction_accepted_template = setting?(String, :induction_accepted) || "induction_accepted" - @notify_induction_declined_template = setting?(String, :induction_declined) || "induction_declined" + @notify_induction_accepted_template = setting?(String, :notify_induction_accepted_template) || "induction_accepted" + @notify_induction_declined_template = setting?(String, :notify_induction_declined_template) || "induction_declined" @notify_original_host_template = setting?(String, :notify_original_host_template) || "notify_original_host" @booking_changed_template = setting?(String, :booking_changed_template) || "booking_changed" @group_event_template = setting?(String, :group_event_template) || "group_event" @@ -556,8 +556,12 @@ class Place::VisitorMailer < PlaceOS::Driver logger.debug { "received booking changed payload: #{payload}" } details = BookingChanged.from_json payload - # only respond to full changes, not metadata-only updates - return unless details.action == "changed" + # Ignore create / cancel signals — only updates can carry visitor-relevant changes. + # We intentionally do NOT filter on action == "changed" because the bookings + # controller may label the signal "metadata_changed" even when visitor-relevant + # fields (time, location) have changed. The field-diff check below is the + # authoritative gate. + return if details.action.in?("create", "cancelled") # ensure the event is for this building if zones = details.zones diff --git a/drivers/place/visitor_mailer_spec.cr b/drivers/place/visitor_mailer_spec.cr index 661266882ab..cce2738dbfa 100644 --- a/drivers/place/visitor_mailer_spec.cr +++ b/drivers/place/visitor_mailer_spec.cr @@ -358,6 +358,39 @@ DriverSpecs.mock_driver "Place::VisitorMailer" do system(:Mailer)[:send_count].should eq 3 + # ------------------------------------------------------------------ + # Test 6b: booking_changed with "metadata_changed" action but time + # window actually shrunk (e.g. 9am–5pm → 10am–4pm). + # The driver should still send the notification because the + # previous values differ from the current values. + # ------------------------------------------------------------------ + + changed_payload_shrunk = { + action: "metadata_changed", + id: 106_i64, + booking_type: "desk", + booking_start: now + 7200, + booking_end: now + 10800, + timezone: "GMT", + resource_id: "desk-1", + resource_ids: ["desk-1"], + user_email: "host@example.com", + title: "Shrunk Window Meeting", + zones: ["zone-building", "zone-room"], + previous_booking_start: now + 3600, + previous_booking_end: now + 14400, + }.to_json + + publish("staff/booking/changed", changed_payload_shrunk) + sleep 1.5 + + # Even though the action is "metadata_changed", the time genuinely + # changed so visitors must be notified. + system(:Mailer)[:send_count].should eq 4 + system(:Mailer)[:last_to].should eq "visitor@external.com" + system(:Mailer)[:last_template].should eq ["visitor_invited", "booking_changed"] + system(:Mailer)[:last_args]["event_title"].should eq "Shrunk Window Meeting" + # ================================================================== # booking_host_changed_event tests # ================================================================== @@ -383,7 +416,7 @@ DriverSpecs.mock_driver "Place::VisitorMailer" do sleep 1.5 # Email should be sent to the previous host - system(:Mailer)[:send_count].should eq 4 + system(:Mailer)[:send_count].should eq 5 system(:Mailer)[:last_to].should eq "old-host@example.com" system(:Mailer)[:last_template].should eq ["visitor_invited", "notify_original_host"] @@ -419,7 +452,7 @@ DriverSpecs.mock_driver "Place::VisitorMailer" do sleep 0.5 # Count should not have increased — event was for a different building - system(:Mailer)[:send_count].should eq 4 + system(:Mailer)[:send_count].should eq 5 # ------------------------------------------------------------------ # Test 9: booking_host_changed — nil zones skips zone filter @@ -441,7 +474,7 @@ DriverSpecs.mock_driver "Place::VisitorMailer" do sleep 1.5 # When zones are nil, zone filtering is skipped — email should be sent - system(:Mailer)[:send_count].should eq 5 + system(:Mailer)[:send_count].should eq 6 system(:Mailer)[:last_to].should eq "old-host2@example.com" system(:Mailer)[:last_template].should eq ["visitor_invited", "notify_original_host"] @@ -465,13 +498,40 @@ DriverSpecs.mock_driver "Place::VisitorMailer" do publish("staff/booking/host_changed", host_changed_no_title) sleep 1.5 - system(:Mailer)[:send_count].should eq 6 + system(:Mailer)[:send_count].should eq 7 system(:Mailer)[:last_to].should eq "old-host3@example.com" args10 = system(:Mailer)[:last_args] # event_title is nil in the payload, so it falls back to event_summary args10["event_title"].should eq "Fallback Summary Title" + # ------------------------------------------------------------------ + # Test 10b: booking_host_changed — both event_title and event_summary + # are null (booking has no title or description). Must not + # crash during deserialisation. + # ------------------------------------------------------------------ + + host_changed_nil_summary = { + action: "host_changed", + booking_id: 204_i64, + resource_id: "desk-1", + resource_ids: ["desk-1"], + event_starting: now + 3600, + previous_host_email: "old-host4@example.com", + new_host_email: "new-host4@example.com", + zones: ["zone-building"], + }.to_json + + publish("staff/booking/host_changed", host_changed_nil_summary) + sleep 1.5 + + # Email should still be sent — event_title falls back to nil gracefully + system(:Mailer)[:send_count].should eq 8 + system(:Mailer)[:last_to].should eq "old-host4@example.com" + + args10b = system(:Mailer)[:last_args] + args10b["event_title"].raw.should be_nil + # ================================================================== # event_changed_event tests (staff/event/changed) # ================================================================== @@ -500,7 +560,7 @@ DriverSpecs.mock_driver "Place::VisitorMailer" do sleep 1.5 # Visitor should receive a booking_changed email - system(:Mailer)[:send_count].should eq 7 + system(:Mailer)[:send_count].should eq 9 system(:Mailer)[:last_to].should eq "visitor@external.com" system(:Mailer)[:last_template].should eq ["visitor_invited", "booking_changed"] @@ -535,7 +595,7 @@ DriverSpecs.mock_driver "Place::VisitorMailer" do publish("staff/event/changed", event_changed_location) sleep 1.5 - system(:Mailer)[:send_count].should eq 8 + system(:Mailer)[:send_count].should eq 10 system(:Mailer)[:last_to].should eq "visitor@external.com" system(:Mailer)[:last_template].should eq ["visitor_invited", "booking_changed"] @@ -564,7 +624,7 @@ DriverSpecs.mock_driver "Place::VisitorMailer" do publish("staff/event/changed", event_changed_host) sleep 1.5 - system(:Mailer)[:send_count].should eq 9 + system(:Mailer)[:send_count].should eq 11 system(:Mailer)[:last_to].should eq "old-organiser@example.com" system(:Mailer)[:last_template].should eq ["visitor_invited", "notify_original_host"] @@ -595,7 +655,7 @@ DriverSpecs.mock_driver "Place::VisitorMailer" do sleep 0.5 # No email — create events have no previous state to diff against - system(:Mailer)[:send_count].should eq 9 + system(:Mailer)[:send_count].should eq 11 # ------------------------------------------------------------------ # Test 15: event_changed — wrong zone is ignored @@ -619,7 +679,7 @@ DriverSpecs.mock_driver "Place::VisitorMailer" do publish("staff/event/changed", event_changed_wrong_zone) sleep 0.5 - system(:Mailer)[:send_count].should eq 9 + system(:Mailer)[:send_count].should eq 11 # ------------------------------------------------------------------ # Test 16: event_changed — no actual changes (previous == current) @@ -644,5 +704,5 @@ DriverSpecs.mock_driver "Place::VisitorMailer" do publish("staff/event/changed", event_changed_no_diff) sleep 0.5 - system(:Mailer)[:send_count].should eq 9 + system(:Mailer)[:send_count].should eq 11 end diff --git a/drivers/place/visitor_models.cr b/drivers/place/visitor_models.cr index 70fcdb1e595..fb9cff831c0 100644 --- a/drivers/place/visitor_models.cr +++ b/drivers/place/visitor_models.cr @@ -92,7 +92,7 @@ module Place property resource_id : String property resource_ids : Array(String) property event_title : String? - property event_summary : String + property event_summary : String? property event_starting : Int64 property previous_host_email : String property new_host_email : String From 51d9568a6b83f99bc2aba477b9979d00a2d1f2c5 Mon Sep 17 00:00:00 2001 From: Mia Bennett Date: Thu, 30 Apr 2026 13:54:03 +0930 Subject: [PATCH 04/11] refactor(bookings): [PPT-2375] refine implementation --- drivers/place/visitor_mailer.cr | 6 +++ drivers/place/visitor_mailer_spec.cr | 81 ++++++++++++++++++++++++---- 2 files changed, 76 insertions(+), 11 deletions(-) diff --git a/drivers/place/visitor_mailer.cr b/drivers/place/visitor_mailer.cr index d6e428b662c..023282c9bf6 100644 --- a/drivers/place/visitor_mailer.cr +++ b/drivers/place/visitor_mailer.cr @@ -581,6 +581,9 @@ class Place::VisitorMailer < PlaceOS::Driver if prev_start = details.previous_booking_start fields_changed = true if prev_start != details.booking_start end + if prev_end = details.previous_booking_end + fields_changed = true if prev_end != details.booking_end + end # Location changed: zones identify the building/room the visitor should attend if prev_zones = details.previous_zones @@ -696,6 +699,9 @@ class Place::VisitorMailer < PlaceOS::Driver if prev_start = details.previous_event_start fields_changed = true if prev_start != details.event_start end + if prev_end = details.previous_event_end + fields_changed = true if prev_end != details.event_end + end # Location changed (system_id represents the room) if prev_sys = details.previous_system_id diff --git a/drivers/place/visitor_mailer_spec.cr b/drivers/place/visitor_mailer_spec.cr index cce2738dbfa..146f4914836 100644 --- a/drivers/place/visitor_mailer_spec.cr +++ b/drivers/place/visitor_mailer_spec.cr @@ -391,6 +391,36 @@ DriverSpecs.mock_driver "Place::VisitorMailer" do system(:Mailer)[:last_template].should eq ["visitor_invited", "booking_changed"] system(:Mailer)[:last_args]["event_title"].should eq "Shrunk Window Meeting" + # ------------------------------------------------------------------ + # Test 6c: booking_changed with end-time-only change. + # Start time and zones are the same, only the end time moved + # earlier (e.g. 5pm → 3pm). Visitors should still be notified. + # ------------------------------------------------------------------ + + changed_payload_end_only = { + action: "metadata_changed", + id: 107_i64, + booking_type: "desk", + booking_start: now + 3600, + booking_end: now + 10800, + timezone: "GMT", + resource_id: "desk-1", + resource_ids: ["desk-1"], + user_email: "host@example.com", + title: "End Time Only Change", + zones: ["zone-building", "zone-room"], + previous_booking_start: now + 3600, + previous_booking_end: now + 14400, + }.to_json + + publish("staff/booking/changed", changed_payload_end_only) + sleep 1.5 + + system(:Mailer)[:send_count].should eq 5 + system(:Mailer)[:last_to].should eq "visitor@external.com" + system(:Mailer)[:last_template].should eq ["visitor_invited", "booking_changed"] + system(:Mailer)[:last_args]["event_title"].should eq "End Time Only Change" + # ================================================================== # booking_host_changed_event tests # ================================================================== @@ -416,7 +446,7 @@ DriverSpecs.mock_driver "Place::VisitorMailer" do sleep 1.5 # Email should be sent to the previous host - system(:Mailer)[:send_count].should eq 5 + system(:Mailer)[:send_count].should eq 6 system(:Mailer)[:last_to].should eq "old-host@example.com" system(:Mailer)[:last_template].should eq ["visitor_invited", "notify_original_host"] @@ -452,7 +482,7 @@ DriverSpecs.mock_driver "Place::VisitorMailer" do sleep 0.5 # Count should not have increased — event was for a different building - system(:Mailer)[:send_count].should eq 5 + system(:Mailer)[:send_count].should eq 6 # ------------------------------------------------------------------ # Test 9: booking_host_changed — nil zones skips zone filter @@ -474,7 +504,7 @@ DriverSpecs.mock_driver "Place::VisitorMailer" do sleep 1.5 # When zones are nil, zone filtering is skipped — email should be sent - system(:Mailer)[:send_count].should eq 6 + system(:Mailer)[:send_count].should eq 7 system(:Mailer)[:last_to].should eq "old-host2@example.com" system(:Mailer)[:last_template].should eq ["visitor_invited", "notify_original_host"] @@ -498,7 +528,7 @@ DriverSpecs.mock_driver "Place::VisitorMailer" do publish("staff/booking/host_changed", host_changed_no_title) sleep 1.5 - system(:Mailer)[:send_count].should eq 7 + system(:Mailer)[:send_count].should eq 8 system(:Mailer)[:last_to].should eq "old-host3@example.com" args10 = system(:Mailer)[:last_args] @@ -526,7 +556,7 @@ DriverSpecs.mock_driver "Place::VisitorMailer" do sleep 1.5 # Email should still be sent — event_title falls back to nil gracefully - system(:Mailer)[:send_count].should eq 8 + system(:Mailer)[:send_count].should eq 9 system(:Mailer)[:last_to].should eq "old-host4@example.com" args10b = system(:Mailer)[:last_args] @@ -560,7 +590,7 @@ DriverSpecs.mock_driver "Place::VisitorMailer" do sleep 1.5 # Visitor should receive a booking_changed email - system(:Mailer)[:send_count].should eq 9 + system(:Mailer)[:send_count].should eq 10 system(:Mailer)[:last_to].should eq "visitor@external.com" system(:Mailer)[:last_template].should eq ["visitor_invited", "booking_changed"] @@ -595,7 +625,7 @@ DriverSpecs.mock_driver "Place::VisitorMailer" do publish("staff/event/changed", event_changed_location) sleep 1.5 - system(:Mailer)[:send_count].should eq 10 + system(:Mailer)[:send_count].should eq 11 system(:Mailer)[:last_to].should eq "visitor@external.com" system(:Mailer)[:last_template].should eq ["visitor_invited", "booking_changed"] @@ -624,7 +654,7 @@ DriverSpecs.mock_driver "Place::VisitorMailer" do publish("staff/event/changed", event_changed_host) sleep 1.5 - system(:Mailer)[:send_count].should eq 11 + system(:Mailer)[:send_count].should eq 12 system(:Mailer)[:last_to].should eq "old-organiser@example.com" system(:Mailer)[:last_template].should eq ["visitor_invited", "notify_original_host"] @@ -655,7 +685,7 @@ DriverSpecs.mock_driver "Place::VisitorMailer" do sleep 0.5 # No email — create events have no previous state to diff against - system(:Mailer)[:send_count].should eq 11 + system(:Mailer)[:send_count].should eq 12 # ------------------------------------------------------------------ # Test 15: event_changed — wrong zone is ignored @@ -679,7 +709,7 @@ DriverSpecs.mock_driver "Place::VisitorMailer" do publish("staff/event/changed", event_changed_wrong_zone) sleep 0.5 - system(:Mailer)[:send_count].should eq 11 + system(:Mailer)[:send_count].should eq 12 # ------------------------------------------------------------------ # Test 16: event_changed — no actual changes (previous == current) @@ -704,5 +734,34 @@ DriverSpecs.mock_driver "Place::VisitorMailer" do publish("staff/event/changed", event_changed_no_diff) sleep 0.5 - system(:Mailer)[:send_count].should eq 11 + system(:Mailer)[:send_count].should eq 12 + + # ------------------------------------------------------------------ + # Test 17: event_changed with end-time-only change. + # Start time and system_id are the same, only the end time + # moved earlier. Visitors should still be notified. + # ------------------------------------------------------------------ + + event_changed_end_only = { + action: "update", + system_id: "sys-room1", + event_id: "evt-106", + event_ical_uid: "ical-106", + host: "host@example.com", + resource: "room1@example.com", + title: "End Time Only Event", + event_start: now + 3600, + event_end: now + 7200, + zones: ["zone-building", "zone-room"], + previous_event_start: now + 3600, + previous_event_end: now + 10800, + }.to_json + + publish("staff/event/changed", event_changed_end_only) + sleep 1.5 + + system(:Mailer)[:send_count].should eq 13 + system(:Mailer)[:last_to].should eq "visitor@external.com" + system(:Mailer)[:last_template].should eq ["visitor_invited", "booking_changed"] + system(:Mailer)[:last_args]["event_title"].should eq "End Time Only Event" end From 438a985847ec6d7a4aede8b5f792abbd48205346 Mon Sep 17 00:00:00 2001 From: Mia Bennett Date: Thu, 30 Apr 2026 16:51:13 +0930 Subject: [PATCH 05/11] refactor(visitor_mailer): [PPT-2375] refine implementation --- drivers/place/visitor_mailer.cr | 143 ++++++++++++++++----------- drivers/place/visitor_mailer_spec.cr | 110 +++++++++++++++++++++ 2 files changed, 195 insertions(+), 58 deletions(-) diff --git a/drivers/place/visitor_mailer.cr b/drivers/place/visitor_mailer.cr index 023282c9bf6..2a8997db864 100644 --- a/drivers/place/visitor_mailer.cr +++ b/drivers/place/visitor_mailer.cr @@ -556,12 +556,10 @@ class Place::VisitorMailer < PlaceOS::Driver logger.debug { "received booking changed payload: #{payload}" } details = BookingChanged.from_json payload - # Ignore create / cancel signals — only updates can carry visitor-relevant changes. - # We intentionally do NOT filter on action == "changed" because the bookings - # controller may label the signal "metadata_changed" even when visitor-relevant - # fields (time, location) have changed. The field-diff check below is the - # authoritative gate. - return if details.action.in?("create", "cancelled") + # Only process actions that can carry visitor-relevant changes. + # Using an allowlist ensures new action types (e.g. "approved", "rejected", + # "checked_in") are ignored by default and don't trigger spurious emails. + return unless details.action.in?("changed", "metadata_changed") # ensure the event is for this building if zones = details.zones @@ -617,41 +615,15 @@ class Place::VisitorMailer < PlaceOS::Driver end guests = staff_api.booking_guests(details.id).get.as_a - guests.each do |guest| - visitor_email = guest["email"].as_s - visitor_name = guest["name"].as_s? - - # don't email staff members - next if !@host_domain_filter.empty? && visitor_email.split('@', 2)[1].downcase.in?(@host_domain_filter) - - local_start_time = Time.unix(details.booking_start).in(@time_zone) - - previous_date = details.previous_booking_start.try { |timestamp| Time.unix(timestamp).in(@time_zone).to_s(@date_format) } - previous_time = details.previous_booking_start.try { |timestamp| Time.unix(timestamp).in(@time_zone).to_s(@time_format) } - - mailer.send_template( - visitor_email, - {"visitor_invited", @booking_changed_template}, - { - visitor_email: visitor_email, - visitor_name: visitor_name, - host_name: get_host_name(details.user_email), - host_email: details.user_email, - room_name: @booking_space_name, - building_name: building_zone.display_name.presence || building_zone.name, - event_title: details.title, - event_start: local_start_time.to_s(@time_format), - event_date: local_start_time.to_s(@date_format), - event_time: local_start_time.to_s(@time_format), - previous_event_date: previous_date, - previous_event_time: previous_time, - previous_room_name: previous_room_name, - previous_building_name: previous_building_name, - } - ) - rescue error - logger.warn(exception: error) { "failed to send booking_changed email to #{visitor_email}" } - end + send_booking_changed_emails( + guests, + details.user_email, + details.booking_start, + details.title, + details.previous_booking_start, + previous_building_name, + previous_room_name, + ) rescue error logger.error { error.inspect_with_backtrace } self[:error_count] = @error_count += 1 @@ -710,7 +682,69 @@ class Place::VisitorMailer < PlaceOS::Driver return unless fields_changed + # Resolve previous location names from previous_system_id when room changed. + # Default previous_room_name to "unknown" when we know there was a different + # previous system — if the lookup succeeds it will be overwritten with the + # real name; if it fails (rescue) the "unknown" default is preserved and the + # recipient can see that the original location could not be determined. + previous_building_name = building_zone.display_name.presence || building_zone.name + previous_room_name = @booking_space_name + + if (prev_sys_id = details.previous_system_id) && prev_sys_id != details.system_id + previous_room_name = "unknown" + begin + prev_sys = get_room_details(prev_sys_id) + previous_room_name = prev_sys.display_name.presence || prev_sys.name + if prev_zones = prev_sys.zones + prev_zones.each do |zone_id| + begin + zone = fetch_zone(zone_id) + if zone.tags.includes?(@invite_zone_tag) + previous_building_name = zone.display_name.presence || zone.name + break + end + rescue error + logger.warn(exception: error) { "error looking up previous zone #{zone_id}" } + end + end + end + rescue error + logger.warn(exception: error) { "error looking up previous system #{prev_sys_id}" } + end + end + guests = staff_api.event_guests(details.event_id, details.system_id).get.as_a + send_booking_changed_emails( + guests, + details.host, + details.event_start, + details.title, + details.previous_event_start, + previous_building_name, + previous_room_name, + ) + rescue error + logger.error { error.inspect_with_backtrace } + self[:error_count] = @error_count += 1 + self[:last_error] = { + error: error.message, + time: Time.local.to_s, + user: payload, + } + end + + # Sends booking-changed notification emails to each visitor in the guest list. + # Used by both `booking_changed_event` and `event_changed_event` to avoid + # duplicating the per-guest iteration and template-argument construction. + private def send_booking_changed_emails( + guests : Array(JSON::Any), + host_email : String, + event_start : Int64, + event_title : String?, + previous_start : Int64?, + previous_building_name : String, + previous_room_name : String, + ) guests.each do |guest| visitor_email = guest["email"].as_s visitor_name = guest["name"].as_s? @@ -718,10 +752,10 @@ class Place::VisitorMailer < PlaceOS::Driver # don't email staff members next if !@host_domain_filter.empty? && visitor_email.split('@', 2)[1].downcase.in?(@host_domain_filter) - local_start_time = Time.unix(details.event_start).in(@time_zone) + local_start_time = Time.unix(event_start).in(@time_zone) - previous_date = details.previous_event_start.try { |timestamp| Time.unix(timestamp).in(@time_zone).to_s(@date_format) } - previous_time = details.previous_event_start.try { |timestamp| Time.unix(timestamp).in(@time_zone).to_s(@time_format) } + previous_date = previous_start.try { |timestamp| Time.unix(timestamp).in(@time_zone).to_s(@date_format) } + previous_time = previous_start.try { |timestamp| Time.unix(timestamp).in(@time_zone).to_s(@time_format) } mailer.send_template( visitor_email, @@ -729,31 +763,23 @@ class Place::VisitorMailer < PlaceOS::Driver { visitor_email: visitor_email, visitor_name: visitor_name, - host_name: get_host_name(details.host), - host_email: details.host, + host_name: get_host_name(host_email), + host_email: host_email, room_name: @booking_space_name, building_name: building_zone.display_name.presence || building_zone.name, - event_title: details.title, + event_title: event_title, event_start: local_start_time.to_s(@time_format), event_date: local_start_time.to_s(@date_format), event_time: local_start_time.to_s(@time_format), previous_event_date: previous_date, previous_event_time: previous_time, - previous_room_name: @booking_space_name, - previous_building_name: building_zone.display_name.presence || building_zone.name, + previous_room_name: previous_room_name, + previous_building_name: previous_building_name, } ) rescue error - logger.warn(exception: error) { "failed to send event_changed email to #{visitor_email}" } + logger.warn(exception: error) { "failed to send booking_changed email to #{visitor_email}" } end - rescue error - logger.error { error.inspect_with_backtrace } - self[:error_count] = @error_count += 1 - self[:last_error] = { - error: error.message, - time: Time.local.to_s, - user: payload, - } end @[Security(Level::Support)] @@ -934,6 +960,7 @@ class Place::VisitorMailer < PlaceOS::Driver property name : String property display_name : String? property map_id : String? + property zones : Array(String)? end protected def get_room_details(system_id : String, retries = 0) diff --git a/drivers/place/visitor_mailer_spec.cr b/drivers/place/visitor_mailer_spec.cr index 146f4914836..257da8c3cf1 100644 --- a/drivers/place/visitor_mailer_spec.cr +++ b/drivers/place/visitor_mailer_spec.cr @@ -141,6 +141,19 @@ class StaffAPIMock < DriverSpecs::MockDriver }, ] end + + def get_system(id : String, complete : Bool = false) + case id + when "sys-room1" + {id: "sys-room1", name: "Room 1", display_name: "Conference Room 1", map_id: nil, zones: ["zone-building", "zone-room"]} + when "sys-old-room" + {id: "sys-old-room", name: "Room 202", display_name: "Old Conference Room 202", map_id: nil, zones: ["zone-old-building", "zone-old-room"]} + when "sys-error" + raise "system not found: #{id}" + else + {id: id, name: "Unknown Room", display_name: nil, map_id: nil, zones: [] of String} + end + end end DriverSpecs.mock_driver "Place::VisitorMailer" do @@ -764,4 +777,101 @@ DriverSpecs.mock_driver "Place::VisitorMailer" do system(:Mailer)[:last_to].should eq "visitor@external.com" system(:Mailer)[:last_template].should eq ["visitor_invited", "booking_changed"] system(:Mailer)[:last_args]["event_title"].should eq "End Time Only Event" + + # ------------------------------------------------------------------ + # Test 18: event_changed with location change — previous_room_name and + # previous_building_name show the PREVIOUS location, not the + # current one. previous_system_id differs from system_id. + # ------------------------------------------------------------------ + + event_changed_prev_location = { + action: "update", + system_id: "sys-room1", + event_id: "evt-108", + event_ical_uid: "ical-108", + host: "host@example.com", + resource: "room1@example.com", + title: "Location Change Meeting", + event_start: now + 3600, + event_end: now + 7200, + zones: ["zone-building", "zone-room"], + previous_system_id: "sys-old-room", + }.to_json + + publish("staff/event/changed", event_changed_prev_location) + sleep 1.5 + + system(:Mailer)[:send_count].should eq 14 + system(:Mailer)[:last_to].should eq "visitor@external.com" + system(:Mailer)[:last_template].should eq ["visitor_invited", "booking_changed"] + + args18 = system(:Mailer)[:last_args] + args18["event_title"].should eq "Location Change Meeting" + # previous location should come from the previous system (sys-old-room), NOT the current + args18["previous_room_name"].should eq "Old Conference Room 202" + args18["previous_building_name"].should eq "Previous Building" + # current location should remain correct + args18["room_name"].should eq "Client Floor" + args18["building_name"].should eq "Main Building" + + # ------------------------------------------------------------------ + # Test 18b: event_changed with an unresolvable previous_system_id. + # get_room_details retries 4× with 1-second delays before + # giving up, so previous_room_name must fall back to "unknown" + # rather than silently showing the current room. + # Note: this test requires a longer sleep to accommodate the retries. + # ------------------------------------------------------------------ + + event_changed_error_system = { + action: "update", + system_id: "sys-room1", + event_id: "evt-109", + event_ical_uid: "ical-109", + host: "host@example.com", + resource: "room1@example.com", + title: "Error System Meeting", + event_start: now + 3600, + event_end: now + 7200, + zones: ["zone-building", "zone-room"], + previous_system_id: "sys-error", + }.to_json + + count_before_18b = system(:Mailer)[:send_count].as_i + publish("staff/event/changed", event_changed_error_system) + sleep 6.0 # allow for 4× 1-second retries inside get_room_details + + system(:Mailer)[:send_count].should eq count_before_18b + 1 + system(:Mailer)[:last_to].should eq "visitor@external.com" + args18b = system(:Mailer)[:last_args] + args18b["event_title"].should eq "Error System Meeting" + args18b["previous_room_name"].should eq "unknown" + + # ------------------------------------------------------------------ + # Test 19: booking_changed with action "approved" that contains + # previous_* field differences must NOT send an email. + # Only "changed" and "metadata_changed" actions are relevant. + # ------------------------------------------------------------------ + + approved_payload_with_diff = { + action: "approved", + id: 108_i64, + booking_type: "desk", + booking_start: now + 7200, + booking_end: now + 10800, + timezone: "GMT", + resource_id: "desk-1", + resource_ids: ["desk-1"], + user_email: "host@example.com", + title: "Approved Booking", + zones: ["zone-building", "zone-room"], + previous_booking_start: now + 3600, + previous_booking_end: now + 7200, + }.to_json + + count_before_approved = system(:Mailer)[:send_count].as_i + publish("staff/booking/changed", approved_payload_with_diff) + sleep 0.5 + + # "approved" is not a visitor-notification action — no email should be sent + system(:Mailer)[:send_count].should eq count_before_approved end From 149095c5fac743e27af228c5c2ebfba3bc974f32 Mon Sep 17 00:00:00 2001 From: Stephen von Takach Date: Thu, 30 Apr 2026 13:55:45 +1000 Subject: [PATCH 06/11] fix(orbility/approvals): fetch matching bookings instead of using the event --- drivers/orbility/approvals.cr | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/drivers/orbility/approvals.cr b/drivers/orbility/approvals.cr index aefcfd023d5..b7ce7cf81f0 100644 --- a/drivers/orbility/approvals.cr +++ b/drivers/orbility/approvals.cr @@ -170,16 +170,19 @@ class Place::Parking::Approvals < PlaceOS::Driver protected def booking_changed(event) return unless event.booking_type == BOOKING_TYPE return unless event.zones.includes?(building_id) - return if event.recurring? # this will be an allocated parking spot - logger.debug { "booking_changed: parking request is in this building\n#{event}" } + booking = Booking.from_json(staff_api.get_booking(event.id).get.to_json) + + return if booking.recurring? # this will be an allocated parking spot + + logger.debug { "booking_changed: parking request is in this building\n#{booking.pretty_inspect}" } case event.action when "create" return if event.approved - @sync_mutex.synchronize { check_approval(event) } + @sync_mutex.synchronize { check_approval(booking) } when "cancelled", "rejected" - @sync_mutex.synchronize { cleanup_parking(event, event.action == "rejected") } + @sync_mutex.synchronize { cleanup_parking(booking, event.action == "rejected") } # when "changed" # we're ignoring change events else From c60e899b983454a4eb44f22bb9e278804f6f6317 Mon Sep 17 00:00:00 2001 From: Stephen von Takach Date: Thu, 30 Apr 2026 15:54:11 +1000 Subject: [PATCH 07/11] fix(orbility/approvals): add check for manual approval --- drivers/orbility/approvals.cr | 3 +++ 1 file changed, 3 insertions(+) diff --git a/drivers/orbility/approvals.cr b/drivers/orbility/approvals.cr index b7ce7cf81f0..c822b607e0a 100644 --- a/drivers/orbility/approvals.cr +++ b/drivers/orbility/approvals.cr @@ -238,6 +238,9 @@ class Place::Parking::Approvals < PlaceOS::Driver end protected def auto_approve?(booking : Booking) : Bool + # was the booking was manually approved (then we want to move to the next step) + return true if booking.approved + # check booked_by and user_emails = [booking.booked_by_email.downcase, booking.user_email.downcase].uniq! From c474aa87203e2d76734f1c75dd8f6387dd894e23 Mon Sep 17 00:00:00 2001 From: Stephen von Takach Date: Fri, 1 May 2026 12:41:49 +1000 Subject: [PATCH 08/11] fix(orbility/approvals): return bookable parking spaces --- drivers/orbility/approvals.cr | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/drivers/orbility/approvals.cr b/drivers/orbility/approvals.cr index c822b607e0a..82fa12c5feb 100644 --- a/drivers/orbility/approvals.cr +++ b/drivers/orbility/approvals.cr @@ -111,10 +111,11 @@ class Place::Parking::Approvals < PlaceOS::Driver # Parking Spaces # =================================== - PARKING_SPACES = "_PARKING_SPACES_" + PARKING_CATEGORY = "_PARKING_" + PARKING_SPACES = "_PARKING_SPACES_" protected getter parking_spaces_asset_type : String do - category = staff_api.asset_categories(hidden: true).get.as_a.find { |cat| cat["name"].as_s == PARKING_SPACES } + category = staff_api.asset_categories(hidden: true).get.as_a.find { |cat| cat["name"].as_s == PARKING_CATEGORY } raise "no parking space asset category (#{PARKING_SPACES})" unless category type = staff_api.asset_types(category_id: category["id"].as_s).get.as_a.find! { |cat| cat["name"].as_s == PARKING_SPACES } type["id"].as_s @@ -129,7 +130,7 @@ class Place::Parking::Approvals < PlaceOS::Driver end # =================================== - # Assigned desks + # Assigned desks (used to check if user works in the building) # TODO:: migrate to asset version # =================================== From 16978446714d85d83bcb321b2d451c84a5bd29f2 Mon Sep 17 00:00:00 2001 From: Mia Bennett Date: Fri, 1 May 2026 12:32:22 +0930 Subject: [PATCH 09/11] fix(harness): improve worktree handling --- harness | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/harness b/harness index 6be5a1b5050..9e08ba8b630 100755 --- a/harness +++ b/harness @@ -22,6 +22,15 @@ restore_git_worktree() { # that points to an absolute path the container cannot see. Detect this and # temporarily replace it with a self-contained repo for the duration of the run. setup_git_for_harness() { + # Recover from a previous run that was killed before the EXIT trap could fire. + # If the backup exists, the current .git is a leftover temp repo — restore the + # real worktree pointer before proceeding. + if [ -f "${PWD}/.git.worktree-bak" ]; then + echo '░░░ Stale worktree backup found — restoring from previous interrupted run...' + rm -rf "${PWD}/.git" + mv "${PWD}/.git.worktree-bak" "${PWD}/.git" + fi + if [ -f "${PWD}/.git" ]; then echo '░░░ Git worktree detected, creating temporary repo for harness...' _WORKTREE_BACKUP="${PWD}/.git.worktree-bak" From 90b826f6e95b142e75340e2d8d9e0ab740639dd5 Mon Sep 17 00:00:00 2001 From: Mia Bennett Date: Tue, 5 May 2026 10:45:09 +0930 Subject: [PATCH 10/11] refactor(visitor_mailer): [PPT-2375] refine implementation --- drivers/place/visitor_mailer.cr | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/drivers/place/visitor_mailer.cr b/drivers/place/visitor_mailer.cr index 2a8997db864..82f713ab295 100644 --- a/drivers/place/visitor_mailer.cr +++ b/drivers/place/visitor_mailer.cr @@ -153,8 +153,8 @@ class Place::VisitorMailer < PlaceOS::Driver @event_template = setting?(String, :event_template) || "event" @booking_template = setting?(String, :booking_template) || "booking" @notify_checkin_template = setting?(String, :notify_checkin_template) || "notify_checkin" - @notify_induction_accepted_template = setting?(String, :notify_induction_accepted_template) || "induction_accepted" - @notify_induction_declined_template = setting?(String, :notify_induction_declined_template) || "induction_declined" + @notify_induction_accepted_template = setting?(String, :induction_accepted) || "induction_accepted" + @notify_induction_declined_template = setting?(String, :induction_declined) || "induction_declined" @notify_original_host_template = setting?(String, :notify_original_host_template) || "notify_original_host" @booking_changed_template = setting?(String, :booking_changed_template) || "booking_changed" @group_event_template = setting?(String, :group_event_template) || "group_event" From 4b94463f2499d491c6d4f26fece50bdc1e5cc549 Mon Sep 17 00:00:00 2001 From: Mia Bennett Date: Tue, 5 May 2026 10:51:17 +0930 Subject: [PATCH 11/11] refactor(visitor_mailer): [PPT-2375] refine implementation --- drivers/place/visitor_mailer.cr | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/drivers/place/visitor_mailer.cr b/drivers/place/visitor_mailer.cr index 82f713ab295..601c7c5a0a9 100644 --- a/drivers/place/visitor_mailer.cr +++ b/drivers/place/visitor_mailer.cr @@ -651,7 +651,7 @@ class Place::VisitorMailer < PlaceOS::Driver end end - # --- Host change notification (ticket #1) --- + # --- Host change notification if prev_host = details.previous_host_email if prev_host.downcase != details.host.downcase send_original_host_email( @@ -664,7 +664,7 @@ class Place::VisitorMailer < PlaceOS::Driver end end - # --- Date / time / location change notification (tickets #3, #4, #5) --- + # --- Date / time / location change notification fields_changed = false # Date or time changed @@ -734,8 +734,6 @@ class Place::VisitorMailer < PlaceOS::Driver end # Sends booking-changed notification emails to each visitor in the guest list. - # Used by both `booking_changed_event` and `event_changed_event` to avoid - # duplicating the per-guest iteration and template-argument construction. private def send_booking_changed_emails( guests : Array(JSON::Any), host_email : String,