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
168 changes: 160 additions & 8 deletions drivers/place/public_events.cr
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.
Expand Down Expand Up @@ -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
Expand All @@ -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
101 changes: 82 additions & 19 deletions drivers/place/public_events_readme.md
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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.
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. |
Loading
Loading