diff --git a/drivers/place/visitor_mailer.cr b/drivers/place/visitor_mailer.cr index fe3ec62aac..9b64825605 100644 --- a/drivers/place/visitor_mailer.cr +++ b/drivers/place/visitor_mailer.cr @@ -160,11 +160,20 @@ class Place::VisitorMailer < PlaceOS::Driver @skip_event_linked_booking_email : Bool = true @skip_host_email : Bool = true + # How long an event is remembered as changed after its notification was + # buffered, so a re-invite that arrives late is still recognised as one. + CHANGE_MEMORY = 60 + # Coalescing buffer for staff/event/changed, swept once the window elapses. # seconds to buffer a change; 0 emails on every signal @event_change_debounce : Int32 = 15 # ical_uid (or event_id) => coalesced change awaiting its flush @pending_event_changes : Hash(String, PendingEventChange) = {} of String => PendingEventChange + # "event key|attendee" => invite awaiting its flush + @pending_invites : Hash(String, PendingInvite) = {} of String => PendingInvite + # ical_uid and event_id of a changed event => expiry + @recent_event_changes : Hash(String, Time::Span) = {} of String => Time::Span + # guards all three of the above @pending_event_changes_lock : Mutex = Mutex.new @uri : URI = URI.new @@ -219,7 +228,10 @@ class Place::VisitorMailer < PlaceOS::Driver zones = control_system_zone_list # The sweep below picks up the rest; with the debounce off nothing would. - flush_event_changes("debounce disabled") if @event_change_debounce <= 0 + if @event_change_debounce <= 0 + flush_event_changes("debounce disabled") + flush_pending_invites("debounce disabled") + end schedule.clear if reminders = @send_reminders @@ -227,15 +239,16 @@ class Place::VisitorMailer < PlaceOS::Driver end # Sweeps at most every 5s, so a change waits its debounce plus up to one interval. - schedule.every(@event_change_debounce.clamp(1, 5).seconds) { sweep_event_changes } if @event_change_debounce > 0 + schedule.every(@event_change_debounce.clamp(1, 5).seconds) { sweep_buffers } if @event_change_debounce > 0 spawn { ensure_building_zone(zones) } end - # The scheduler is dead by now, so nothing else would sweep the buffer. + # The scheduler is dead by now, so nothing else would sweep the buffers. # Bounded to return within the driver manager's 6s unload budget. def on_unload - flush_event_changes("driver unloading", wait: 5.seconds) + flush_event_changes("driver unloading", wait: 3.seconds) + flush_pending_invites("driver unloading", wait: 2.seconds) end def control_system_zone_list @@ -360,9 +373,25 @@ class Place::VisitorMailer < PlaceOS::Driver in EventGuest return if @disable_event_visitors - room = get_room_details(guest_details.system_id) - area_name = room.display_name.presence || room.name - template = @event_template + # Moving an event to another room makes staff-api re-signal every visitor + # already attending it, which is a change rather than a new invitation. + # Hold the invite until the change window closes and drop it if a change + # for the same event turns up — the change email covers those visitors. + # The two signals are emitted concurrently, so either can arrive first. + if guest_details.action == "meeting_update" + if event_changed?(guest_details.event_ical_uid, guest_details.event_id) + logger.debug { "ignoring re-invite for #{guest_details.attendee_email} as event #{guest_details.event_id} changed" } + return + end + + if @event_change_debounce > 0 + buffer_pending_invite(guest_details) + return + end + end + + send_event_invite(guest_details) + return in BookingGuest area_name = @booking_space_name template = @booking_template @@ -562,7 +591,7 @@ class Place::VisitorMailer < PlaceOS::Driver {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"}, - ] + ] + jwt_fields [ TemplateFields.new( @@ -721,6 +750,8 @@ class Place::VisitorMailer < PlaceOS::Driver details.previous_booking_start, previous_building_name, previous_room_name, + event_id: details.id.to_s, + resource_id: details.resource_id, ) rescue error logger.error { error.inspect_with_backtrace } @@ -794,6 +825,11 @@ class Place::VisitorMailer < PlaceOS::Driver return unless fields_changed + # Remember the edit before buffering it: staff-api re-invites the visitors + # already on the event when it moves rooms, and that signal may arrive + # either side of this one. + register_event_change(details.event_ical_uid, details.event_id) + # Coalesce the burst of signals Office365 emits per edit into one email. change = PendingEventChange.new( details.event_id, details.system_id, details.event_ical_uid, @@ -811,6 +847,25 @@ class Place::VisitorMailer < PlaceOS::Driver } end + # Emails a visitor their invitation to a calendar event. The room is resolved + # here rather than on receipt so a held invite names the settled room. + private def send_event_invite(guest : EventGuest) : Nil + room = get_room_details(guest.system_id) + + send_visitor_qr_email( + @event_template, + guest.attendee_email, + guest.attendee_name, + guest.host, + guest.event_title || guest.event_summary, + guest.event_starting, + guest.resource_id, + guest.event_id, + room.display_name.presence || room.name, + system_id: guest.system_id, + ) + end + # Collapses the burst of signals for one edit into a single buffered change. # Keyed by event instance, so the rooms either side of a move coalesce too; # the one email then names a single room and uses that room's guest list. @@ -824,9 +879,71 @@ class Place::VisitorMailer < PlaceOS::Driver end end - # Sends any change that has been buffered for its full debounce window. - private def sweep_event_changes : Nil - flush_event_changes("debounce window elapsed", older_than: Time.monotonic - @event_change_debounce.seconds) + # Holds an invite back until we know whether the same edit also produced a + # change notification, which supersedes it. + private def buffer_pending_invite(guest : EventGuest) : Nil + invite = PendingInvite.new(guest) + @pending_event_changes_lock.synchronize do + @pending_invites[invite.buffer_key] ||= invite + end + end + + # Records that visitors are being emailed about an edit of this event. Both + # identifiers are stored as a signal may carry either one alone. + private def register_event_change(ical_uid : String?, event_id : String?) : Nil + expiry = Time.monotonic + (@event_change_debounce + CHANGE_MEMORY).seconds + @pending_event_changes_lock.synchronize do + expire_event_changes + {ical_uid, event_id}.each do |key| + @recent_event_changes[key] = expiry if key && !key.blank? + end + end + end + + private def event_changed?(ical_uid : String?, event_id : String?) : Bool + @pending_event_changes_lock.synchronize do + expire_event_changes + {ical_uid, event_id}.any? { |key| key && !key.blank? && @recent_event_changes.has_key?(key) } + end + end + + # Callers must hold `@pending_event_changes_lock`. + private def expire_event_changes : Nil + now = Time.monotonic + @recent_event_changes.reject! { |_key, expiry| expiry <= now } + end + + # Sends anything that has been buffered for its full debounce window. + private def sweep_buffers : Nil + cutoff = Time.monotonic - @event_change_debounce.seconds + flush_event_changes("debounce window elapsed", older_than: cutoff) + flush_pending_invites("debounce window elapsed", older_than: cutoff) + end + + # Sends the invites whose window has closed, dropping any the same edit's + # change notification has since covered. + private def flush_pending_invites(reason : String, older_than : Time::Span? = nil, wait : Time::Span? = nil) : Nil + flushing = @pending_event_changes_lock.synchronize do + ready = if cutoff = older_than + @pending_invites.values.select { |pending| pending.first_seen <= cutoff } + else + @pending_invites.values + end + ready.each { |pending| @pending_invites.delete(pending.buffer_key) } + ready + end + return if flushing.empty? + + logger.debug { "flushing #{flushing.size} held invite(s): #{reason}" } + + dispatch_flushed(flushing, "held invites: #{reason}", wait) do |pending| + guest = pending.guest + if event_changed?(guest.event_ical_uid, guest.event_id) + logger.debug { "dropping held invite for #{guest.attendee_email} as event #{guest.event_id} changed" } + else + send_event_invite(guest) + end + end end # Dispatches matching changes, each in its own fiber so a slow send can't stall @@ -846,17 +963,23 @@ class Place::VisitorMailer < PlaceOS::Driver logger.debug { "flushing #{flushing.size} pending event change(s): #{reason}" } - complete = Channel(Nil).new(flushing.size) - flushing.each do |pending| + dispatch_flushed(flushing, "event changes: #{reason}", wait) { |pending| dispatch_event_change(pending) } + end + + # Runs the handler for each flushed entry in its own fiber so a slow send can't + # stall the sweep. `wait` bounds how long we block for them to finish. + private def dispatch_flushed(items : Array(T), reason : String, wait : Time::Span?, &handler : T -> Nil) : Nil forall T + complete = Channel(Nil).new(items.size) + items.each do |item| spawn do - dispatch_event_change(pending) + handler.call(item) 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: "flushing event change #{pending.event_id}: #{reason}", + user: "flushing #{reason}", } ensure complete.send(nil) @@ -865,14 +988,14 @@ class Place::VisitorMailer < PlaceOS::Driver return unless wait deadline = Time.monotonic + wait - flushing.size.times do |index| + items.size.times do |index| remaining = deadline - Time.monotonic remaining = Time::Span.zero if remaining < Time::Span.zero select when complete.receive when timeout(remaining) - logger.warn { "timeout flushing pending event changes: #{reason}, #{flushing.size - index} of #{flushing.size} still in flight" } + logger.warn { "timeout flushing #{reason}, #{items.size - index} of #{items.size} still in flight" } break end end @@ -919,12 +1042,19 @@ class Place::VisitorMailer < PlaceOS::Driver previous_room_name, current_building_name, current_room_name, + event_id: change.event_id, + resource_id: system_id, + system_id: system_id, ) end # `building_name` / `room_name` override the current location names; when # omitted they fall back to `building_zone` / `@booking_space_name` (used by # the booking flow, which has no system_id to resolve from). + # + # `event_id` / `resource_id` / `system_id` identify the visit for the QR code + # and kiosk link; supplying them mirrors what the invitation email carries, so + # a visitor whose meeting moved has a check-in code for the new room. private def send_booking_changed_emails( guests : Array(JSON::Any), template : String, @@ -936,6 +1066,9 @@ class Place::VisitorMailer < PlaceOS::Driver previous_room_name : String, building_name : String? = nil, room_name : String? = nil, + event_id : String? = nil, + resource_id : String? = nil, + system_id : String? = nil, ) resolved_building_name = building_name || (building_zone.display_name.presence || building_zone.name) resolved_room_name = room_name || @booking_space_name @@ -955,6 +1088,28 @@ class Place::VisitorMailer < PlaceOS::Driver 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) } + guest_jwt = kiosk_url = "" + attach = [] of NamedTuple(file_name: String, content: String, content_id: String) + + if event_id + qr_resource = resource_id.presence || system_id.presence || "" + jwt_resource = system_id.presence || resource_id.presence || "" + + guest_jwt = generate_guest_jwt(visitor_name || visitor_email, visitor_email, visitor_email, event_id, jwt_resource) + kiosk_url = "/visitor-kiosk/?email=#{visitor_email}&token=#{guest_jwt}&event_id=#{event_id}#/checkin/preferences" + + unless @disable_qr_code + qr_png = mailer.generate_png_qrcode(text: "VISIT:#{visitor_email},#{qr_resource},#{event_id},#{host_email}", size: 256).get.as_s + attach = [ + { + file_name: "qr.png", + content: qr_png, + content_id: visitor_email, + }, + ] + end + end + mailer.send_template( visitor_email, {"visitor_invited", template}, @@ -973,7 +1128,10 @@ class Place::VisitorMailer < PlaceOS::Driver previous_event_time: previous_time, previous_room_name: previous_room_name, previous_building_name: previous_building_name, + guest_jwt: guest_jwt, + kiosk_url: kiosk_url, }, + attach, reply_to: host_email.presence, ) rescue error @@ -1236,6 +1394,20 @@ class Place::VisitorMailer < PlaceOS::Driver end end + # A visitor invitation held back until the change window for its event closes. + class PendingInvite + getter guest : EventGuest + getter first_seen : Time::Span = Time.monotonic + # one invite per attendee per event instance; a repeated signal for the same + # pair is the same invitation, not a second one. + getter buffer_key : String + + def initialize(@guest) + event_key = @guest.event_ical_uid.presence || @guest.event_id + @buffer_key = "#{event_key}|#{@guest.attendee_email.downcase}" + end + end + # zone_id, timeout, zone alias ZoneCache = Hash(String, Tuple(Int64, ZoneDetails)) diff --git a/drivers/place/visitor_mailer_readme.md b/drivers/place/visitor_mailer_readme.md index 8980ee0981..e9fa709b6c 100644 --- a/drivers/place/visitor_mailer_readme.md +++ b/drivers/place/visitor_mailer_readme.md @@ -52,6 +52,34 @@ moves the meeting *and* changes the time sends one email describing both rather one per room. A move between buildings is handled by two separate mailer modules and so still sends an email each. +### Re-invites during a room move + +Moving an event to another room makes the staff API re-announce every visitor already +attending it, exactly as though each had just been invited. That is a change, not a +new invitation, so those invites are held for the same debounce window and dropped +when a change notification for the same event turns up — the change email covers +those visitors. The two signals are emitted concurrently, so either order works. + +Consequences worth knowing: + +* invitations for visitors added to an *existing* event are delayed by the debounce + window (invitations for brand new events are always sent immediately), +* a visitor added by the same edit that moved the room receives the change + notification instead of an invitation. They still get a QR code and kiosk link (see + below), so nothing is lost. + +## QR code and kiosk link on change notifications + +The `booking_changed` and `event_changed` templates receive `guest_jwt` and +`kiosk_url` fields and the same inline `qr.png` attachment as an invitation, because +a move invalidates the kiosk link issued with the original invite (its token is scoped +to the old room) and the invite is no longer re-sent. + +Reference the attachment from the template the same way the invite template does, or +set `disable_qr_code: true` to leave it off. Until a template uses them the fields are +simply unused, though an unreferenced attachment may still show up in some mail +clients. + ## Reply-To Visitor emails set a `Reply-To` header so replies reach a useful person rather than diff --git a/drivers/place/visitor_mailer_spec.cr b/drivers/place/visitor_mailer_spec.cr index 8a738fa43d..e9e022c0c6 100644 --- a/drivers/place/visitor_mailer_spec.cr +++ b/drivers/place/visitor_mailer_spec.cr @@ -5,8 +5,13 @@ require "placeos-driver/interface/mailer" class MailerMock < DriverSpecs::MockDriver include PlaceOS::Driver::Interface::Mailer + # Every template name sent so far, so a test can assert that a particular + # email was NOT sent rather than only counting the total. + @templates_sent : Array(String) = [] of String + def on_load self[:send_count] = 0 + self[:sent_templates] = @templates_sent end def send_template( @@ -24,6 +29,9 @@ class MailerMock < DriverSpecs::MockDriver self[:last_template] = template self[:last_args] = args self[:last_reply_to] = reply_to + self[:last_attachments] = resource_attachments + @templates_sent << template[1] + self[:sent_templates] = @templates_sent self[:send_count] = self[:send_count].as_i + 1 true end @@ -155,6 +163,11 @@ class StaffAPIMock < DriverSpecs::MockDriver def event_guests(event_id : String, system_id : String, ical_uid : String? = nil) case event_id + when "evt-two-visitors" + [ + {email: "visitor-a@external.com", name: "Visitor A", checked_in: false, visit_expected: true}, + {email: "visitor-b@external.com", name: "Visitor B", checked_in: false, visit_expected: true}, + ] when "evt-host-in-guests" # Mirrors the production scenario where events.cr stores the host # as an attendee (visit_expected: true), so they appear in the @@ -2190,4 +2203,603 @@ DriverSpecs.mock_driver "Place::VisitorMailer" do system(:Mailer)[:send_count].should eq count_before_checkout status[:users_checked_in].should eq checked_in_before_checkout + + # ================================================================== + # Scenario 5 — a room move must not re-invite existing visitors + # ================================================================== + # + # When the room changes, staff-api re-signals staff/guest/attending + # (action: meeting_update) for every visitor already on the event, which the + # driver turned into a second, full invitation email on top of the change + # notification. The invite is held for the debounce window and dropped when + # a change for the same event turns up — in either signal order. + + settings({ + timezone: "GMT", + booking_space_name: "Client Floor", + invite_zone_tag: "building", + event_change_debounce: 1, + domain_uri: "https://example.com/", + }) + sleep 1.0 + + # ------------------------------------------------------------------ + # Test 42: room move — change signal first, then the re-invite + # ------------------------------------------------------------------ + + count_before_move_invite = system(:Mailer)[:send_count].as_i + invites_before_move = system(:Mailer)[:sent_templates].as_a.count { |name| name.as_s == "event" } + + publish("staff/event/changed", { + action: "update", + system_id: "sys-room1", + event_id: "evt-move-invite", + event_ical_uid: "ical-move-invite", + host: "host@example.com", + resource: "room1@example.com", + title: "Moved Meeting", + event_start: now + 3600, + event_end: now + 7200, + zones: ["zone-building", "zone-room"], + previous_system_id: "sys-room2", + }.to_json) + + publish("staff/guest/attending", { + action: "meeting_update", + system_id: "sys-room1", + event_id: "evt-move-invite", + event_ical_uid: "ical-move-invite", + resource: "room1@example.com", + event_title: "Moved Meeting", + event_summary: "Moved Meeting", + event_starting: now + 3600, + attendee_name: "Visitor One", + attendee_email: "visitor@external.com", + host: "host@example.com", + zones: ["zone-building", "zone-room"], + }.to_json) + + sleep 5.0 + + # only the change notification, no second invitation + system(:Mailer)[:send_count].should eq count_before_move_invite + 1 + system(:Mailer)[:last_to].should eq "visitor@external.com" + system(:Mailer)[:last_template].should eq ["visitor_invited", "event_changed"] + system(:Mailer)[:sent_templates].as_a.count { |name| name.as_s == "event" }.should eq invites_before_move + + # ------------------------------------------------------------------ + # Test 43: the same pair in the other order — the re-invite arrives + # before the change signal (both are spawned concurrently by + # staff-api, so neither order is guaranteed) + # ------------------------------------------------------------------ + + count_before_invite_first = system(:Mailer)[:send_count].as_i + invites_before_invite_first = system(:Mailer)[:sent_templates].as_a.count { |name| name.as_s == "event" } + + publish("staff/guest/attending", { + action: "meeting_update", + system_id: "sys-room1", + event_id: "evt-invite-first", + event_ical_uid: "ical-invite-first", + resource: "room1@example.com", + event_title: "Invite First", + event_summary: "Invite First", + event_starting: now + 3600, + attendee_name: "Visitor One", + attendee_email: "visitor@external.com", + host: "host@example.com", + zones: ["zone-building", "zone-room"], + }.to_json) + + publish("staff/event/changed", { + action: "update", + system_id: "sys-room1", + event_id: "evt-invite-first", + event_ical_uid: "ical-invite-first", + host: "host@example.com", + resource: "room1@example.com", + title: "Invite First", + event_start: now + 3600, + event_end: now + 7200, + zones: ["zone-building", "zone-room"], + previous_system_id: "sys-room2", + }.to_json) + + sleep 5.0 + + system(:Mailer)[:send_count].should eq count_before_invite_first + 1 + system(:Mailer)[:last_template].should eq ["visitor_invited", "event_changed"] + system(:Mailer)[:sent_templates].as_a.count { |name| name.as_s == "event" }.should eq invites_before_invite_first + + # ------------------------------------------------------------------ + # Test 44: every visitor on the event is re-signalled, so all of the + # invites must be dropped — not just the first + # ------------------------------------------------------------------ + + count_before_two = system(:Mailer)[:send_count].as_i + invites_before_two = system(:Mailer)[:sent_templates].as_a.count { |name| name.as_s == "event" } + + publish("staff/event/changed", { + action: "update", + system_id: "sys-room1", + event_id: "evt-two-visitors", + event_ical_uid: "ical-two-visitors", + host: "host@example.com", + resource: "room1@example.com", + title: "Two Visitors Moved", + event_start: now + 3600, + event_end: now + 7200, + zones: ["zone-building", "zone-room"], + previous_system_id: "sys-room2", + }.to_json) + + ["visitor-a@external.com", "visitor-b@external.com"].each do |attendee| + publish("staff/guest/attending", { + action: "meeting_update", + system_id: "sys-room1", + event_id: "evt-two-visitors", + event_ical_uid: "ical-two-visitors", + resource: "room1@example.com", + event_title: "Two Visitors Moved", + event_summary: "Two Visitors Moved", + event_starting: now + 3600, + attendee_name: "Visitor", + attendee_email: attendee, + host: "host@example.com", + zones: ["zone-building", "zone-room"], + }.to_json) + end + + sleep 5.0 + + # two change notifications (one per visitor), no invitations + system(:Mailer)[:send_count].should eq count_before_two + 2 + system(:Mailer)[:sent_templates].as_a.count { |name| name.as_s == "event" }.should eq invites_before_two + + # ------------------------------------------------------------------ + # Test 45: a visitor added to an existing event (no change signal) + # still receives their invitation, with the QR code and kiosk + # link. This is the "new visitors added" scenario and must + # not be caught by the suppression above. + # ------------------------------------------------------------------ + + count_before_new_visitor = system(:Mailer)[:send_count].as_i + + publish("staff/guest/attending", { + action: "meeting_update", + system_id: "sys-room1", + event_id: "evt-new-visitor", + event_ical_uid: "ical-new-visitor", + resource: "room1@example.com", + event_title: "Added To Meeting", + event_summary: "Added To Meeting", + event_starting: now + 3600, + attendee_name: "Visitor One", + attendee_email: "visitor@external.com", + host: "host@example.com", + zones: ["zone-building", "zone-room"], + }.to_json) + + sleep 5.0 + + system(:Mailer)[:send_count].should eq count_before_new_visitor + 1 + system(:Mailer)[:last_to].should eq "visitor@external.com" + system(:Mailer)[:last_template].should eq ["visitor_invited", "event"] + + args45 = system(:Mailer)[:last_args] + args45["room_name"].should eq "Conference Room 1" + args45["guest_jwt"].as_s.should_not be_empty + args45["kiosk_url"].as_s.should_not be_empty + + attachments45 = system(:Mailer)[:last_attachments].as_a + attachments45.size.should eq 1 + attachments45[0]["content_id"].should eq "visitor@external.com" + + # ------------------------------------------------------------------ + # Test 46: a brand new event invitation is never held — only the + # re-invite (meeting_update) waits for the change window. + # ------------------------------------------------------------------ + + settings({ + timezone: "GMT", + booking_space_name: "Client Floor", + invite_zone_tag: "building", + event_change_debounce: 3, + domain_uri: "https://example.com/", + }) + sleep 1.0 + + count_before_created = system(:Mailer)[:send_count].as_i + + publish("staff/guest/attending", { + action: "meeting_created", + system_id: "sys-room1", + event_id: "evt-created", + event_ical_uid: "ical-created", + resource: "room1@example.com", + event_title: "Brand New Meeting", + event_summary: "Brand New Meeting", + event_starting: now + 3600, + attendee_name: "Visitor One", + attendee_email: "visitor@external.com", + host: "host@example.com", + zones: ["zone-building", "zone-room"], + }.to_json) + + publish("staff/guest/attending", { + action: "meeting_update", + system_id: "sys-room1", + event_id: "evt-held", + event_ical_uid: "ical-held", + resource: "room1@example.com", + event_title: "Held Meeting", + event_summary: "Held Meeting", + event_starting: now + 3600, + attendee_name: "Visitor One", + attendee_email: "visitor@external.com", + host: "host@example.com", + zones: ["zone-building", "zone-room"], + }.to_json) + + sleep 2.0 + + # the new invitation is already out, the re-invite is still buffered + system(:Mailer)[:send_count].should eq count_before_created + 1 + system(:Mailer)[:last_args]["event_title"].should eq "Brand New Meeting" + + # no change turned up, so the held invite is sent once the window closes + sleep 7.0 + system(:Mailer)[:send_count].should eq count_before_created + 2 + system(:Mailer)[:last_template].should eq ["visitor_invited", "event"] + system(:Mailer)[:last_args]["event_title"].should eq "Held Meeting" + + # ------------------------------------------------------------------ + # Test 47: with the debounce off, invites are sent immediately, but a + # change that has already been seen still suppresses them. + # ------------------------------------------------------------------ + + settings({ + timezone: "GMT", + booking_space_name: "Client Floor", + invite_zone_tag: "building", + event_change_debounce: 0, + domain_uri: "https://example.com/", + }) + sleep 1.0 + + count_before_no_debounce = system(:Mailer)[:send_count].as_i + invites_before_no_debounce = system(:Mailer)[:sent_templates].as_a.count { |name| name.as_s == "event" } + + publish("staff/event/changed", { + action: "update", + system_id: "sys-room1", + event_id: "evt-no-debounce", + event_ical_uid: "ical-no-debounce", + host: "host@example.com", + resource: "room1@example.com", + title: "No Debounce Move", + event_start: now + 3600, + event_end: now + 7200, + zones: ["zone-building", "zone-room"], + previous_system_id: "sys-room2", + }.to_json) + + sleep 1.5 + + publish("staff/guest/attending", { + action: "meeting_update", + system_id: "sys-room1", + event_id: "evt-no-debounce", + event_ical_uid: "ical-no-debounce", + resource: "room1@example.com", + event_title: "No Debounce Move", + event_summary: "No Debounce Move", + event_starting: now + 3600, + attendee_name: "Visitor One", + attendee_email: "visitor@external.com", + host: "host@example.com", + zones: ["zone-building", "zone-room"], + }.to_json) + + sleep 1.5 + + system(:Mailer)[:send_count].should eq count_before_no_debounce + 1 + system(:Mailer)[:sent_templates].as_a.count { |name| name.as_s == "event" }.should eq invites_before_no_debounce + + # ------------------------------------------------------------------ + # Test 48: suppression is scoped to the event that changed — an + # unrelated event's invite must still go out. + # ------------------------------------------------------------------ + + settings({ + timezone: "GMT", + booking_space_name: "Client Floor", + invite_zone_tag: "building", + event_change_debounce: 1, + domain_uri: "https://example.com/", + }) + sleep 1.0 + + count_before_scope = system(:Mailer)[:send_count].as_i + invites_before_scope = system(:Mailer)[:sent_templates].as_a.count { |name| name.as_s == "event" } + + publish("staff/event/changed", { + action: "update", + system_id: "sys-room1", + event_id: "evt-scope-a", + event_ical_uid: "ical-scope-a", + host: "host@example.com", + resource: "room1@example.com", + title: "Scope A", + event_start: now + 3600, + event_end: now + 7200, + zones: ["zone-building", "zone-room"], + previous_system_id: "sys-room2", + }.to_json) + + publish("staff/guest/attending", { + action: "meeting_update", + system_id: "sys-room1", + event_id: "evt-scope-b", + event_ical_uid: "ical-scope-b", + resource: "room1@example.com", + event_title: "Scope B", + event_summary: "Scope B", + event_starting: now + 3600, + attendee_name: "Visitor One", + attendee_email: "visitor@external.com", + host: "host@example.com", + zones: ["zone-building", "zone-room"], + }.to_json) + + sleep 5.0 + + # one change email for event A, one invitation for event B + system(:Mailer)[:send_count].should eq count_before_scope + 2 + system(:Mailer)[:sent_templates].as_a.count { |name| name.as_s == "event" }.should eq invites_before_scope + 1 + + # ------------------------------------------------------------------ + # Test 49: the guest signal may omit the ical uid, so the event id is + # matched as well. + # ------------------------------------------------------------------ + + count_before_alias = system(:Mailer)[:send_count].as_i + invites_before_alias = system(:Mailer)[:sent_templates].as_a.count { |name| name.as_s == "event" } + + publish("staff/event/changed", { + action: "update", + system_id: "sys-room1", + event_id: "evt-alias", + event_ical_uid: "ical-alias", + host: "host@example.com", + resource: "room1@example.com", + title: "Alias Match", + event_start: now + 3600, + event_end: now + 7200, + zones: ["zone-building", "zone-room"], + previous_system_id: "sys-room2", + }.to_json) + + publish("staff/guest/attending", { + action: "meeting_update", + system_id: "sys-room1", + event_id: "evt-alias", + # no event_ical_uid on this signal + resource: "room1@example.com", + event_title: "Alias Match", + event_summary: "Alias Match", + event_starting: now + 3600, + attendee_name: "Visitor One", + attendee_email: "visitor@external.com", + host: "host@example.com", + zones: ["zone-building", "zone-room"], + }.to_json) + + sleep 5.0 + + system(:Mailer)[:send_count].should eq count_before_alias + 1 + system(:Mailer)[:sent_templates].as_a.count { |name| name.as_s == "event" }.should eq invites_before_alias + + # ------------------------------------------------------------------ + # Test 50: turning the debounce off flushes held invites rather than + # orphaning them (the same drain on_unload performs, which the + # spec harness cannot invoke directly). + # ------------------------------------------------------------------ + + settings({ + timezone: "GMT", + booking_space_name: "Client Floor", + invite_zone_tag: "building", + event_change_debounce: 30, + domain_uri: "https://example.com/", + }) + sleep 1.0 + + count_before_invite_drain = system(:Mailer)[:send_count].as_i + + publish("staff/guest/attending", { + action: "meeting_update", + system_id: "sys-room1", + event_id: "evt-invite-drain", + event_ical_uid: "ical-invite-drain", + resource: "room1@example.com", + event_title: "Invite Drain", + event_summary: "Invite Drain", + event_starting: now + 3600, + attendee_name: "Visitor One", + attendee_email: "visitor@external.com", + host: "host@example.com", + zones: ["zone-building", "zone-room"], + }.to_json) + + sleep 1.0 + system(:Mailer)[:send_count].should eq count_before_invite_drain + + settings({ + timezone: "GMT", + booking_space_name: "Client Floor", + invite_zone_tag: "building", + event_change_debounce: 0, + domain_uri: "https://example.com/", + }) + sleep 2.0 + + system(:Mailer)[:send_count].should eq count_before_invite_drain + 1 + system(:Mailer)[:last_template].should eq ["visitor_invited", "event"] + system(:Mailer)[:last_args]["event_title"].should eq "Invite Drain" + + # ------------------------------------------------------------------ + # Test 51: the host is force-added to the attendee list by staff-api, + # so they are signalled like a visitor — skip_host_email must + # catch that on the event invite path too. + # ------------------------------------------------------------------ + + count_before_host_invite = system(:Mailer)[:send_count].as_i + + publish("staff/guest/attending", { + action: "meeting_update", + system_id: "sys-room1", + event_id: "evt-host-invite", + event_ical_uid: "ical-host-invite", + resource: "room1@example.com", + event_title: "Host As Attendee", + event_summary: "Host As Attendee", + event_starting: now + 3600, + attendee_name: "Host User", + attendee_email: "host@example.com", + host: "host@example.com", + zones: ["zone-building", "zone-room"], + }.to_json) + + sleep 1.5 + system(:Mailer)[:send_count].should eq count_before_host_invite + + # ================================================================== + # Change notifications carry the QR code and kiosk link + # ================================================================== + # + # A move invalidates the kiosk link issued with the original invite (its + # token is scoped to the old room), and the invite is no longer re-sent, + # so the change notification has to carry a fresh one. + + # ------------------------------------------------------------------ + # Test 52: event_changed includes guest_jwt, kiosk_url and the QR + # ------------------------------------------------------------------ + + count_before_event_qr = system(:Mailer)[:send_count].as_i + + publish("staff/event/changed", { + action: "update", + system_id: "sys-room1", + event_id: "evt-qr", + event_ical_uid: "ical-qr", + host: "host@example.com", + resource: "room1@example.com", + title: "QR Change", + event_start: now + 3600, + event_end: now + 7200, + zones: ["zone-building", "zone-room"], + previous_system_id: "sys-room2", + }.to_json) + + sleep 1.5 + + system(:Mailer)[:send_count].should eq count_before_event_qr + 1 + system(:Mailer)[:last_template].should eq ["visitor_invited", "event_changed"] + + args52 = system(:Mailer)[:last_args] + args52["guest_jwt"].as_s.should_not be_empty + args52["kiosk_url"].as_s.includes?("visitor@external.com").should be_true + + attachments52 = system(:Mailer)[:last_attachments].as_a + attachments52.size.should eq 1 + attachments52[0]["file_name"].should eq "qr.png" + attachments52[0]["content_id"].should eq "visitor@external.com" + # the QR must point at the room the meeting moved TO + attachments52[0]["content"].as_s.includes?("VISIT:visitor@external.com,sys-room1,evt-qr").should be_true + + # ------------------------------------------------------------------ + # Test 53: booking_changed carries the same + # ------------------------------------------------------------------ + + count_before_booking_qr = system(:Mailer)[:send_count].as_i + + publish("staff/booking/changed", { + action: "changed", + id: 950_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: "QR Booking Change", + zones: ["zone-building", "zone-room"], + previous_booking_start: now + 3600, + previous_booking_end: now + 7200, + }.to_json) + + sleep 1.5 + + system(:Mailer)[:send_count].should eq count_before_booking_qr + 1 + system(:Mailer)[:last_template].should eq ["visitor_invited", "booking_changed"] + + args53 = system(:Mailer)[:last_args] + args53["guest_jwt"].as_s.should_not be_empty + args53["kiosk_url"].as_s.includes?("event_id=950").should be_true + + attachments53 = system(:Mailer)[:last_attachments].as_a + attachments53.size.should eq 1 + attachments53[0]["content"].as_s.includes?("VISIT:visitor@external.com,visitor@external.com,950").should be_true + + # ------------------------------------------------------------------ + # Test 54: disable_qr_code drops the attachment but keeps the link + # ------------------------------------------------------------------ + + settings({ + timezone: "GMT", + booking_space_name: "Client Floor", + invite_zone_tag: "building", + event_change_debounce: 0, + disable_qr_code: true, + domain_uri: "https://example.com/", + }) + sleep 1.0 + + count_before_no_qr = system(:Mailer)[:send_count].as_i + + publish("staff/event/changed", { + action: "update", + system_id: "sys-room1", + event_id: "evt-no-qr", + event_ical_uid: "ical-no-qr", + host: "host@example.com", + resource: "room1@example.com", + title: "No QR Change", + event_start: now + 3600, + event_end: now + 7200, + zones: ["zone-building", "zone-room"], + previous_system_id: "sys-room2", + }.to_json) + + sleep 1.5 + + system(:Mailer)[:send_count].should eq count_before_no_qr + 1 + system(:Mailer)[:last_attachments].as_a.size.should eq 0 + system(:Mailer)[:last_args]["kiosk_url"].as_s.should_not be_empty + + # ------------------------------------------------------------------ + # Test 55: the changed templates expose the kiosk fields so they can be + # referenced from the template editor. + # ------------------------------------------------------------------ + + fields = exec(:template_fields).get.as_a + ["booking_changed", "event_changed"].each do |template_name| + entry = fields.find { |field| field["trigger"].as_a[1].as_s == template_name } + entry.should_not be_nil + names = entry.not_nil!["fields"].as_a.map { |field| field["name"].as_s } + names.should contain "guest_jwt" + names.should contain "kiosk_url" + end end diff --git a/drivers/place/visitor_models.cr b/drivers/place/visitor_models.cr index 2740ca4363..2b3e8521d3 100644 --- a/drivers/place/visitor_models.cr +++ b/drivers/place/visitor_models.cr @@ -31,6 +31,11 @@ module Place property attendee_email : String property host : String + # Present on calendar event signals. Identifies the event instance across + # mailbox copies, so an invite can be matched against a change notification + # for the same edit. + property event_ical_uid : String? + # This is optional for backwards compatibility property zones : Array(String)?