diff --git a/drivers/place/public_events.cr b/drivers/place/public_events.cr index ef30882c6f..a20bb227d4 100644 --- a/drivers/place/public_events.cr +++ b/drivers/place/public_events.cr @@ -13,11 +13,37 @@ class Place::PublicEvents < PlaceOS::Driver accessor bookings : Bookings_1 accessor calendar : Calendar_1 + # the permission field lives in the staff API `EventMetadata` table, it is not + # part of a calendar event, so it can't be included in the Bookings cache + accessor staff_api : StaffAPI_1 + + alias Permission = PlaceOS::Model::EventMetadata::Permission + + # the number of event references we send to the staff API in a single request, + # this keeps the query string well below the HTTP request line size limit + REF_BATCH_SIZE = 50 + + default_settings({ + # how often we re-check the event metadata permissions + metadata_refresh_minutes: 5, + }) + @all_bookings : Array(PublicEvent) = [] of PublicEvent @public_event_ids : Set(String) = Set(String).new + @filter_mutex : Mutex = Mutex.new bind Bookings_1, :bookings, :on_bookings_change + def on_update + refresh_minutes = setting?(Int32, :metadata_refresh_minutes) || 5 + + # a permission can be changed without the event changing, and the Bookings + # driver only publishes `bookings` when the events have actually changed, + # so we can't rely on the subscription alone to keep the cache fresh + schedule.clear + schedule.every(refresh_minutes.minutes) { filter_and_cache } if refresh_minutes > 0 + end + private def on_bookings_change(_subscription, new_value : String) @all_bookings = Array(PublicEvent).from_json(new_value) filter_and_cache @@ -26,21 +52,108 @@ class Place::PublicEvents < PlaceOS::Driver end private def filter_and_cache : Array(PublicEvent) - logger.debug { "received #{@all_bookings.size} total events from bookings" } + @filter_mutex.synchronize do + events = @all_bookings + logger.debug { "received #{events.size} total events from bookings" } + + permissions = event_permissions(events) + + public_events = events.select do |event| + # a calendar event marked private has had its title and host masked by + # the Bookings driver, so there is nothing useful (or safe) to publish + permission_for(event, permissions).public? && !event.private? + end + + logger.debug { "#{public_events.size} events have PUBLIC permission" } - public_events = @all_bookings.select(&.permission.public?) + @public_event_ids = public_events.compact_map(&.id).to_set + self["public_events"] = public_events + public_events + end + end + + # Looks the metadata permission up in the staff API. + # Returns the instance level permissions and the recurring master permissions + # separately, so instance metadata can take precedence over the master. + private def event_permissions(events : Array(PublicEvent)) : Permissions + by_event = {} of String => EventMetadata + by_master = {} of String => EventMetadata + permissions = {by_event, by_master} + return permissions if events.empty? + + system_id = system.id + refs = events.flat_map { |event| [event.id, event.ical_uid, event.recurring_event_id] }.compact + refs.uniq! + return permissions if refs.empty? + + refs.each_slice(REF_BATCH_SIZE) do |batch| + metadata(system_id, batch).each do |meta| + logger.debug { "event metadata: #{meta.id} event_id=#{meta.event_id} ical_uid=#{meta.ical_uid} permission=#{meta.permission} ext_data=#{meta.ext_data? ? "present" : "null"} updated_at=#{meta.updated_at}" } + + prefer(by_event, meta.event_id, meta) + prefer(by_event, meta.ical_uid, meta) + + # only the metadata of the series master applies to the whole series, + # instances have their own metadata which also references the master + if (master_id = meta.recurring_master_id) && master_id == meta.event_id + prefer(by_master, master_id, meta) + if resource_master_id = meta.resource_master_id + prefer(by_master, resource_master_id, meta) + end + end + end + end + + permissions + end - logger.debug { "#{public_events.size} events have PUBLIC permission" } + # Adds `meta` to `map[key]` unless a more authoritative record is already + # stored there. + # + # The staff API can hold more than one metadata record for an event (a race + # between the event create route and the calendar webhook path inserts + # duplicates, the webhook copy has no `ext_data` and always defaults to + # PRIVATE). The same conflict is resolved by the staff API itself by + # preferring the record that has `ext_data`, so we mirror that and then + # fall back to the most recently written record. + private def prefer(map : Hash(String, EventMetadata), key : String, meta : EventMetadata) + return if key.empty? + + if (existing = map[key]?) && existing.supersedes?(meta) + if existing.permission != meta.permission + logger.warn { "ignoring event metadata #{meta.id} (#{meta.permission}) for #{key}, preferring #{existing.id} (#{existing.permission})" } + end + return + end + + if existing = map[key]? + logger.warn { "replacing event metadata #{existing.id} (#{existing.permission}) for #{key} with #{meta.id} (#{meta.permission})" } + end + + map[key] = meta + end - @public_event_ids = public_events.compact_map(&.id).to_set - self["public_events"] = public_events - public_events + private def metadata(system_id : String, event_ref : Array(String)) : Array(EventMetadata) + response = staff_api.query_metadata(system_id: system_id, event_ref: event_ref).get + Array(EventMetadata).from_json(response.to_json) + end + + private def permission_for(event : PublicEvent, permissions : Permissions) : Permission + by_event, by_master = permissions + meta = (by_event[event.id]? || by_event[event.ical_uid]? || by_master[event.recurring_event_id]?) + permission = meta.try(&.permission) || Permission::PRIVATE + logger.debug { "event #{event.id} permission=#{permission} (metadata #{meta.try(&.id)})" } + permission end # Forces a Bookings re-poll then re-applies the public filter. @[Security(Level::Administrator)] def update_public_events : Nil bookings.poll_events.get + + # the re-poll only publishes `bookings` if the events have changed, so we + # always re-apply the filter to pick up metadata permission changes + filter_and_cache end # Appends an external attendee to the calendar event. @@ -68,7 +181,39 @@ class Place::PublicEvents < PlaceOS::Driver true end - alias Permission = PlaceOS::Model::EventMetadata::Permission + alias Permissions = Tuple(Hash(String, EventMetadata), Hash(String, EventMetadata)) + + # The subset of the staff API event metadata we require. + # NOTE:: we don't use `PlaceOS::Model::EventMetadata` as it is a database + # backed model that renders linked bookings on serialisation. + private struct EventMetadata + include JSON::Serializable + + getter id : Int64? + getter event_id : String + getter ical_uid : String + getter recurring_master_id : String? + getter resource_master_id : String? + getter permission : Permission = Permission::PRIVATE + + @[JSON::Field(key: "ext_data")] + getter ext_data : JSON::Any? + + @[JSON::Field(converter: Time::EpochConverter, type: "integer", format: "Int64")] + getter updated_at : Time + + # true if this record should be preferred over `other` when they both + # resolve to the same event key + def supersedes?(other : EventMetadata) : Bool + return true if ext_data? && !other.ext_data? + return false if other.ext_data? && !ext_data? + updated_at > other.updated_at + end + + def ext_data? : Bool + !@ext_data.nil? + end + end # Fields that are safe to expose publicly. private struct PublicEvent @@ -83,7 +228,14 @@ class Place::PublicEvents < PlaceOS::Driver getter timezone : String? getter? all_day : Bool = false + # used for matching metadata and filtering, never exposed publicly @[JSON::Field(ignore_serialize: true)] - getter permission : Permission = Permission::PRIVATE + getter ical_uid : String? = nil + + @[JSON::Field(ignore_serialize: true)] + getter recurring_event_id : String? = nil + + @[JSON::Field(ignore_serialize: true)] + getter? private : Bool = false end end diff --git a/drivers/place/public_events_readme.md b/drivers/place/public_events_readme.md index f053d82b32..8e284a2c97 100644 --- a/drivers/place/public_events_readme.md +++ b/drivers/place/public_events_readme.md @@ -1,44 +1,96 @@ # Public Events Readme -Docs on the PlaceOS Public Events driver. -This driver filters the Bookings event cache down to publicly visible events and handles guest registration, enabling unauthenticated access to selected calendar events. +Docs on how to configure the PlaceOS Public Events driver. +This driver publishes the events that have been marked public in Concierge so that they can be read by people who have not signed in, and lets those people register to attend. -* Subscribes to the Bookings driver's `:bookings` status and filters events where `private` is `false` -* Caches the filtered set of public events (with a reduced set of safe fields) as the `:public_events` status -* Provides a `register_attendee` function for appending external (guest) attendees to a public event via the Calendar driver +* Publishes the public events from the system's calendar as the `public_events` status +* Exposes only a limited set of event fields, everything else is withheld +* Provides `register_attendee` so a guest can add themselves to a public event ## Requirements Requires the following drivers in the same system: -* Bookings - for the room/calendar event cache and polling -* Calendar - for reading and updating calendar events when registering attendees +* Bookings - reads the events on the system's calendar +* Calendar - adds guests to an event when they register +* StaffAPI - reads the publish state of each event -The system must also have a calendar email configured (used as the `calendar_id` when calling the Calendar driver). +**CRITICAL:** the system must have its **calendar email** configured. Without it the driver cannot add guests to events and every registration attempt will fail. -## How It Works +## Publishing an Event -1. The Bookings driver polls the calendar and publishes all events to its `:bookings` status -2. PublicEvents receives the update via the subscription binding and filters to non-private events (`private == false`) -3. The filtered events are stored in `:public_events` with only safe, non-sensitive fields exposed: `id`, `title`, `body`, `event_start`, `event_end`, `location`, `timezone`, `all_day` -4. When a guest registers, `register_attendee` checks the event is in the public set, fetches it from the Calendar driver, appends the attendee, and writes it back +Whether an event appears publicly is controlled from the **Concierge UI**, on the event itself. It is not controlled by this driver and it is not a calendar setting. +| Concierge option | Published? | +| --- | --- | +| Publish (Public) | **Yes** | +| Publish (Internal) | No | +| Draft | No | +| Nothing set | No | -## Public System Usage +Only "Publish (Public)" is treated as public. "Publish (Internal)" makes an event joinable by people signed in to your own tenant, which is not safe to hand out to anonymous visitors, so it is deliberately excluded. -This driver is intended to be placed in the same system as the public events calendar. It follows the same public system access pattern as the WebRTC driver — a Guest JWT is issued to the caller after passing the invisible Google reCAPTCHA, granting read access to the `:public_events` status and the ability to call `register_attendee`. +Two further rules apply: + +* An event marked **Private** on the calendar is never published, even if it is set to "Publish (Public)". Its title and host have already been hidden, so there is nothing useful or safe left to show. +* For a **recurring event**, publishing a single occurrence publishes only that occurrence. Publish the series itself if you want the whole series to appear. + +Publishing and unpublishing take up to `metadata_refresh_minutes` (5 minutes by default) to appear. Call `update_public_events` if you need the change applied immediately. + + +## Settings + +```yaml +# how often the driver re-checks which events are published, in minutes +# set to 0 to disable, publish changes will then only be picked up when the +# calendar itself changes, which can leave the public list out of date +metadata_refresh_minutes: 5 +``` + + +## What Gets Published + +Only the following fields of a public event are exposed: + +* `id` +* `title` +* `body` +* `event_start` +* `event_end` +* `location` +* `timezone` +* `all_day` + +Attendees, the organiser, and every other event detail are never exposed. + +The title and body are readable by anyone, including people who have not signed in. Organisers should be reminded not to put internal or sensitive detail in the description of an event they intend to publish. + + +## Public Access + +This driver is intended to be placed in the same system as the public events calendar. + +Callers who have not signed in can: + +* read the `public_events` status +* call `register_attendee` + +`update_public_events` is administrator-only and is not available to those callers. ## Functions ### `register_attendee(event_id, name, email) : Bool` -Appends an external attendee to a public calendar event. +Adds a guest to a public calendar event as an attendee. + +Returns `true` on success. Returns `false` if: -* Returns `true` on success -* Returns `false` if the `event_id` is not in the public events set, or if the system has no calendar email configured +* the `event_id` is not a currently published event +* the system has no calendar email configured +* the event no longer exists on the calendar ```yaml # Example call @@ -51,4 +103,15 @@ args: ### `update_public_events : Nil` -Administrator-only. Triggers a Bookings re-poll and repopulates the public events cache via the subscription binding. \ No newline at end of file +Administrator-only. Re-reads the calendar and refreshes the published list straight away, rather than waiting for the next scheduled refresh. Use it after publishing or unpublishing an event. + + +## Troubleshooting + +| Symptom | Check | +| --- | --- | +| An event is missing from `public_events` | It is set to "Publish (Public)" in Concierge, not "Publish (Internal)" or "Draft". It is not marked private on the calendar. It is on this system's calendar. Up to 5 minutes may not have passed yet, run `update_public_events` to apply the change now. | +| A recurring event only shows one occurrence | Only that occurrence has been published. Publish the series to show all of them. | +| A recurring event shows no occurrences | The series has not been published, publishing an occurrence does not publish the series. | +| `register_attendee` returns `false` | The event is not currently published, the system has no calendar email configured, or the event has since been deleted from the calendar. | +| `public_events` is always empty | Confirm the Bookings, Calendar and StaffAPI drivers are all present in this system, and that the system's calendar actually has published events on it. | diff --git a/drivers/place/public_events_spec.cr b/drivers/place/public_events_spec.cr index e1512df3a6..f6f26aed6d 100644 --- a/drivers/place/public_events_spec.cr +++ b/drivers/place/public_events_spec.cr @@ -5,6 +5,7 @@ DriverSpecs.mock_driver "Place::PublicEvents" do system({ Bookings: {BookingsMock}, Calendar: {CalendarMock}, + StaffAPI: {StaffAPIMock}, }) # BookingsMock publishes its events in on_load, which triggers the @@ -12,46 +13,95 @@ DriverSpecs.mock_driver "Place::PublicEvents" do sleep 200.milliseconds # ----------------------------------------------------------------------- - # Test 1: subscription populates the public events cache automatically + # Test 1: subscription populates the public events cache automatically. + # + # evt-public-1 has a duplicate metadata pair: the real PUBLIC record with + # ext_data, plus a newer PRIVATE webhook copy without ext_data. The record + # with ext_data must win (mirroring the staff API's own resolution), so the + # event still appears. # ----------------------------------------------------------------------- events = status[:public_events].as_a - events.size.should eq(1) - events[0]["id"].as_s.should eq("evt-public-1") + event_ids = events.map { |event| event["id"].as_s } + event_ids.should eq(["evt-public-1", "evt-series-instance", "evt-series-public-instance"]) events[0]["title"].as_s.should eq("Public Conference") # ----------------------------------------------------------------------- - # Test 2: private events are excluded + # Test 2: when every duplicate record carries ext_data, the most recently + # written one wins (evt-private-meta: older PUBLIC + newer PRIVATE, both with + # ext_data, so the event must stay excluded). # ----------------------------------------------------------------------- - events.none? { |e| e["id"].as_s == "evt-private-no-ext" }.should be_true + event_ids.should_not contain("evt-private-meta") # ----------------------------------------------------------------------- - # Test 3: events explicitly marked private are also excluded + # Test 3: the permission field is not in the Bookings payload, so it has + # to be fetched from the staff API # ----------------------------------------------------------------------- - events.none? { |e| e["id"].as_s == "evt-private-explicit" }.should be_true + queried = system(:StaffAPI)[:queried_refs].as_a.map(&.as_s) + queried.size.should eq(queried.uniq.size) + queried.should contain("evt-public-1") + queried.should contain("uid-public-1") + queried.should contain("evt-series-master") # ----------------------------------------------------------------------- - # Test 4: only allowlisted fields are present in the public cache + # Test 4: events without PUBLIC metadata permission are excluded + # ----------------------------------------------------------------------- + # metadata says open (tenant users only, not the public) + event_ids.should_not contain("evt-open-meta") + # no metadata at all, defaults to private + event_ids.should_not contain("evt-no-meta") + + # ----------------------------------------------------------------------- + # Test 5: instance metadata takes precedence over the recurring master + # ----------------------------------------------------------------------- + # the master is PUBLIC but this instance has its own PRIVATE metadata + event_ids.should_not contain("evt-series-instance-private") + # a sibling instance being PUBLIC must not make the whole series public + event_ids.should_not contain("evt-series-sibling") + + # ----------------------------------------------------------------------- + # Test 6: calendar private events are excluded, even when marked PUBLIC + # (the Bookings driver has already masked the title and host) + # ----------------------------------------------------------------------- + event_ids.should_not contain("evt-public-but-private-cal") + + # ----------------------------------------------------------------------- + # Test 7: only allowlisted fields are present in the public cache # ----------------------------------------------------------------------- events[0]["event_start"].as_i64.should be > 0_i64 events[0]["event_end"].as_i64.should be > 0_i64 + events[0]["body"]?.should_not be_nil events[0]["attendees"]?.should be_nil events[0]["host"]?.should be_nil - events[0]["body"]?.should_not be_nil events[0]["online_meeting_url"]?.should be_nil events[0]["creator"]?.should be_nil + events[0]["private"]?.should be_nil + events[0]["permission"]?.should be_nil + events[0]["ical_uid"]?.should be_nil + events[0]["recurring_event_id"]?.should be_nil # ----------------------------------------------------------------------- - # Test 5: update_public_events triggers a Bookings re-poll and returns nil; - # the cache is repopulated via the :bookings subscription binding. + # Test 8: update_public_events triggers a Bookings re-poll and re-checks the + # metadata permissions. + # + # A permission can change without the events changing, and the Bookings + # driver only publishes `bookings` when the value has changed, so the filter + # must be re-applied regardless of the subscription firing. + # StaffAPIMock marks `evt-no-meta` as PUBLIC (a record with no ext_data, + # created outside Concierge) from the second query onwards. # ----------------------------------------------------------------------- + system(:StaffAPI)[:query_count].as_i.should eq(1) + exec(:update_public_events).get sleep 200.milliseconds + + system(:StaffAPI)[:query_count].as_i.should eq(2) updated_events = status[:public_events].as_a - updated_events.size.should eq(1) - updated_events[0]["id"].as_s.should eq("evt-public-1") + updated_events.map { |event| event["id"].as_s }.should eq([ + "evt-public-1", "evt-no-meta", "evt-series-instance", "evt-series-public-instance", + ]) # ----------------------------------------------------------------------- - # Test 6: register_attendee appends the guest via the Calendar driver + # Test 9: register_attendee appends the guest via the Calendar driver # ----------------------------------------------------------------------- exec(:register_attendee, "evt-public-1", "Alice Smith", "alice@external.com").get.should be_true @@ -60,9 +110,9 @@ DriverSpecs.mock_driver "Place::PublicEvents" do attendees.any? { |a| a["name"].as_s == "Alice Smith" }.should be_true # ----------------------------------------------------------------------- - # Test 7: register_attendee returns false for unknown event IDs + # Test 10: register_attendee returns false for events that are not public # ----------------------------------------------------------------------- - exec(:register_attendee, "evt-private-no-ext", "Bob", "bob@example.com").get.should be_false + exec(:register_attendee, "evt-private-meta", "Bob", "bob@example.com").get.should be_false # Calendar must not have been called again — updated_attendees unchanged system(:Calendar)[:updated_attendees].as_a @@ -71,16 +121,62 @@ DriverSpecs.mock_driver "Place::PublicEvents" do end # :nodoc: -# Simulates the Bookings driver. Publishes a fixed set of three events on load -# so the PublicEvents driver's subscription fires immediately: -# - one non-private event (should appear in the cache) -# - two private events (should be excluded) +# A staff API event metadata record, as returned by `query_metadata` +struct MetadataFixture + include JSON::Serializable + + getter id : Int64? + getter event_id : String + getter ical_uid : String + getter recurring_master_id : String? + getter resource_master_id : String? + getter permission : String + getter ext_data : JSON::Any? + getter updated_at : Int64 + + def initialize( + @event_id, + @ical_uid, + @permission, + @id = nil, + @recurring_master_id = nil, + @resource_master_id = nil, + @ext_data = nil, + @updated_at = 0_i64, + ) + end + + # mirrors the staff API `by_events_or_master_ids` lookup + def matches?(refs : Array(String)) : Bool + return true if refs.includes?(event_id) || refs.includes?(ical_uid) + return true if (master = recurring_master_id) && refs.includes?(master) + return true if (master = resource_master_id) && refs.includes?(master) + false + end +end + +# :nodoc: +# Simulates the Bookings driver. Publishes a fixed set of events on load +# so the PublicEvents driver's subscription fires immediately. class BookingsMock < DriverSpecs::MockDriver def on_load + self[:bookings] = events + end + + def poll_events : Nil + # Re-publish the current bookings to exercise the subscription path. + # NOTE:: the payload is stable, so a re-poll will not publish a change + self[:bookings] = events + end + + # built once so that re-polling doesn't change the payload + private getter events : Array(PlaceCalendar::Event) do now = Time.utc - self[:bookings] = [ + [ + # metadata permission PUBLIC, should appear in the cache PlaceCalendar::Event.new( id: "evt-public-1", + ical_uid: "uid-public-1", host: "organizer@company.com", title: "Public Conference", event_start: now + 1.day, @@ -88,28 +184,155 @@ class BookingsMock < DriverSpecs::MockDriver body: "Join us for the annual public conference.", attendees: [PlaceCalendar::Event::Attendee.new(name: "Internal Person", email: "internal@company.com")], ), + # metadata permission PRIVATE PlaceCalendar::Event.new( - id: "evt-private-no-ext", + id: "evt-private-meta", + ical_uid: "uid-private-meta", host: "team@company.com", title: "Internal Meeting", event_start: now + 2.days, event_end: now + 2.days + 1.hour, - private: true, ), + # metadata permission OPEN (tenant users only) + PlaceCalendar::Event.new( + id: "evt-open-meta", + ical_uid: "uid-open-meta", + host: "team@company.com", + title: "Lunch and Learn", + event_start: now + 2.days, + event_end: now + 2.days + 1.hour, + ), + # no metadata record exists for this event PlaceCalendar::Event.new( - id: "evt-private-explicit", + id: "evt-no-meta", + ical_uid: "uid-no-meta", host: "exec@company.com", title: "Executive Briefing", event_start: now + 3.days, event_end: now + 3.days + 1.hour, + ), + # inherits the PUBLIC permission of the recurring master metadata + PlaceCalendar::Event.new( + id: "evt-series-instance", + ical_uid: "uid-series-instance", + recurring_event_id: "evt-series-master", + host: "organizer@company.com", + title: "Weekly Public Tour", + event_start: now + 4.days, + event_end: now + 4.days + 1.hour, + ), + # the master is PUBLIC, however this instance has its own PRIVATE metadata + PlaceCalendar::Event.new( + id: "evt-series-instance-private", + ical_uid: "uid-series-instance-private", + recurring_event_id: "evt-series-master", + host: "organizer@company.com", + title: "Weekly Public Tour (cancelled to the public)", + event_start: now + 11.days, + event_end: now + 11.days + 1.hour, + ), + # this instance is PUBLIC, its siblings are not + PlaceCalendar::Event.new( + id: "evt-series-public-instance", + ical_uid: "uid-series-public-instance", + recurring_event_id: "evt-series-master-2", + host: "organizer@company.com", + title: "Weekly Standup (open day)", + event_start: now + 5.days, + event_end: now + 5.days + 1.hour, + ), + PlaceCalendar::Event.new( + id: "evt-series-sibling", + ical_uid: "uid-series-sibling", + recurring_event_id: "evt-series-master-2", + host: "organizer@company.com", + title: "Weekly Standup", + event_start: now + 12.days, + event_end: now + 12.days + 1.hour, + ), + # marked PUBLIC, but private on the calendar so title / host are masked + PlaceCalendar::Event.new( + id: "evt-public-but-private-cal", + ical_uid: "uid-public-but-private-cal", + host: "Private", + title: "Private", + event_start: now + 6.days, + event_end: now + 6.days + 1.hour, private: true, ), ] end +end - def poll_events : Nil - # Re-publish current bookings to exercise the subscription path. - on_load +# :nodoc: +# Simulates the staff API driver, returning event metadata for the requested +# event references. Note: the recurring master metadata is the record where +# `recurring_master_id == event_id`. +class StaffAPIMock < DriverSpecs::MockDriver + EXT = JSON.parse(%({"view_access": "PUBLIC"})) + + METADATA = [ + # evt-public-1 has a duplicate pair (a race between the event create route + # and the calendar webhook). The record with `ext_data` is the real one; + # the webhook copy has none and defaults to PRIVATE even though it is the + # most recently updated. + MetadataFixture.new("evt-public-1", "uid-public-1", "public", + id: 11, ext_data: EXT, updated_at: 1_787_628_000_i64), + MetadataFixture.new("evt-public-1", "uid-public-1", "private", + id: 12, ext_data: nil, updated_at: 1_787_630_000_i64), + + # evt-private-meta also has a duplicate pair, but both records carry + # `ext_data`, so the most recently updated one wins. + MetadataFixture.new("evt-private-meta", "uid-private-meta", "public", + id: 21, ext_data: EXT, updated_at: 1_787_628_000_i64), + MetadataFixture.new("evt-private-meta", "uid-private-meta", "private", + id: 22, ext_data: EXT, updated_at: 1_787_629_000_i64), + + # metadata permission OPEN (tenant users only, not the public) + MetadataFixture.new("evt-open-meta", "uid-open-meta", "open", + id: 30, ext_data: JSON.parse(%({"view_access": "OPEN"})), updated_at: 1_787_628_000_i64), + + MetadataFixture.new("evt-series-master", "uid-series-master", "public", + id: 40, recurring_master_id: "evt-series-master", resource_master_id: "res-series-master", + ext_data: EXT, updated_at: 1_787_628_000_i64), + + MetadataFixture.new("evt-series-instance-private", "uid-series-instance-private", "private", + id: 41, recurring_master_id: "evt-series-master", + ext_data: EXT, updated_at: 1_787_628_000_i64), + + MetadataFixture.new("evt-series-public-instance", "uid-series-public-instance", "public", + id: 42, recurring_master_id: "evt-series-master-2", + ext_data: EXT, updated_at: 1_787_628_000_i64), + + MetadataFixture.new("evt-public-but-private-cal", "uid-public-but-private-cal", "public", + id: 50, ext_data: EXT, updated_at: 1_787_628_000_i64), + ] + + # simulates someone marking `evt-no-meta` as public after the initial lookup. + # Note the record has no `ext_data` - events created outside Concierge may + # have no extension data at all, so it must still be honoured. + LATE_METADATA = MetadataFixture.new("evt-no-meta", "uid-no-meta", "public", + id: 60, ext_data: nil, updated_at: 1_787_628_001_i64) + + @queries : Int32 = 0 + + def query_metadata( + period_start : Int64? = nil, + period_end : Int64? = nil, + field_name : String? = nil, + value : String? = nil, + system_id : String? = nil, + event_ref : Array(String)? = nil, + ) : Array(MetadataFixture) + refs = event_ref || [] of String + @queries += 1 + self[:query_count] = @queries + self[:queried_system_id] = system_id + self[:queried_refs] = refs + return [] of MetadataFixture if refs.empty? + + metadata = @queries > 1 ? METADATA + [LATE_METADATA] : METADATA + metadata.select &.matches?(refs) end end