diff --git a/drivers/place/visitor_mailer.cr b/drivers/place/visitor_mailer.cr index fe3ec62aac..2df4db38b1 100644 --- a/drivers/place/visitor_mailer.cr +++ b/drivers/place/visitor_mailer.cr @@ -562,7 +562,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 +721,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 } @@ -919,12 +921,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 +945,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 +967,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 +1007,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 diff --git a/drivers/place/visitor_mailer_readme.md b/drivers/place/visitor_mailer_readme.md index 8980ee0981..c28a604c69 100644 --- a/drivers/place/visitor_mailer_readme.md +++ b/drivers/place/visitor_mailer_readme.md @@ -52,6 +52,18 @@ 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. +## 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 room the meeting has just left, and no fresh invitation is 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..a6f469dde2 100644 --- a/drivers/place/visitor_mailer_spec.cr +++ b/drivers/place/visitor_mailer_spec.cr @@ -24,6 +24,7 @@ class MailerMock < DriverSpecs::MockDriver self[:last_template] = template self[:last_args] = args self[:last_reply_to] = reply_to + self[:last_attachments] = resource_attachments self[:send_count] = self[:send_count].as_i + 1 true end @@ -2190,4 +2191,168 @@ 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 + + settings({ + timezone: "GMT", + booking_space_name: "Client Floor", + invite_zone_tag: "building", + event_change_debounce: 0, + domain_uri: "https://example.com/", + }) + sleep 1.0 + + # ------------------------------------------------------------------ + # Test 42: 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 invitation — its + # token is scoped to the room the meeting has just left — and no fresh + # invitation is sent, so the change notification has to carry one. + + # ------------------------------------------------------------------ + # Test 43: 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"] + + args43 = system(:Mailer)[:last_args] + args43["guest_jwt"].as_s.should_not be_empty + args43["kiosk_url"].as_s.includes?("visitor@external.com").should be_true + + attachments43 = system(:Mailer)[:last_attachments].as_a + attachments43.size.should eq 1 + attachments43[0]["file_name"].should eq "qr.png" + attachments43[0]["content_id"].should eq "visitor@external.com" + # the QR must point at the room the meeting moved TO + attachments43[0]["content"].as_s.includes?("VISIT:visitor@external.com,sys-room1,evt-qr").should be_true + + # ------------------------------------------------------------------ + # Test 44: 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"] + + args44 = system(:Mailer)[:last_args] + args44["guest_jwt"].as_s.should_not be_empty + args44["kiosk_url"].as_s.includes?("event_id=950").should be_true + + attachments44 = system(:Mailer)[:last_attachments].as_a + attachments44.size.should eq 1 + attachments44[0]["content"].as_s.includes?("VISIT:visitor@external.com,visitor@external.com,950").should be_true + + # ------------------------------------------------------------------ + # Test 45: 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 46: 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