From 635d0a1b4295f1132bf84c6326c68f8815a089b4 Mon Sep 17 00:00:00 2001 From: Cameron Reeves Date: Thu, 8 Oct 2026 09:55:54 +1100 Subject: [PATCH] feat(place/demo/calendar): in-memory calendar for demo and e2e systems A stand-in for Place::Calendar with the same method signatures, so Place::Bookings runs against it unchanged on a system that has no Google or Microsoft tenant behind it. Events are kept in the module for as long as it runs, held on the organiser's calendar and every attendee's, and nothing reaches a mailbox. Used by the user-interfaces e2e stack, which now runs core, so the workplace availability panel and room check-in have a live Bookings status to bind. --- drivers/place/demo/calendar.cr | 242 ++++++++++++++++++++++++++++ drivers/place/demo/calendar_spec.cr | 61 +++++++ 2 files changed, 303 insertions(+) create mode 100644 drivers/place/demo/calendar.cr create mode 100644 drivers/place/demo/calendar_spec.cr diff --git a/drivers/place/demo/calendar.cr b/drivers/place/demo/calendar.cr new file mode 100644 index 00000000000..1026448a80f --- /dev/null +++ b/drivers/place/demo/calendar.cr @@ -0,0 +1,242 @@ +require "placeos-driver" +require "place_calendar" +require "uuid" + +# An in-memory calendar with the interface of `Place::Calendar`, for systems +# that have no Google or Microsoft tenant behind them: demo instances and end +# to end test stacks. `Place::Bookings` runs against it unchanged. +# +# Events exist only while the module runs. Nothing is sent to a mailbox. +class Place::Demo::Calendar < PlaceOS::Driver + descriptive_name "Demo Calendar" + generic_name :Calendar + description %(an in-memory stand-in for the PlaceOS Calendar driver. Events \ +are kept in the module and nothing reaches a real mailbox. For demo systems \ +and end to end testing.) + + default_settings({ + # the directory `list_users` and `get_user` answer from + users: [ + {name: "Demo User", email: "demo.user@example.com"}, + ], + }) + + alias Event = ::PlaceCalendar::Event + alias Attendee = ::PlaceCalendar::Event::Attendee + alias User = ::PlaceCalendar::User + + # calendar id (lower case) => event id => event + @calendars = {} of String => Hash(String, Event) + @users = [] of User + + def on_update + @users = setting?(Array(User), :users) || [] of User + publish_counts + end + + # --------------------------------------------------------------------------- + # Directory + + @[PlaceOS::Driver::Security(Level::Support)] + def list_users( + query : String? = nil, + limit : Int32? = nil, + filter : String? = nil, + next_page : String? = nil, + additional_fields : Array(String)? = nil, + ) + users = @users + if term = query.presence.try(&.downcase) + users = users.select do |user| + user.name.to_s.downcase.includes?(term) || user.email.to_s.downcase.includes?(term) + end + end + limit ? users.first(limit) : users + end + + @[PlaceOS::Driver::Security(Level::Support)] + def get_user(user_id : String, additional_fields : Array(String)? = nil) + id = user_id.downcase + @users.find { |user| user.email.to_s.downcase == id || user.id == user_id } + end + + @[PlaceOS::Driver::Security(Level::Support)] + def list_calendars(user_id : String) + [::PlaceCalendar::Calendar.new(user_id.downcase, user_id, primary: true, can_edit: true)] + end + + # --------------------------------------------------------------------------- + # Events + + @[PlaceOS::Driver::Security(Level::Support)] + def list_events( + calendar_id : String, + period_start : Int64, + period_end : Int64, + time_zone : String? = nil, + user_id : String? = nil, + include_cancelled : Bool = false, + ical_uid : String? = nil, + ) + starting = Time.unix(period_start) + ending = Time.unix(period_end) + + events = stored_on(calendar_id).values.select do |event| + next false if !include_cancelled && event.status == "cancelled" + next false if ical_uid && event.ical_uid != ical_uid + event.event_start < ending && ends_at(event) > starting + end + events.sort_by(&.event_start) + end + + @[PlaceOS::Driver::Security(Level::Support)] + def get_event(calendar_id : String, event_id : String, user_id : String? = nil) + stored_on(calendar_id)[event_id]? + end + + @[PlaceOS::Driver::Security(Level::Support)] + def create_event( + title : String, + event_start : Int64, + event_end : Int64? = nil, + description : String = "", + attendees : Array(Attendee) = [] of Attendee, + location : String? = nil, + timezone : String? = nil, + user_id : String? = nil, + calendar_id : String? = nil, + online_meeting_id : String? = nil, + online_meeting_provider : String? = nil, + online_meeting_url : String? = nil, + online_meeting_sip : String? = nil, + online_meeting_phones : Array(String)? = nil, + online_meeting_pin : String? = nil, + ) + user_id = (user_id || calendar_id).not_nil! + calendar_id = calendar_id || user_id + now = Time.utc + tz = timezone ? Time::Location.load(timezone) : Time::Location::UTC + + event = Event.new( + id: UUID.random.to_s, + host: calendar_id, + title: title, + body: description, + location: location, + timezone: timezone, + attendees: attendees, + online_meeting_id: online_meeting_id, + online_meeting_url: online_meeting_url, + online_meeting_sip: online_meeting_sip, + online_meeting_pin: online_meeting_pin, + online_meeting_phones: online_meeting_phones, + online_meeting_provider: online_meeting_provider, + ) + event.event_start = Time.unix(event_start).in(tz) + event.event_end = Time.unix(event_end).in(tz) if event_end + event.all_day = event_end.nil? + event.status = "confirmed" + event.creator = user_id + event.ical_uid = UUID.random.to_s + event.created = now + event.updated = now + + store(event, calendar_id) + event + end + + @[PlaceOS::Driver::Security(Level::Support)] + def update_event(event : Event, user_id : String? = nil, calendar_id : String? = nil) + id = event.id || raise "event has no id" + holders = calendars_holding(id) + raise "event #{id} not found" if holders.empty? + + event.updated = Time.utc + holders.each { |cal| @calendars[cal][id] = event } + publish_counts + event + end + + @[PlaceOS::Driver::Security(Level::Support)] + def delete_event(calendar_id : String, event_id : String, user_id : String? = nil, notify : Bool = false, comment : String? = nil) + calendars_holding(event_id).each { |cal| @calendars[cal].delete(event_id) } + publish_counts + nil + end + + # Removes the event from this calendar only, as a declined invitation leaves + # the organiser's copy in place. + @[PlaceOS::Driver::Security(Level::Support)] + def decline_event(calendar_id : String, event_id : String, user_id : String? = nil, notify : Bool = false, comment : String? = nil) + events_on(calendar_id).delete(event_id) + respond(calendar_id, event_id, "declined") + publish_counts + nil + end + + @[PlaceOS::Driver::Security(Level::Support)] + def accept_event(calendar_id : String, event_id : String, user_id : String? = nil, notify : Bool = false, comment : String? = nil) + respond(calendar_id, event_id, "accepted") + nil + end + + @[PlaceOS::Driver::Security(Level::Support)] + def clear_events(calendar_id : String? = nil) + if calendar_id + @calendars.delete(calendar_id.downcase) + else + @calendars.clear + end + publish_counts + nil + end + + def calendar_service_name + "demo" + end + + # --------------------------------------------------------------------------- + + protected def events_on(calendar_id : String) : Hash(String, Event) + @calendars[calendar_id.downcase] ||= {} of String => Event + end + + EMPTY = {} of String => Event + + protected def stored_on(calendar_id : String) : Hash(String, Event) + @calendars[calendar_id.downcase]? || EMPTY + end + + protected def ends_at(event : Event) : Time + event.event_end || event.event_start + 1.day + end + + protected def calendars_holding(event_id : String) : Array(String) + @calendars.compact_map { |cal, events| cal if events.has_key?(event_id) } + end + + # The organiser's calendar and every attendee's calendar hold the same event, + # as they do after a real invitation is sent. + protected def store(event : Event, calendar_id : String) + id = event.id.not_nil! + events_on(calendar_id)[id] = event + event.attendees.each { |attendee| events_on(attendee.email)[id] = event } + publish_counts + end + + # `Attendee` is a struct, so the list is rebuilt rather than edited in place. + protected def respond(calendar_id : String, event_id : String, response : String) + email = calendar_id.downcase + calendars_holding(event_id).each do |cal| + event = @calendars[cal][event_id] + event.attendees = event.attendees.map do |attendee| + next attendee unless attendee.email.downcase == email + Attendee.new(attendee.name, attendee.email, response, attendee.resource, attendee.organizer) + end + end + end + + protected def publish_counts + self[:calendars] = @calendars.transform_values(&.size) + end +end diff --git a/drivers/place/demo/calendar_spec.cr b/drivers/place/demo/calendar_spec.cr new file mode 100644 index 00000000000..6c84630808f --- /dev/null +++ b/drivers/place/demo/calendar_spec.cr @@ -0,0 +1,61 @@ +require "placeos-driver/spec" + +DriverSpecs.mock_driver "Place::Demo::Calendar" do + now = Time.utc.to_unix + day = now + 86_400 + room = "room@example.com" + host = "host@example.com" + + # nothing booked yet + exec(:list_events, room, now, day).get.as_a.should be_empty + + created = exec(:create_event, + title: "Standup", + event_start: now + 3600, + event_end: now + 5400, + calendar_id: room, + attendees: [{name: "Host", email: host}, {name: "Room", email: room, resource: true}], + ).get.not_nil! + id = created["id"].as_s + created["event_start"].as_i64.should eq(now + 3600) + created["status"].as_s.should eq("confirmed") + created["private"].as_bool.should eq(false) + + # the room and the attendee both see it, and only inside the window asked for + events = exec(:list_events, room, now, day).get.as_a + events.size.should eq(1) + events[0]["title"].as_s.should eq("Standup") + exec(:list_events, host, now, day).get.as_a.size.should eq(1) + exec(:list_events, room, now + 7200, day).get.as_a.should be_empty + exec(:list_events, "other@example.com", now, day).get.as_a.should be_empty + + exec(:get_event, room, id).get.not_nil!["id"].as_s.should eq(id) + exec(:get_event, room, "missing").get.should eq(nil) + + status[:calendars][room].as_i.should eq(1) + status[:calendars][host].as_i.should eq(1) + + # an updated event replaces the stored one everywhere it is held + updated = created.as_h.dup + updated["title"] = JSON::Any.new("Standup (moved)") + exec(:update_event, event: updated).get + exec(:list_events, host, now, day).get.as_a[0]["title"].as_s.should eq("Standup (moved)") + + # declining removes the room's copy and records the response on the host's + exec(:decline_event, room, id).get + exec(:list_events, room, now, day).get.as_a.should be_empty + remaining = exec(:list_events, host, now, day).get.as_a + remaining.size.should eq(1) + responses = remaining[0]["attendees"].as_a.map { |a| {a["email"].as_s, a["response_status"]?.try(&.as_s?)} } + responses.should eq([{host, nil}, {room, "declined"}]) + + # deleting removes every copy + exec(:delete_event, host, id).get + exec(:list_events, host, now, day).get.as_a.should be_empty + + # the directory comes from settings + exec(:list_users, "demo").get.as_a.size.should eq(1) + exec(:list_users, "nobody").get.as_a.should be_empty + exec(:get_user, "demo.user@example.com").get.not_nil!["name"].as_s.should eq("Demo User") + exec(:get_user, "nobody@example.com").get.should eq(nil) +end