Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 38 additions & 1 deletion drivers/place/visitor_mailer.cr
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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 }
Expand Down Expand Up @@ -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,
Expand All @@ -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
Expand All @@ -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},
Expand All @@ -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
Expand Down
12 changes: 12 additions & 0 deletions drivers/place/visitor_mailer_readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
165 changes: 165 additions & 0 deletions drivers/place/visitor_mailer_spec.cr
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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: "[email protected]",
event_title: "Host As Attendee",
event_summary: "Host As Attendee",
event_starting: now + 3600,
attendee_name: "Host User",
attendee_email: "[email protected]",
host: "[email protected]",
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: "[email protected]",
resource: "[email protected]",
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?("[email protected]").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 "[email protected]"
# the QR must point at the room the meeting moved TO
attachments43[0]["content"].as_s.includes?("VISIT:[email protected],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: "[email protected]",
resource_ids: ["[email protected]"],
user_email: "[email protected]",
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:[email protected],[email protected],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: "[email protected]",
resource: "[email protected]",
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
Loading