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..601c7c5a0a9 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 @@ -553,8 +556,10 @@ 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" + # 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 @@ -574,6 +579,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 @@ -607,6 +615,134 @@ class Place::VisitorMailer < PlaceOS::Driver end guests = staff_api.booking_guests(details.id).get.as_a + 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 + self[:last_error] = { + error: error.message, + time: Time.local.to_s, + user: payload, + } + 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 + 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 + fields_changed = false + + # Date or time changed + 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 + fields_changed = true if prev_sys != details.system_id + end + + 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. + 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? @@ -614,10 +750,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.booking_start).in(@time_zone) + local_start_time = Time.unix(event_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) } + 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, @@ -625,11 +761,11 @@ class Place::VisitorMailer < PlaceOS::Driver { visitor_email: visitor_email, visitor_name: visitor_name, - host_name: get_host_name(details.user_email), - host_email: details.user_email, + 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), @@ -642,14 +778,6 @@ class Place::VisitorMailer < PlaceOS::Driver rescue error 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)] @@ -830,6 +958,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 84f5c3db754..257da8c3cf1 100644 --- a/drivers/place/visitor_mailer_spec.cr +++ b/drivers/place/visitor_mailer_spec.cr @@ -130,6 +130,30 @@ 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 + + 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 @@ -346,4 +370,508 @@ DriverSpecs.mock_driver "Place::VisitorMailer" do sleep 0.5 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" + + # ------------------------------------------------------------------ + # 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 + # ================================================================== + + # ------------------------------------------------------------------ + # 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 6 + 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 6 + + # ------------------------------------------------------------------ + # 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 7 + 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 8 + 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 9 + 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) + # ================================================================== + + # ------------------------------------------------------------------ + # 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 10 + 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 11 + 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 12 + 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 12 + + # ------------------------------------------------------------------ + # 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 12 + + # ------------------------------------------------------------------ + # 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 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" + + # ------------------------------------------------------------------ + # 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 diff --git a/drivers/place/visitor_models.cr b/drivers/place/visitor_models.cr index 6681374dc75..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 @@ -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 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"