diff --git a/drivers/place/staff_api.cr b/drivers/place/staff_api.cr index f7700616ac..0fab97f304 100644 --- a/drivers/place/staff_api.cr +++ b/drivers/place/staff_api.cr @@ -986,9 +986,10 @@ class Place::StaffAPI < PlaceOS::Driver JSON.parse(response.body) end - def booking_guests(booking_id : String | Int64) + def booking_guests(booking_id : String | Int64, include_linked : Bool? = nil) logger.debug { "getting guests for booking #{booking_id}" } - response = get("/api/staff/v1/bookings/#{booking_id}/guests", headers: authentication) + params = include_linked ? "?include_linked=true" : "" + response = get("/api/staff/v1/bookings/#{booking_id}/guests#{params}", headers: authentication) raise "issue getting guests for booking #{booking_id}: #{response.status_code}" unless response.success? JSON.parse(response.body) end diff --git a/drivers/place/visitor_mailer.cr b/drivers/place/visitor_mailer.cr index 02fec19102..d4839a1650 100644 --- a/drivers/place/visitor_mailer.cr +++ b/drivers/place/visitor_mailer.cr @@ -614,7 +614,10 @@ class Place::VisitorMailer < PlaceOS::Driver end end - guests = staff_api.booking_guests(details.id).get.as_a + # include_linked: true ensures guests from child bookings (e.g. per-visitor + # bookings under a group parent) are returned in a single request. + guests = staff_api.booking_guests(details.id, include_linked: details.booking_type == "group").get.as_a + send_booking_changed_emails( guests, details.user_email, diff --git a/drivers/place/visitor_mailer_spec.cr b/drivers/place/visitor_mailer_spec.cr index 33d34fa7ee..c1e74f6986 100644 --- a/drivers/place/visitor_mailer_spec.cr +++ b/drivers/place/visitor_mailer_spec.cr @@ -120,15 +120,23 @@ class StaffAPIMock < DriverSpecs::MockDriver end end - def booking_guests(booking_id : Int64) - [ - { - email: "visitor@external.com", - name: "Visitor One", - checked_in: false, - visit_expected: true, - }, - ] + # When include_linked is true, parent group bookings (e.g. id 300) return + # guests from all child bookings in a single response — just like the real + # staff-api endpoint. + def booking_guests(booking_id : Int64, include_linked : Bool? = nil) + case booking_id + when 300 + if include_linked + [ + {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}, + ] + else + [] of NamedTuple(email: String, name: String, checked_in: Bool, visit_expected: Bool) + end + else + [{email: "visitor@external.com", name: "Visitor One", checked_in: false, visit_expected: true}] + end end def event_guests(event_id : String, system_id : String, ical_uid : String? = nil) @@ -874,4 +882,83 @@ DriverSpecs.mock_driver "Place::VisitorMailer" do # "approved" is not a visitor-notification action — no email should be sent system(:Mailer)[:send_count].should eq count_before_approved + + # ================================================================== + # Group booking linked-guest tests + # ================================================================== + + # ------------------------------------------------------------------ + # Test 20: booking_changed for a parent "group" booking. The driver + # passes include_linked: true so the API aggregates guests + # from child bookings into a single response. The mock + # returns 2 unique guests for booking 300 when the flag is + # set, simulating this aggregation. + # ------------------------------------------------------------------ + + count_before_group = system(:Mailer)[:send_count].as_i + + group_changed_payload = { + action: "changed", + id: 300_i64, + booking_type: "group", + booking_start: now + 7200, + booking_end: now + 10800, + timezone: "GMT", + resource_id: "host@example.com[2026-05-15]", + resource_ids: ["host@example.com[2026-05-15]"], + user_email: "host@example.com", + title: "Group Visit", + zones: ["zone-building", "zone-room"], + previous_booking_start: now + 3600, + previous_booking_end: now + 7200, + }.to_json + + publish("staff/booking/changed", group_changed_payload) + sleep 1.5 + + # The mock returns 2 unique guests for booking 300 with + # include_linked: true, so 2 emails should be sent. + system(:Mailer)[:send_count].should eq count_before_group + 2 + + # Last email should be to visitor-b (second child processed) + system(:Mailer)[:last_to].should eq "visitor-b@external.com" + system(:Mailer)[:last_template].should eq ["visitor_invited", "booking_changed"] + + args20 = system(:Mailer)[:last_args] + args20["event_title"].should eq "Group Visit" + args20["host_email"].should eq "host@example.com" + + # ------------------------------------------------------------------ + # Test 21: non-group booking should NOT pass include_linked. The + # mock for booking 300 returns an empty guest list when + # include_linked is false, so if the driver incorrectly + # passes include_linked: true for a non-group type the + # assertion below would fail (2 emails would be sent). + # ------------------------------------------------------------------ + + count_before_non_group = system(:Mailer)[:send_count].as_i + + non_group_payload = { + action: "changed", + id: 300_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: "Desk Booking", + zones: ["zone-building", "zone-room"], + previous_booking_start: now + 3600, + previous_booking_end: now + 7200, + }.to_json + + publish("staff/booking/changed", non_group_payload) + sleep 0.5 + + # Booking 300 with include_linked: false returns no guests, so no + # emails should be sent. This proves the driver does not pass + # include_linked: true for non-group booking types. + system(:Mailer)[:send_count].should eq count_before_non_group end