diff --git a/drivers/place/visitor_mailer.cr b/drivers/place/visitor_mailer.cr index 8ee9dd5017..6dbb3f8563 100644 --- a/drivers/place/visitor_mailer.cr +++ b/drivers/place/visitor_mailer.cr @@ -34,8 +34,10 @@ class Place::VisitorMailer < PlaceOS::Driver notify_induction_accepted_template: "induction_accepted", notify_induction_declined_template: "induction_declined", notify_original_host_template: "notify_original_host", - # sent to all visitors when booking details change (date, time, location, etc.) + # sent to all visitors when details change (date, time, location, etc.): + # bookings (desk/resource) use booking_changed, calendar events (rooms) use event_changed booking_changed_template: "booking_changed", + event_changed_template: "event_changed", group_event_template: "group_event", disable_qr_code: false, send_network_credentials: false, @@ -140,6 +142,7 @@ class Place::VisitorMailer < PlaceOS::Driver @notify_induction_declined_template : String = "induction_declined" @notify_original_host_template : String = "notify_original_host" @booking_changed_template : String = "booking_changed" + @event_changed_template : String = "event_changed" @group_event_template : String = "group_event" @determine_host_name_using : String = "calendar-driver" @send_network_credentials = false @@ -171,6 +174,7 @@ class Place::VisitorMailer < PlaceOS::Driver @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" + @event_changed_template = setting?(String, :event_changed_template) || "event_changed" @group_event_template = setting?(String, :group_event_template) || "group_event" @disable_qr_code = setting?(Bool, :disable_qr_code) || false @determine_host_name_using = setting?(String, :determine_host_name_using) || "calendar-driver" @@ -464,9 +468,11 @@ class Place::VisitorMailer < PlaceOS::Driver previous_host_email : String, new_host_email : String, event_title : String?, - event_start : Int64, + event_start : Int64?, ) - local_start_time = Time.unix(event_start).in(@time_zone) + # A host can be reassigned via a metadata-only update that carries no event + # timing, so render the date/time only when a start time is available. + local_start_time = event_start.try { |timestamp| Time.unix(timestamp).in(@time_zone) } mailer.send_template( previous_host_email, @@ -478,8 +484,8 @@ class Place::VisitorMailer < PlaceOS::Driver new_host_name: get_host_name(new_host_email), building_name: building_zone.display_name.presence || building_zone.name, event_title: event_title, - event_date: local_start_time.to_s(@date_format), - event_time: local_start_time.to_s(@time_format), + event_date: local_start_time.try(&.to_s(@date_format)), + event_time: local_start_time.try(&.to_s(@time_format)), } ) end @@ -513,6 +519,16 @@ class Place::VisitorMailer < PlaceOS::Driver {name: "kiosk_url", description: "URL for the visitor kiosk"}, ] + # Shared by the booking-changed and event-changed notifications, which carry + # the same data but render through separate templates. + changed_fields = common_fields + [ + {name: "room_name", description: "Name of the room or area being visited"}, + {name: "previous_event_date", description: "The original date before it was changed"}, + {name: "previous_event_time", description: "The original time before it was changed"}, + {name: "previous_room_name", description: "The original room or area name before it was moved"}, + {name: "previous_building_name", description: "The original building name before it was moved"}, + ] + [ TemplateFields.new( trigger: {"visitor_invited", @reminder_template}, @@ -574,14 +590,14 @@ class Place::VisitorMailer < PlaceOS::Driver TemplateFields.new( trigger: {"visitor_invited", @booking_changed_template}, name: "Booking details changed notification", - description: "Notification sent to all visitors on a booking when details change (date, time, location, etc.)", - fields: common_fields + [ - {name: "room_name", description: "Name of the room or area being visited"}, - {name: "previous_event_date", description: "The original date of the booking before it was changed"}, - {name: "previous_event_time", description: "The original time of the booking before it was changed"}, - {name: "previous_room_name", description: "The original room or area name before the booking was moved"}, - {name: "previous_building_name", description: "The original building name before the booking was moved"}, - ] + description: "Notification sent to all visitors on a booking (desk/resource) when details change (date, time, etc.)", + fields: changed_fields + ), + TemplateFields.new( + trigger: {"visitor_invited", @event_changed_template}, + name: "Event details changed notification", + description: "Notification sent to all visitors on a calendar event (room) when details change (date, time, location, etc.)", + fields: changed_fields ), ] end @@ -595,6 +611,18 @@ class Place::VisitorMailer < PlaceOS::Driver # "checked_in") are ignored by default and don't trigger spurious emails. return unless details.action.in?("changed", "metadata_changed") + # Bookings auto-created from a calendar event (extension_data.parent_id set) + # are already covered by the event_changed flow (staff/event/changed), which + # resolves the room from the system. Skip them here so a single edit doesn't + # produce two notifications. Opt out with skip_event_linked_booking_email. + if @skip_event_linked_booking_email + parent_id = details.extension_data.try(&.["parent_id"]?).try(&.as_s?) + if parent_id && !parent_id.empty? + logger.debug { "skipping booking_changed email for booking #{details.id} as it is linked to event #{parent_id}" } + return + end + end + # ensure the event is for this building if zones = details.zones check = [building_zone.id] + @parent_zone_ids @@ -654,6 +682,7 @@ class Place::VisitorMailer < PlaceOS::Driver send_booking_changed_emails( guests, + @booking_changed_template, details.user_email, details.booking_start, details.title, @@ -678,13 +707,6 @@ class Place::VisitorMailer < PlaceOS::Driver # only respond to updates, not creates or cancellations return unless details.action == "update" - # These fields may be missing from some payloads (e.g. cancelled events, - # metadata-only updates) so the model marks them nilable. - host = details.host - event_start = details.event_start - event_end = details.event_end - return unless host && event_start && event_end - # ensure the event is for this building if zones = details.zones check = [building_zone.id] + @parent_zone_ids @@ -695,20 +717,34 @@ class Place::VisitorMailer < PlaceOS::Driver end end + # The (new) host is required for every notification below. + host = details.host + return unless host + + # event_start may be omitted from metadata-only update signals (e.g. an + # update_metadata that only touched ext_data). Look it up so the host + # notification and change emails always render a real date. + event_start = details.event_start || lookup_event_start(details.event_id, details.system_id) + # --- Host change notification - if prev_host = details.previous_host_email - if prev_host.downcase != host.downcase - send_original_host_email( - @notify_original_host_template, - prev_host, - host, - details.title, - event_start, - ) - end + # A host can be reassigned without any change to the event timing; the host + # email still renders (date/time blank only if the lookup also came up empty). + if (prev_host = details.previous_host_email) && prev_host.downcase != host.downcase + send_original_host_email( + @notify_original_host_template, + prev_host, + host, + details.title, + event_start, + ) end # --- Date / time / location change notification + # These genuinely require the event timing to render the new schedule, so + # bail out when it is missing. + event_end = details.event_end + return unless event_start && event_end + fields_changed = false # Date or time changed @@ -726,23 +762,27 @@ class Place::VisitorMailer < PlaceOS::Driver return unless fields_changed - 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 - # Use "unknown" as the fallback so a failed lookup surfaces in the email - # rather than silently showing the current room name. - previous_room_name = "unknown" - previous_room_name, previous_building_name = resolve_system_location_names(prev_sys_id, previous_room_name, previous_building_name) - end - current_building_name = building_zone.display_name.presence || building_zone.name current_room_name = @booking_space_name current_room_name, current_building_name = resolve_system_location_names(details.system_id, current_room_name, current_building_name) + # Default the previous location to the current one; only override it when the + # room actually changed. This keeps date/time-only edits showing the same + # (unchanged) room in both the "previous" and "new" sections instead of the + # static @booking_space_name fallback. + previous_building_name = current_building_name + previous_room_name = current_room_name + + if (prev_sys_id = details.previous_system_id) && prev_sys_id != details.system_id + # Use "unknown" as the room fallback so a failed lookup surfaces in the + # email rather than silently showing the current room name. + previous_room_name, previous_building_name = resolve_system_location_names(prev_sys_id, "unknown", current_building_name) + end + guests = staff_api.event_guests(details.event_id, details.system_id, details.event_ical_uid).get.as_a send_booking_changed_emails( guests, + @event_changed_template, host, event_start, details.title, @@ -767,6 +807,7 @@ class Place::VisitorMailer < PlaceOS::Driver # the booking flow, which has no system_id to resolve from). private def send_booking_changed_emails( guests : Array(JSON::Any), + template : String, host_email : String, event_start : Int64, event_title : String?, @@ -796,7 +837,7 @@ class Place::VisitorMailer < PlaceOS::Driver mailer.send_template( visitor_email, - {"visitor_invited", @booking_changed_template}, + {"visitor_invited", template}, { visitor_email: visitor_email, visitor_name: visitor_name, @@ -1037,6 +1078,16 @@ class Place::VisitorMailer < PlaceOS::Driver get_room_details(system_id, retries + 1) end + # Back-fills an event's start time from the staff API when a + # staff/event/changed signal omits it (e.g. metadata-only updates). Returns + # nil on failure so callers can still send without a date rather than crash. + protected def lookup_event_start(event_id : String, system_id : String) : Int64? + staff_api.get_event(event_id, system_id).get["event_start"]?.try(&.as_i64?) + rescue error + logger.warn(exception: error) { "failed to look up start time for event #{event_id}" } + nil + end + protected def get_host_name(host_email) @determine_host_name_using == "staff-api-driver" ? get_host_name_from_staff_api_driver(host_email) : get_host_name_from_calendar_driver(host_email) end diff --git a/drivers/place/visitor_mailer_spec.cr b/drivers/place/visitor_mailer_spec.cr index 11517aa2bd..7e3ea8e7a9 100644 --- a/drivers/place/visitor_mailer_spec.cr +++ b/drivers/place/visitor_mailer_spec.cr @@ -227,6 +227,19 @@ class StaffAPIMock < DriverSpecs::MockDriver } end end + + # Back-fills event_start when a staff/event/changed signal omits it. + # event_start/event_end are epoch integers, matching the real + # PlaceCalendar::Event serialisation (Time::EpochConverter). + def get_event(event_id : String, system_id : String? = nil, calendar : String? = nil) + case event_id + when "evt-no-start" + # Simulates an event the API can't supply a start time for. + {id: event_id, title: "No Start Event"} + else + {id: event_id, title: "Looked Up Event", event_start: 1_760_000_000_i64, event_end: 1_760_003_600_i64} + end + end end DriverSpecs.mock_driver "Place::VisitorMailer" do @@ -678,7 +691,7 @@ DriverSpecs.mock_driver "Place::VisitorMailer" do # 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"] + system(:Mailer)[:last_template].should eq ["visitor_invited", "event_changed"] args11 = system(:Mailer)[:last_args] args11["host_name"].should eq "Host User" @@ -688,6 +701,14 @@ DriverSpecs.mock_driver "Place::VisitorMailer" do # previous dates should be present args11["previous_event_date"].should_not be_nil args11["previous_event_time"].should_not be_nil + # The location did NOT change, so the "previous" room/building must mirror + # the (unchanged) current room — resolved from system_id — rather than the + # static @booking_space_name fallback. Otherwise the email shows a bogus + # "moved from" room for a date/time-only edit. + args11["room_name"].should eq "Conference Room 1" + args11["building_name"].should eq "Main Building" + args11["previous_room_name"].should eq "Conference Room 1" + args11["previous_building_name"].should eq "Main Building" # ------------------------------------------------------------------ # Test 12: event_changed with location change (system_id differs) — @@ -713,7 +734,7 @@ DriverSpecs.mock_driver "Place::VisitorMailer" do 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"] + system(:Mailer)[:last_template].should eq ["visitor_invited", "event_changed"] args12 = system(:Mailer)[:last_args] args12["event_title"].should eq "Sprint Planning" @@ -749,6 +770,72 @@ DriverSpecs.mock_driver "Place::VisitorMailer" do args13["new_host_email"].should eq "new-organiser@example.com" args13["event_title"].should eq "Design Review" + # ------------------------------------------------------------------ + # Test 13b: event_changed host change where the payload omits + # event_end (a metadata-only reassignment). The previous + # host must STILL be notified — the host-change notification + # does not depend on the event end time. + # ------------------------------------------------------------------ + + count_before_host_no_end = system(:Mailer)[:send_count].as_i + + event_changed_host_no_end = { + action: "update", + system_id: "sys-room1", + event_id: "evt-110", + event_ical_uid: "ical-110", + host: "new-organiser2@example.com", + resource: "room1@example.com", + title: "Reassigned No End", + event_start: now + 3600, + # no event_end + zones: ["zone-building"], + previous_host_email: "old-organiser2@example.com", + }.to_json + + publish("staff/event/changed", event_changed_host_no_end) + sleep 1.5 + + system(:Mailer)[:send_count].should eq count_before_host_no_end + 1 + system(:Mailer)[:last_to].should eq "old-organiser2@example.com" + system(:Mailer)[:last_template].should eq ["visitor_invited", "notify_original_host"] + + args13b = system(:Mailer)[:last_args] + args13b["previous_host_email"].should eq "old-organiser2@example.com" + args13b["new_host_email"].should eq "new-organiser2@example.com" + args13b["event_title"].should eq "Reassigned No End" + # event_start was present, so the date should still render + args13b["event_date"].raw.should_not be_nil + + # ------------------------------------------------------------------ + # Test 13c: event_changed host change where the payload omits BOTH + # event_start and event_end (pure metadata reassignment). + # The previous host must still be notified; the date/time + # fields are simply left blank. + # ------------------------------------------------------------------ + + count_before_host_no_times = system(:Mailer)[:send_count].as_i + + event_changed_host_no_times = { + action: "update", + system_id: "sys-room1", + event_id: "evt-111", + event_ical_uid: "ical-111", + host: "new-organiser3@example.com", + resource: "room1@example.com", + title: "Reassigned No Times", + # no event_start, no event_end + zones: ["zone-building"], + previous_host_email: "old-organiser3@example.com", + }.to_json + + publish("staff/event/changed", event_changed_host_no_times) + sleep 1.5 + + system(:Mailer)[:send_count].should eq count_before_host_no_times + 1 + system(:Mailer)[:last_to].should eq "old-organiser3@example.com" + system(:Mailer)[:last_template].should eq ["visitor_invited", "notify_original_host"] + # ------------------------------------------------------------------ # Test 14: event_changed — action "create" is ignored (no previous # values to compare) @@ -771,7 +858,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 12 + system(:Mailer)[:send_count].should eq 14 # ------------------------------------------------------------------ # Test 15: event_changed — wrong zone is ignored @@ -795,7 +882,7 @@ DriverSpecs.mock_driver "Place::VisitorMailer" do publish("staff/event/changed", event_changed_wrong_zone) sleep 0.5 - system(:Mailer)[:send_count].should eq 12 + system(:Mailer)[:send_count].should eq 14 # ------------------------------------------------------------------ # Test 16: event_changed — no actual changes (previous == current) @@ -820,7 +907,7 @@ DriverSpecs.mock_driver "Place::VisitorMailer" do publish("staff/event/changed", event_changed_no_diff) sleep 0.5 - system(:Mailer)[:send_count].should eq 12 + system(:Mailer)[:send_count].should eq 14 # ------------------------------------------------------------------ # Test 17: event_changed with end-time-only change. @@ -846,9 +933,9 @@ DriverSpecs.mock_driver "Place::VisitorMailer" do publish("staff/event/changed", event_changed_end_only) sleep 1.5 - system(:Mailer)[:send_count].should eq 13 + system(:Mailer)[:send_count].should eq 15 system(:Mailer)[:last_to].should eq "visitor@external.com" - system(:Mailer)[:last_template].should eq ["visitor_invited", "booking_changed"] + system(:Mailer)[:last_template].should eq ["visitor_invited", "event_changed"] system(:Mailer)[:last_args]["event_title"].should eq "End Time Only Event" # ------------------------------------------------------------------ @@ -874,9 +961,9 @@ DriverSpecs.mock_driver "Place::VisitorMailer" do publish("staff/event/changed", event_changed_prev_location) sleep 1.5 - system(:Mailer)[:send_count].should eq 14 + system(:Mailer)[:send_count].should eq 16 system(:Mailer)[:last_to].should eq "visitor@external.com" - system(:Mailer)[:last_template].should eq ["visitor_invited", "booking_changed"] + system(:Mailer)[:last_template].should eq ["visitor_invited", "event_changed"] args18 = system(:Mailer)[:last_args] args18["event_title"].should eq "Location Change Meeting" @@ -1212,7 +1299,7 @@ DriverSpecs.mock_driver "Place::VisitorMailer" do # but only the visitor should receive the booking_changed email. system(:Mailer)[:send_count].should eq count_before_evt_host + 1 system(:Mailer)[:last_to].should eq "visitor@external.com" - system(:Mailer)[:last_template].should eq ["visitor_invited", "booking_changed"] + system(:Mailer)[:last_template].should eq ["visitor_invited", "event_changed"] # ------------------------------------------------------------------ # Test 29: send_booking_changed_emails (via booking_changed_event) — @@ -1373,4 +1460,249 @@ DriverSpecs.mock_driver "Place::VisitorMailer" do system(:Mailer)[:send_count].should eq count_before_induction_decline + 1 system(:Mailer)[:last_template].should eq ["visitor_invited", "custom_declined"] + + # ------------------------------------------------------------------ + # Test 33: event (room) changes and booking changes use SEPARATE + # templates. event_changed_event must use the + # `event_changed_template`, while booking_changed_event keeps + # the `booking_changed_template`. Custom overrides confirm + # both setting keys are wired up correctly. + # ------------------------------------------------------------------ + + settings({ + timezone: "GMT", + booking_space_name: "Client Floor", + invite_zone_tag: "building", + booking_changed_template: "custom_booking_changed", + event_changed_template: "custom_event_changed", + }) + sleep 1.0 + + # --- event (room) based change uses the event_changed template + count_before_split_event = system(:Mailer)[:send_count].as_i + + split_event_payload = { + action: "update", + system_id: "sys-room1", + event_id: "evt-split", + event_ical_uid: "ical-split", + host: "host@example.com", + resource: "room1@example.com", + title: "Split Event Change", + 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", split_event_payload) + sleep 1.5 + + system(:Mailer)[:send_count].should eq count_before_split_event + 1 + system(:Mailer)[:last_to].should eq "visitor@external.com" + system(:Mailer)[:last_template].should eq ["visitor_invited", "custom_event_changed"] + + # --- booking based change still uses the booking_changed template + count_before_split_booking = system(:Mailer)[:send_count].as_i + + split_booking_payload = { + action: "changed", + id: 900_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: "Split Booking Change", + zones: ["zone-building", "zone-room"], + previous_booking_start: now + 3600, + previous_booking_end: now + 7200, + }.to_json + + publish("staff/booking/changed", split_booking_payload) + sleep 1.5 + + system(:Mailer)[:send_count].should eq count_before_split_booking + 1 + system(:Mailer)[:last_to].should eq "visitor@external.com" + system(:Mailer)[:last_template].should eq ["visitor_invited", "custom_booking_changed"] + + # ================================================================== + # Issue 2 — de-duplicate event-linked booking notifications + # ================================================================== + # + # A calendar event with a room auto-creates a linked booking + # (extension_data.parent_id points to the event). Editing the event + # fires staff/event/changed (handled by event_changed_event) AND the + # linked booking fires staff/booking/changed — so without de-duplication + # the visitor receives TWO change notifications. booking_changed_event + # must skip event-linked bookings (same skip_event_linked_booking_email + # rule guest_event uses), letting the event_changed flow be the single + # source of truth. + + settings({ + timezone: "GMT", + booking_space_name: "Client Floor", + invite_zone_tag: "building", + # skip_event_linked_booking_email defaults to true + }) + sleep 1.0 + + # ------------------------------------------------------------------ + # Test 34: booking_changed for an event-linked booking is suppressed + # ------------------------------------------------------------------ + + count_before_linked_change = system(:Mailer)[:send_count].as_i + + linked_booking_changed = { + action: "changed", + id: 601_i64, + booking_type: "visitor", + booking_start: now + 7200, + booking_end: now + 10800, + timezone: "GMT", + resource_id: "visitor@external.com", + resource_ids: ["visitor@external.com"], + user_email: "host@example.com", + title: "Linked Visit Changed", + zones: ["zone-building", "zone-room"], + previous_booking_start: now + 3600, + previous_booking_end: now + 7200, + extension_data: {parent_id: "event-evt-200"}, + }.to_json + + publish("staff/booking/changed", linked_booking_changed) + sleep 1.0 + + # Event-linked: the event_changed flow already notifies these visitors, so + # no booking_changed email should be sent. + system(:Mailer)[:send_count].should eq count_before_linked_change + + # ------------------------------------------------------------------ + # Test 35: standalone booking_changed (no parent_id) still notifies + # ------------------------------------------------------------------ + + count_before_standalone_change = system(:Mailer)[:send_count].as_i + + standalone_booking_changed = { + action: "changed", + id: 600_i64, + booking_type: "visitor", + booking_start: now + 7200, + booking_end: now + 10800, + timezone: "GMT", + resource_id: "visitor@external.com", + resource_ids: ["visitor@external.com"], + user_email: "host@example.com", + title: "Standalone Visit Changed", + zones: ["zone-building", "zone-room"], + previous_booking_start: now + 3600, + previous_booking_end: now + 7200, + # no extension_data / parent_id + }.to_json + + publish("staff/booking/changed", standalone_booking_changed) + sleep 1.5 + + system(:Mailer)[:send_count].should eq count_before_standalone_change + 1 + system(:Mailer)[:last_to].should eq "visitor@external.com" + system(:Mailer)[:last_template].should eq ["visitor_invited", "booking_changed"] + + # ------------------------------------------------------------------ + # Test 36: opt-out (skip_event_linked_booking_email: false) restores + # the booking_changed email even for event-linked bookings. + # ------------------------------------------------------------------ + + settings({ + timezone: "GMT", + booking_space_name: "Client Floor", + invite_zone_tag: "building", + skip_event_linked_booking_email: false, + }) + sleep 1.0 + + count_before_optout_linked = system(:Mailer)[:send_count].as_i + + publish("staff/booking/changed", linked_booking_changed) + sleep 1.5 + + system(:Mailer)[:send_count].should eq count_before_optout_linked + 1 + system(:Mailer)[:last_to].should eq "visitor@external.com" + system(:Mailer)[:last_template].should eq ["visitor_invited", "booking_changed"] + + # ================================================================== + # event_start back-fill + # ================================================================== + # + # Metadata-only staff/event/changed signals (e.g. update_metadata) omit + # the top-level event_start. The host-change notification must still + # show a real date, so the driver looks the event up via the staff API. + + settings({ + timezone: "GMT", + booking_space_name: "Client Floor", + invite_zone_tag: "building", + }) + sleep 1.0 + + # ------------------------------------------------------------------ + # Test 37: event_start is looked up when omitted from the payload + # ------------------------------------------------------------------ + + count_before_lookup = system(:Mailer)[:send_count].as_i + + event_changed_needs_lookup = { + action: "update", + system_id: "sys-room1", + event_id: "evt-needs-lookup", + event_ical_uid: "ical-needs-lookup", + host: "new-host-l@example.com", + resource: "room1@example.com", + title: "Needs Lookup", + # no event_start / event_end (metadata-only update) + zones: ["zone-building"], + previous_host_email: "old-host-l@example.com", + }.to_json + + publish("staff/event/changed", event_changed_needs_lookup) + sleep 1.5 + + system(:Mailer)[:send_count].should eq count_before_lookup + 1 + system(:Mailer)[:last_to].should eq "old-host-l@example.com" + system(:Mailer)[:last_template].should eq ["visitor_invited", "notify_original_host"] + + args37 = system(:Mailer)[:last_args] + # The payload carried no event_start, so a non-nil date proves it was + # back-filled from staff_api.get_event (epoch 1_760_000_000). + args37["event_date"].raw.should_not be_nil + args37["event_time"].raw.should_not be_nil + + # ------------------------------------------------------------------ + # Test 38: lookup that can't resolve a start time still notifies the + # previous host (date/time simply left blank). + # ------------------------------------------------------------------ + + count_before_no_start = system(:Mailer)[:send_count].as_i + + event_changed_no_start = { + action: "update", + system_id: "sys-room1", + event_id: "evt-no-start", + event_ical_uid: "ical-no-start", + host: "new-host-n@example.com", + resource: "room1@example.com", + title: "No Start", + zones: ["zone-building"], + previous_host_email: "old-host-n@example.com", + }.to_json + + publish("staff/event/changed", event_changed_no_start) + sleep 1.5 + + system(:Mailer)[:send_count].should eq count_before_no_start + 1 + system(:Mailer)[:last_to].should eq "old-host-n@example.com" + system(:Mailer)[:last_template].should eq ["visitor_invited", "notify_original_host"] + system(:Mailer)[:last_args]["event_date"].raw.should be_nil end diff --git a/drivers/place/visitor_models.cr b/drivers/place/visitor_models.cr index 6f60e2f01e..e098d56ec6 100644 --- a/drivers/place/visitor_models.cr +++ b/drivers/place/visitor_models.cr @@ -120,6 +120,10 @@ module Place property title : String? property zones : Array(String)? + # Present when the booking was auto-created from a calendar event; + # extension_data["parent_id"] holds the linked event id. + property extension_data : Hash(String, JSON::Any)? + # Previous values — only present when action is "changed". # Add new previous_* fields here as more change notifications are introduced. property previous_booking_start : Int64?