From d7444f9269c69ffd06124a2188104840be462003 Mon Sep 17 00:00:00 2001 From: Mia Bennett Date: Thu, 14 May 2026 17:37:35 +0930 Subject: [PATCH 1/7] fix(events): support #guest_list ical_uid lookup --- src/controllers/events.cr | 22 +++++++++++++++------- 1 file changed, 15 insertions(+), 7 deletions(-) diff --git a/src/controllers/events.cr b/src/controllers/events.cr index 8e520d6c..8f1721c4 100644 --- a/src/controllers/events.cr +++ b/src/controllers/events.cr @@ -1706,16 +1706,24 @@ class Events < Application event_id : String, @[AC::Param::Info(description: "the event space associated with this event", example: "sys-1234")] system_id : String, + @[AC::Param::Info(description: "the ical uid of the event you are looking for", example: "sqvitruh3ho3mrq896tplad4v8")] + ical_uid : String? = nil, ) : Array(Guest) - cal_id = get_placeos_client.systems.fetch(system_id).email - return [] of Guest unless cal_id + if ical_uid.presence + metadata = EventMetadata.by_tenant(tenant.id).where(ical_uid: ical_uid, system_id: system_id).first? + parent_meta = false + else + cal_id = get_placeos_client.systems.fetch(system_id).email + return [] of Guest unless cal_id - event = client.get_event(user.email, id: event_id, calendar_id: cal_id) - raise Error::NotFound.new("event #{event_id} not found on system calendar #{cal_id}") unless event + event = client.get_event(user.email, id: event_id, calendar_id: cal_id) + raise Error::NotFound.new("event #{event_id} not found on system calendar #{cal_id}") unless event + + # Grab meeting metadata if it exists + metadata = get_event_metadata(event, system_id) + parent_meta = !metadata.try &.for_event_instance?(event, client.client_id) + end - # Grab meeting metadata if it exists - metadata = get_event_metadata(event, system_id) - parent_meta = !metadata.try &.for_event_instance?(event, client.client_id) return [] of Guest unless metadata # Find anyone who is attending From 844dc7b0bdd3c6cf0f597b59a290d9fe58e38356 Mon Sep 17 00:00:00 2001 From: Mia Bennett Date: Thu, 14 May 2026 17:42:49 +0930 Subject: [PATCH 2/7] docs(openapi.yml) --- OPENAPI_DOC.yml | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/OPENAPI_DOC.yml b/OPENAPI_DOC.yml index 2d29e73e..189deb6d 100644 --- a/OPENAPI_DOC.yml +++ b/OPENAPI_DOC.yml @@ -6268,6 +6268,13 @@ paths: required: true schema: type: string + - name: ical_uid + in: query + description: the ical uid of the event you are looking for + example: sqvitruh3ho3mrq896tplad4v8 + schema: + type: string + nullable: true responses: 200: description: OK From 24e543adc4f97cf449113c487524772992814b28 Mon Sep 17 00:00:00 2001 From: Mia Bennett Date: Fri, 15 May 2026 13:41:43 +0930 Subject: [PATCH 3/7] feat(bookings): add include_linked guests (PPT-2375) --- OPENAPI_DOC.yml | 6 ++++++ src/controllers/bookings.cr | 17 +++++++++++++++-- 2 files changed, 21 insertions(+), 2 deletions(-) diff --git a/OPENAPI_DOC.yml b/OPENAPI_DOC.yml index 189deb6d..a2db8cf4 100644 --- a/OPENAPI_DOC.yml +++ b/OPENAPI_DOC.yml @@ -3253,6 +3253,12 @@ paths: schema: type: integer format: Int64 + - name: include_linked + in: query + description: include guests from linked (child) bookings + example: "true" + schema: + type: boolean - name: instance in: query description: a recurring instance id diff --git a/src/controllers/bookings.cr b/src/controllers/bookings.cr index c6b72088..7eae1446 100644 --- a/src/controllers/bookings.cr +++ b/src/controllers/bookings.cr @@ -963,10 +963,23 @@ class Bookings < Application # returns a list of guests associated with a booking @[AC::Route::GET("/:id/guests")] - def guest_list : Array(Guest) - booking.attendees.to_a.map do |visitor| + def guest_list( + @[AC::Param::Info(description: "include guests from linked (child) bookings", example: "true")] + include_linked : Bool = false, + ) : Array(Guest) + guests = booking.attendees.to_a.map do |visitor| visitor.guest.not_nil!.for_booking_to_h(visitor, booking) end + + if include_linked && booking.parent? + Booking.where(parent_id: booking.id).each do |child| + child.attendees.to_a.each do |visitor| + guests << visitor.guest.not_nil!.for_booking_to_h(visitor, child) + end + end + end + + guests end # marks the standalone visitor as checked-in or checked-out based on the state param From edc19ac64904d09bf9bc46349cd267c6b62dfe18 Mon Sep 17 00:00:00 2001 From: Mia Bennett Date: Fri, 15 May 2026 14:14:27 +0930 Subject: [PATCH 4/7] tests(visitor_mailer): test #guests linked_bookings (PPT-2375) --- spec/controllers/bookings_spec.cr | 43 +++++++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/spec/controllers/bookings_spec.cr b/spec/controllers/bookings_spec.cr index 31d5e9b5..d30ff1d9 100644 --- a/spec/controllers/bookings_spec.cr +++ b/spec/controllers/bookings_spec.cr @@ -1432,6 +1432,49 @@ describe Bookings do body.map(&.["name"]).should eq([guest.name]) end + it "#guest_list with include_linked should include guests from child bookings" do + tenant = get_tenant + user_email = Faker::Internet.email + + parent = BookingsHelper.create_booking( + tenant_id: tenant.id.not_nil!, + user_email: user_email, + booking_type: "group", + ) + + parent_guest = GuestsHelper.create_guest(tenant.id, "parent-guest@example.com") + Attendee.create!(booking_id: parent.id.not_nil!, + guest_id: parent_guest.id, + tenant_id: parent_guest.tenant_id, + checked_in: false, + visit_expected: true, + ) + + child = BookingsHelper.create_booking( + tenant_id: tenant.id.not_nil!, + user_email: user_email, + booking_type: "visitor", + parent_id: parent.id, + ) + + child_guest = GuestsHelper.create_guest(tenant.id, "child-guest@example.com") + Attendee.create!(booking_id: child.id.not_nil!, + guest_id: child_guest.id, + tenant_id: child_guest.tenant_id, + checked_in: false, + visit_expected: true, + ) + + # Without include_linked: only the parent's own guest is returned + body = JSON.parse(client.get("#{BOOKINGS_BASE}/#{parent.id}/guests", headers: headers).body).as_a + body.map(&.["email"]).should eq([parent_guest.email]) + + # With include_linked: guests from both parent and child are returned + body = JSON.parse(client.get("#{BOOKINGS_BASE}/#{parent.id}/guests?include_linked=true", headers: headers).body).as_a + emails = body.map(&.["email"].as_s).sort! + emails.should eq(["child-guest@example.com", "parent-guest@example.com"]) + end + describe "permission", tags: ["auth", "group-event"] do it "#add_attendee should NOT allow adding public or same tenant users to PRIVATE bookings" do WebMock.stub(:post, "#{ENV["PLACE_URI"]}/auth/oauth/token") From f32324ee228e2b8f8f78a48e33f498d017724a3f Mon Sep 17 00:00:00 2001 From: Mia Bennett Date: Fri, 15 May 2026 15:22:42 +0930 Subject: [PATCH 5/7] refactor(visitor_mailer): (PPT-2375) --- spec/controllers/bookings_spec.cr | 18 ++++++++++++++++++ src/controllers/bookings.cr | 9 ++++++++- 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/spec/controllers/bookings_spec.cr b/spec/controllers/bookings_spec.cr index d30ff1d9..079fda4f 100644 --- a/spec/controllers/bookings_spec.cr +++ b/spec/controllers/bookings_spec.cr @@ -1473,6 +1473,24 @@ describe Bookings do body = JSON.parse(client.get("#{BOOKINGS_BASE}/#{parent.id}/guests?include_linked=true", headers: headers).body).as_a emails = body.map(&.["email"].as_s).sort! emails.should eq(["child-guest@example.com", "parent-guest@example.com"]) + + # include_linked on a child booking should be silently ignored — + # only the child's own guests are returned + body = JSON.parse(client.get("#{BOOKINGS_BASE}/#{child.id}/guests?include_linked=true", headers: headers).body).as_a + body.map(&.["email"].as_s).should eq([child_guest.email]) + + # Deduplication: add the same guest (parent_guest) to the child booking. + # The API should return only one entry per email address. + Attendee.create!(booking_id: child.id.not_nil!, + guest_id: parent_guest.id, + tenant_id: parent_guest.tenant_id, + checked_in: false, + visit_expected: true, + ) + + body = JSON.parse(client.get("#{BOOKINGS_BASE}/#{parent.id}/guests?include_linked=true", headers: headers).body).as_a + emails = body.map(&.["email"].as_s).sort! + emails.should eq(["child-guest@example.com", "parent-guest@example.com"]) end describe "permission", tags: ["auth", "group-event"] do diff --git a/src/controllers/bookings.cr b/src/controllers/bookings.cr index 7eae1446..ad206c90 100644 --- a/src/controllers/bookings.cr +++ b/src/controllers/bookings.cr @@ -972,11 +972,18 @@ class Bookings < Application end if include_linked && booking.parent? - Booking.where(parent_id: booking.id).each do |child| + Booking.where(parent_id: booking.id) + .join(:left, Attendee, :booking_id) + .join(:left, Guest, "guests.id = attendees.guest_id") + .to_a.each do |child| child.attendees.to_a.each do |visitor| guests << visitor.guest.not_nil!.for_booking_to_h(visitor, child) end end + + # Deduplicate by email in case a guest appears on both the parent + # and a child booking + guests.uniq! { |guest| guest.email.downcase } end guests From 710a9caea2a3e726302bf021d8c2c5c0b30a6f13 Mon Sep 17 00:00:00 2001 From: Mia Bennett Date: Fri, 15 May 2026 15:31:07 +0930 Subject: [PATCH 6/7] test(ameba): reduce warnings --- .ameba.yml | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/.ameba.yml b/.ameba.yml index a661a83d..e336f5c0 100644 --- a/.ameba.yml +++ b/.ameba.yml @@ -54,8 +54,7 @@ Documentation/DocumentationAdmonition: Enabled: false Style/MultilineStringLiteral: - Excluded: - - spec/**/* + Enabled: false Lint/BeTruthy: Enabled: false @@ -68,3 +67,15 @@ Lint/SpecFilename: Lint/UselessAssign: Excluded: - spec/**/* + +Lint/WhitespaceAroundMacroExpression: + Enabled: false + +Style/PercentLiteralDelimiters: + Enabled: false + +Style/RedundantSelf: + Enabled: false + +Lint/SpecEqWithBoolOrNilLiteral: + Enabled: false From 00bdee9baaf6ce0ba60d50a1f3da7616d0df6fd2 Mon Sep 17 00:00:00 2001 From: Mia Bennett Date: Thu, 21 May 2026 13:38:13 +0930 Subject: [PATCH 7/7] fix(events): correctly signal on room only change (PPT-2375) --- src/controllers/events.cr | 29 ++++++++++++++++++++++------- 1 file changed, 22 insertions(+), 7 deletions(-) diff --git a/src/controllers/events.cr b/src/controllers/events.cr index 8f1721c4..b29b4eb0 100644 --- a/src/controllers/events.cr +++ b/src/controllers/events.cr @@ -648,6 +648,10 @@ class Events < Application # are we moving the event room? changing_room = system_id != (changes.system_id.presence || system_id) + # Captured before any mutation so the downstream `staff/event/changed` + # signal can report the actual prior room (the new room's meta is fresh + # and would otherwise have null previous_* fields). + previous_meta_for_signal : EventMetadata? = nil if changing_room raise Error::BadRequest.new("system_id must be present when changing room") unless new_system_id = changes.system_id @@ -655,6 +659,8 @@ class Events < Application new_sys_cal = new_system.email.presence.try &.downcase raise AC::Route::Param::ValueError.new("attempting to move location and system '#{new_system.name}' (#{new_system_id}) does not have a resource email address specified", "event.system_id") unless new_sys_cal + previous_meta_for_signal = system_id ? get_event_metadata(event, system_id) : nil + # NOTE:: we're allowing this now, the expected behaviour is to overwrite the metadata # Check this room isn't already invited # raise AC::Route::Param::ValueError.new("attempting to move location and system '#{new_system.name}' (#{new_system_id}) is already marked as a resource", "event.system_id") if existing_attendees.includes?(new_sys_cal) @@ -718,7 +724,7 @@ class Events < Application if permission = changes.permission meta.permission = permission end - notify_created_or_updated(:update, system, updated_event, meta, can_skip: false, is_host: true) + notify_created_or_updated(:update, system, updated_event, meta, can_skip: false, is_host: true, previous_meta: previous_meta_for_signal) # Grab the list of externals that might be attending if update_attendees || changing_room @@ -1975,7 +1981,7 @@ class Events < Application # NOTIFICATIONS # ========================== - def notify_created_or_updated(action, system, event, meta = nil, can_skip = true, is_host = true) + def notify_created_or_updated(action, system, event, meta = nil, can_skip = true, is_host = true, previous_meta : EventMetadata? = nil) raise Error::InconsistentState.new("event_start must be present on event") unless event_start = event.event_start raise Error::InconsistentState.new("event_end must be present on event") unless event_end = event.event_end @@ -1992,11 +1998,20 @@ class Events < Application meta = meta || EventMetadata.new - # Capture previous values for change detection in downstream consumers - previous_event_start = meta.event_start if meta.persisted? - previous_event_end = meta.event_end if meta.persisted? - previous_system_id = meta.system_id if meta.persisted? - previous_host_email = meta.host_email if meta.persisted? + # `previous_meta` (when supplied) represents a different prior record + # (e.g. the old room when an event is moved between systems) and takes + # precedence over the current `meta`. + if previous_meta + previous_event_start = previous_meta.event_start + previous_event_end = previous_meta.event_end + previous_system_id = previous_meta.system_id + previous_host_email = previous_meta.host_email + elsif meta.persisted? + previous_event_start = meta.event_start + previous_event_end = meta.event_end + previous_system_id = meta.system_id + previous_host_email = meta.host_email + end if !meta.persisted? meta.event_id = event.id.as(String)