diff --git a/OPENAPI_DOC.yml b/OPENAPI_DOC.yml index df2bf053..741d9ad8 100644 --- a/OPENAPI_DOC.yml +++ b/OPENAPI_DOC.yml @@ -4715,7 +4715,31 @@ paths: set to the current rooms associated system. - Then in the event body, the `system_id` field should be the new system.' + Then in the event body, the `system_id` field should be the new system. + + + Changing `host` hands the meeting to that person. Office365 cannot change + a + + meeting''s organiser, so this does what a person does in Outlook: the meeting + + is cancelled and sent again from the new host''s calendar, which re-invites + + the attendees and gives the event a new id and ical uid. Recurring meetings + + are refused. A `host` matching the requesting user is ignored, as front ends + + fall back to the current user when they cannot resolve the organiser. + + + `extension_data.host_override` instead records who is hosting while leaving + + the meeting on the organiser''s calendar — for deployments that book rooms + as + + a service account. Send an empty string to clear it. Moving the meeting + + supersedes it, as the new host owns the meeting afterwards.' tags: - Events operationId: Events_update @@ -4960,7 +4984,31 @@ paths: set to the current rooms associated system. - Then in the event body, the `system_id` field should be the new system.' + Then in the event body, the `system_id` field should be the new system. + + + Changing `host` hands the meeting to that person. Office365 cannot change + a + + meeting''s organiser, so this does what a person does in Outlook: the meeting + + is cancelled and sent again from the new host''s calendar, which re-invites + + the attendees and gives the event a new id and ical uid. Recurring meetings + + are refused. A `host` matching the requesting user is ignored, as front ends + + fall back to the current user when they cannot resolve the organiser. + + + `extension_data.host_override` instead records who is hosting while leaving + + the meeting on the organiser''s calendar — for deployments that book rooms + as + + a service account. Send an empty string to clear it. Moving the meeting + + supersedes it, as the new host owns the meeting afterwards.' tags: - Events operationId: Events_update{2} diff --git a/spec/controllers/events_spec.cr b/spec/controllers/events_spec.cr index 60533aba..a8f47113 100644 --- a/spec/controllers/events_spec.cr +++ b/spec/controllers/events_spec.cr @@ -1729,6 +1729,336 @@ describe Events, tags: ["event"] do end end + # Office365 will not let the organiser of a meeting change, so a host + # reassignment made in the front end is recorded against the event metadata + # instead. Both that and an organiser change reported by Office365 have to + # surface as a host change on the signal (PPT-2375). + describe "host reassignment", tags: "PPT-2375" do + system_id = "sys-rJQQlR4Cn7" + organiser = "dev@acaprojects.onmicrosoft.com" + # an attendee of the event, used where the requester must not be the host + requester = "jon@example.com" + + # Creates an event and returns {event_id, staff/event/changed bodies, + # staff/guest/attending bodies, bodies PATCHed upstream}. Everything is + # captured from here on; WebMock matches the first stub registered, so these + # have to go in ahead of the shared ones. + create_event = ->(one_off : Bool) do + WebMock.reset + changed_bodies = [] of String + attending_bodies = [] of String + patch_bodies = [] of String + WebMock.stub(:post, "#{ENV["PLACE_URI"]}/api/engine/v2/signal?channel=staff/event/changed") + .to_return do |request| + changed_bodies << (request.body.try(&.gets_to_end) || "") + HTTP::Client::Response.new(200, body: "") + end + WebMock.stub(:post, "#{ENV["PLACE_URI"]}/api/engine/v2/signal?channel=staff/guest/attending") + .to_return do |request| + attending_bodies << (request.body.try(&.gets_to_end) || "") + HTTP::Client::Response.new(200, body: "") + end + WebMock.stub(:patch, "https://graph.microsoft.com/v1.0/users/dev%40acaprojects.onmicrosoft.com/calendar/events/AAMkADE3YmQxMGQ2LTRmZDgtNDljYy1hNDg1LWM0NzFmMGI0ZTQ3YgBGAAAAAADFYQb3DJ_xSJHh14kbXHWhBwB08dwEuoS_QYSBDzuv558sAAAAAAENAAB08dwEuoS_QYSBDzuv558sAACGVOwUAAA%3D") + .to_return do |request| + patch_bodies << (request.body.try(&.gets_to_end) || "") + HTTP::Client::Response.new(200, body: File.read("./spec/fixtures/events/o365/update.json")) + end + # the shared fixtures are a recurring series, which cannot be moved + EventsHelper.stub_one_off_event if one_off + EventsHelper.stub_event_tokens + EventsHelper.stub_update_endpoints + EventsHelper.stub_permissions_check(system_id) + + created = JSON.parse(client.post(EVENTS_BASE, headers: headers, body: EventsHelper.create_event_input).body).as_h + event_id = created["id"].to_s + EventsHelper.stub_room_event_query(event_id) + + changed_bodies.clear + attending_bodies.clear + patch_bodies.clear + {event_id, changed_bodies, attending_bodies, patch_bodies} + end + + it "reports extension_data.host_override as the host" do + event_id, changed_bodies, _, _ = create_event.call(false) + + new_host = "new-host@example.com" + resp = client.patch("#{EVENTS_BASE}/#{event_id}?system_id=#{system_id}", headers: headers, + body: EventsHelper.reassign_host_input(host_override: new_host)) + resp.status_code.should eq(200) + sleep 100.milliseconds + + payload = JSON.parse(changed_bodies.last) + payload["host"].as_s.should eq new_host + payload["previous_host_email"].as_s.should eq organiser + # the mailbox the event actually lives on is still reported + payload["organiser_email"].as_s.should eq organiser + + # a later edit that does not touch the host is not a reassignment + changed_bodies.clear + client.patch("#{EVENTS_BASE}/#{event_id}?system_id=#{system_id}", headers: headers, + body: EventsHelper.reassign_host_input(host_override: new_host)).status_code.should eq(200) + sleep 100.milliseconds + + payload = JSON.parse(changed_bodies.last) + payload["host"].as_s.should eq new_host + payload["previous_host_email"].as_s.should eq new_host + end + + it "moves the meeting to the new host's calendar when the host field changes" do + event_id, changed_bodies, _, _ = create_event.call(true) + new_host = "another-host@example.com" + EventsHelper.stub_calendar_write_access(new_host) + + # Office365 cannot change a meeting's organiser, so this is what a person + # does in Outlook: cancel the meeting and send it again from the new host + cancelled = [] of String + WebMock.stub(:delete, "https://graph.microsoft.com/v1.0/users/dev%40acaprojects.onmicrosoft.com/calendar/events/#{URI.encode_path_segment(event_id)}") + .to_return do |request| + cancelled << (request.path || "") + HTTP::Client::Response.new(204, body: "") + end + resent = [] of String + moved_event_id = "evt-moved-to-another-host" + WebMock.stub(:post, "https://graph.microsoft.com/v1.0/users/#{URI.encode_path_segment(new_host)}/calendar/events") + .to_return do |request| + resent << (request.body.try(&.gets_to_end) || "") + HTTP::Client::Response.new(201, body: EventsHelper.mock_event_id(moved_event_id, "ical-moved-001", recurring: false, organizer: new_host).to_json) + end + + before = EventMetadata.find_by(event_id: event_id) + resp = client.patch("#{EVENTS_BASE}/#{event_id}?system_id=#{system_id}", headers: headers, + body: EventsHelper.reassign_host_input(host: new_host)) + resp.status_code.should eq(200) + sleep 100.milliseconds + + cancelled.size.should eq 1 + resent.size.should eq 1 + # the room is invited by the new meeting, and the old host stays involved + invited = JSON.parse(resent.first)["attendees"].as_a.map { |attendee| attendee["emailAddress"]["address"].as_s.downcase } + invited.should contain "room1@example.com" + invited.should contain organiser + + # the meeting keeps its identity in PlaceOS: same metadata record, so + # visitors, check-in state and extension data survive the move + after = EventMetadata.find!(before.id.not_nil!) + after.host_email.should eq new_host + after.event_id.should eq moved_event_id + after.ical_uid.should eq "ical-moved-001" + after.attendees.to_a.size.should eq before.attendees.to_a.size + + payload = JSON.parse(changed_bodies.last) + payload["host"].as_s.should eq new_host + payload["organiser_email"].as_s.should eq new_host + payload["previous_host_email"].as_s.should eq organiser + end + + it "supersedes a reassignment when the meeting moves to the new host" do + event_id, changed_bodies, _, _ = create_event.call(true) + + # a meeting hosted by someone other than the mailbox that owns it + stand_in = "stand-in-host@example.com" + client.patch("#{EVENTS_BASE}/#{event_id}?system_id=#{system_id}", headers: headers, + body: EventsHelper.reassign_host_input(host_override: stand_in)).status_code.should eq(200) + sleep 100.milliseconds + host_override_of = ->(meta : EventMetadata) { meta.ext_data.try(&.as_h?).try(&.[]?("host_override")).try(&.as_s) } + host_override_of.call(EventMetadata.find_by(event_id: event_id)).should eq stand_in + + new_host = "another-host@example.com" + EventsHelper.stub_calendar_write_access(new_host) + moved_event_id = "evt-superseding-the-override" + WebMock.stub(:delete, "https://graph.microsoft.com/v1.0/users/dev%40acaprojects.onmicrosoft.com/calendar/events/#{URI.encode_path_segment(event_id)}") + .to_return(status: 204, body: "") + WebMock.stub(:post, "https://graph.microsoft.com/v1.0/users/#{URI.encode_path_segment(new_host)}/calendar/events") + .to_return(body: EventsHelper.mock_event_id(moved_event_id, "ical-superseded", recurring: false, organizer: new_host).to_json) + + changed_bodies.clear + client.patch("#{EVENTS_BASE}/#{event_id}?system_id=#{system_id}", headers: headers, + body: EventsHelper.reassign_host_input(host: new_host)).status_code.should eq(200) + sleep 100.milliseconds + + # the new host owns the meeting, so nothing is left standing in for them + moved = EventMetadata.find_by(event_id: moved_event_id) + moved.host_email.should eq new_host + host_override_of.call(moved).should be_nil + + # and the person who was actually hosting is the one told they no longer are + payload = JSON.parse(changed_bodies.last) + payload["host"].as_s.should eq new_host + payload["previous_host_email"].as_s.should eq stand_in + end + + it "does not move the meeting when the host field repeats the organiser" do + event_id, changed_bodies, _, _ = create_event.call(false) + + WebMock.stub(:delete, "https://graph.microsoft.com/v1.0/users/dev%40acaprojects.onmicrosoft.com/calendar/events/#{URI.encode_path_segment(event_id)}") + .to_return { raise "the meeting must not be cancelled" } + + client.patch("#{EVENTS_BASE}/#{event_id}?system_id=#{system_id}", headers: headers, + body: EventsHelper.reassign_host_input(host: organiser)).status_code.should eq(200) + sleep 100.milliseconds + + payload = JSON.parse(changed_bodies.last) + payload["host"].as_s.should eq organiser + payload["previous_host_email"].as_s.should eq organiser + end + + it "refuses to move a recurring meeting rather than mangling the series" do + WebMock.reset + EventsHelper.stub_event_tokens + EventsHelper.stub_update_endpoints + EventsHelper.stub_permissions_check(system_id) + + created = JSON.parse(client.post(EVENTS_BASE, headers: headers, body: EventsHelper.create_recurring_event_input).body).as_h + recurring_id = created["id"].to_s + EventsHelper.stub_room_event_query(recurring_id) + + new_host = "another-host@example.com" + EventsHelper.stub_calendar_write_access(new_host) + WebMock.stub(:delete, "https://graph.microsoft.com/v1.0/users/dev%40acaprojects.onmicrosoft.com/calendar/events/#{URI.encode_path_segment(recurring_id)}") + .to_return { raise "the series must not be cancelled" } + + resp = client.patch("#{EVENTS_BASE}/#{recurring_id}?system_id=#{system_id}", headers: headers, + body: EventsHelper.reassign_host_input(host: new_host)) + resp.status_code.should eq(400) + resp.body.should contain "recurring" + end + + it "ignores a host field that has fallen back to the requesting user" do + event_id, changed_bodies, _, _ = create_event.call(false) + + # an attendee edits the meeting; front ends fall back to the current user + # when they cannot resolve the organiser, which must not steal the event + attendee_headers = Mock::Headers.office365_normal_user(requester) + client.patch("#{EVENTS_BASE}/#{event_id}?system_id=#{system_id}", headers: attendee_headers, + body: EventsHelper.reassign_host_input(host: requester)).status_code.should eq(200) + sleep 100.milliseconds + + payload = JSON.parse(changed_bodies.last) + payload["host"].as_s.should eq organiser + payload["previous_host_email"].as_s.should eq organiser + end + + it "clears the reassignment when the override is emptied" do + event_id, changed_bodies, _, _ = create_event.call(false) + + new_host = "temporary-host@example.com" + client.patch("#{EVENTS_BASE}/#{event_id}?system_id=#{system_id}", headers: headers, + body: EventsHelper.reassign_host_input(host_override: new_host)).status_code.should eq(200) + sleep 100.milliseconds + + changed_bodies.clear + client.patch("#{EVENTS_BASE}/#{event_id}?system_id=#{system_id}", headers: headers, + body: EventsHelper.reassign_host_input(host_override: "")).status_code.should eq(200) + sleep 100.milliseconds + + payload = JSON.parse(changed_bodies.last) + payload["host"].as_s.should eq organiser + payload["previous_host_email"].as_s.should eq new_host + end + + it "keeps the reassignment, and does not re-invite anyone, when the event changes room" do + event_id, changed_bodies, attending_bodies, _ = create_event.call(false) + + new_host = "moved-host@example.com" + client.patch("#{EVENTS_BASE}/#{event_id}?system_id=#{system_id}", headers: headers, + body: EventsHelper.reassign_host_input(host_override: new_host, attendee: requester)).status_code.should eq(200) + sleep 100.milliseconds + + # the room the event moves to + systems = Array(JSON::Any).from_json(File.read("./spec/fixtures/placeos/systems.json")).map &.to_json + moved_system_id = "sys_id" + WebMock.stub(:get, ENV["PLACE_URI"].to_s + "/api/engine/v2/systems/#{moved_system_id}") + .to_return(body: systems[1]) + EventsHelper.stub_permissions_check(moved_system_id) + + changed_bodies.clear + attending_bodies.clear + client.patch("#{EVENTS_BASE}/#{event_id}?system_id=#{system_id}", headers: headers, + body: EventsHelper.reassign_host_input(host_override: new_host, attendee: requester, system_id: moved_system_id)).status_code.should eq(200) + sleep 100.milliseconds + + payload = JSON.parse(changed_bodies.last) + payload["previous_system_id"].as_s.should eq system_id + payload["system_id"].as_s.should eq moved_system_id + # a move must not read as a reassignment back to the organiser + payload["host"].as_s.should eq new_host + payload["previous_host_email"].as_s.should eq new_host + + # the visitor was already attending — the move is a change, not an invite + attending_bodies.map { |body| JSON.parse(body)["attendee_email"].as_s }.should_not contain requester + end + + it "puts the new host in the meeting so it reaches their calendar" do + event_id, _, _, patch_bodies = create_event.call(false) + + new_host = "not-invited@example.com" + client.patch("#{EVENTS_BASE}/#{event_id}?system_id=#{system_id}", headers: headers, + body: EventsHelper.reassign_host_input(host_override: new_host)).status_code.should eq(200) + + # Office365 cannot move the organiser, but the new host must at least be + # in the meeting for it to show up in their calendar + invited = JSON.parse(patch_bodies.first)["attendees"].as_a.map { |attendee| attendee["emailAddress"]["address"].as_s.downcase } + invited.should contain new_host + end + + it "drops a reassignment when the calendar reports a different organiser" do + event_id, changed_bodies, _, _ = create_event.call(false) + + new_host = "placeos-host@example.com" + client.patch("#{EVENTS_BASE}/#{event_id}?system_id=#{system_id}", headers: headers, + body: EventsHelper.reassign_host_input(host_override: new_host)).status_code.should eq(200) + sleep 100.milliseconds + JSON.parse(changed_bodies.last)["host"].as_s.should eq new_host + + # Office365 owns the organiser, so a change there wins: recreating the + # meeting under someone else must not leave the old reassignment in place + changed_bodies.clear + outlook_organiser = "outlook-organiser@example.com" + meta = EventMetadata.find_by(event_id: event_id) + + # the webhook route resolves the system from the database + PlaceOS::Model::ControlSystem.find?(system_id).try(&.delete) + webhook_system = PlaceOS::Model::Generator.control_system + webhook_system.id = system_id + webhook_system.save! + + client.post("#{EVENTS_BASE}/notify/updated/#{system_id}/#{event_id}", headers: headers, body: %({ + "event_start": #{meta.event_start}, + "event_end": #{meta.event_end}, + "id": "#{event_id}", + "host": "#{outlook_organiser}", + "ical_uid": "#{meta.ical_uid}", + "attendees": [], + "private": false, + "all_day": false + })).status_code.should eq(202) + sleep 100.milliseconds + + payload = JSON.parse(changed_bodies.last) + payload["host"].as_s.should eq outlook_organiser + payload["organiser_email"].as_s.should eq outlook_organiser + # the person who was hosting is told they no longer are + payload["previous_host_email"].as_s.should eq new_host + EventMetadata.find_by(event_id: event_id).ext_data.not_nil!.as_h.has_key?("host_override").should be_false + end + + it "announces new visitors as attending the reassigned host" do + event_id, _, attending_bodies, _ = create_event.call(false) + + new_host = "new-host@example.com" + client.patch("#{EVENTS_BASE}/#{event_id}?system_id=#{system_id}", headers: headers, + body: EventsHelper.reassign_host_input(host_override: new_host, extra_attendee: "guest@external.com")).status_code.should eq(200) + sleep 100.milliseconds + + # the visitor mailer skips emailing the host their own invite, which only + # works when the reassignment is reflected here too + guest = attending_bodies.map { |body| JSON.parse(body) }.find { |body| body["attendee_email"].as_s == "guest@external.com" } + guest.should_not be_nil + guest.not_nil!["host"].as_s.should eq new_host + end + end + # Metadata is stored per room, so an event that changes room used to start a # fresh record and re-announce every visitor as newly invited (PPT-2375). describe "room moves", tags: "PPT-2375" do diff --git a/spec/controllers/helpers/event_helper.cr b/spec/controllers/helpers/event_helper.cr index d8825a11..1e703d0a 100644 --- a/spec/controllers/helpers/event_helper.cr +++ b/spec/controllers/helpers/event_helper.cr @@ -47,14 +47,78 @@ module EventsHelper .to_return(body: File.read("./spec/fixtures/events/o365/events_query.json")) end + # Lets `can_create?` see the requesting user as a delegate of `mailbox`, so a + # meeting can be re-sent from their calendar. + def stub_calendar_write_access(mailbox : String, user = "dev@acaprojects.onmicrosoft.com") + WebMock.stub(:get, "https://graph.microsoft.com/v1.0/users/#{URI.encode_path_segment(user)}/calendars") + .to_return(body: File.read("./spec/fixtures/calendars/o365/show.json")) + WebMock.stub(:get, "https://graph.microsoft.com/v1.0/users/#{URI.encode_path_segment(mailbox)}/calendar/calendarPermissions") + .to_return(body: {value: [{ + id: "permission-1", + role: "write", + isRemovable: true, + isInsideOrganization: true, + allowedRoles: ["write"], + emailAddress: {address: user, name: user}, + }]}.to_json) + end + # The room's copy of the event, looked up by ical uid once the id is known. def stub_room_event_query(event_id) WebMock.stub(:get, "https://graph.microsoft.com/v1.0/users/room1%40example.com/calendar/calendarView?startDateTime=2020-08-26T14:00:00-00:00&endDateTime=2020-08-27T13:59:59-00:00&%24filter=iCalUId+eq+%27040000008200E00074C5B7101A82E008000000006DE2E3761F8AD6010000000000000000100000009CCCDBB1F09DE74D8B157797D97F6A10%27&$top=10000") .to_return(event_query_response(event_id)) end - def mock_event_id(id, ical = nil) + # An update body with a configurable host and host_override, for exercising + # host reassignment on an existing event. + def reassign_host_input(host = "dev@acaprojects.onmicrosoft.com", host_override : String? = nil, extra_attendee : String? = nil, attendee = "amit@redant.com.au", system_id = "sys-rJQQlR4Cn7") + extension_data = host_override.nil? ? %({"fizz": "buzz"}) : %({"fizz": "buzz", "host_override": "#{host_override}"}) + attendees = [%({ + "name": "Amit", + "email": "#{attendee}", + "response_status": "accepted", + "resource": false, + "organizer": true, + "checked_in": true, + "visit_expected": true + })] + if extra_attendee + attendees << %({ + "name": "New Guest", + "email": "#{extra_attendee}", + "response_status": "tentative", + "resource": false, + "organizer": false, + "checked_in": false, + "visit_expected": true + }) + end + + %({ + "event_start": 1598504460, + "event_end": 1598508120, + "attendees": [#{attendees.join(",")}], + "private": false, + "all_day": false, + "recurring": false, + "host": "#{host}", + "title": "tentative event response status and default timezone trial", + "body": "yeehaw hiya updated", + "location": "test", + "system_id": "#{system_id}", + "system": { + "id": "#{system_id}" + }, + "extension_data": #{extension_data} + }) + end + + # The ical uid carried by the o365 event fixtures. + ICAL_UID = "040000008200E00074C5B7101A82E008000000006DE2E3761F8AD6010000000000000000100000009CCCDBB1F09DE74D8B157797D97F6A10" + + def mock_event_id(id, ical = nil, recurring = true, organizer : String? = nil) event = Office365::Event.new(**{ + organizer: organizer, id: id, starts_at: Time.unix(1598503500), ends_at: Time.unix(1598507160), @@ -62,12 +126,28 @@ module EventsHelper rooms: ["Red Room"], attendees: ["elon@musk.com", Office365::EmailAddress.new(address: "david@bowie.net", name: "David Bowie"), Office365::Attendee.new(email: "the@goodies.org")], response_status: Office365::ResponseStatus.new(response: Office365::ResponseStatus::Response::Organizer, time: "0001-01-01T00:00:00Z"), - recurrence: Office365::RecurrenceParam.new(pattern: "daily", range_end: Time.unix(1598508160)), + recurrence: (Office365::RecurrenceParam.new(pattern: "daily", range_end: Time.unix(1598508160)) if recurring), }) event.icaluid = ical event end + # The event id carried by the o365 event fixtures. + FIXTURE_EVENT_ID = "AAMkADE3YmQxMGQ2LTRmZDgtNDljYy1hNDg1LWM0NzFmMGI0ZTQ3YgBGAAAAAADFYQb3DJ_xSJHh14kbXHWhBwB08dwEuoS_QYSBDzuv558sAAAAAAENAAB08dwEuoS_QYSBDzuv558sAACGVOwUAAA=" + + # Serves the event as a one-off rather than a recurring series, on both the + # room's and the host's calendar. Register before the shared stubs. + def stub_one_off_event(event_id = FIXTURE_EVENT_ID, host = "dev@acaprojects.onmicrosoft.com") + event = mock_event_id(event_id, ICAL_UID, recurring: false, organizer: host).to_json + + WebMock.stub(:get, "https://graph.microsoft.com/v1.0/users/room1%40example.com/calendar/events/#{URI.encode_path_segment(event_id)}") + .to_return(body: event) + # the host's copy is looked up over the event's day, which this event spans + # in UTC rather than in the fixture's timezone + WebMock.stub(:get, "https://graph.microsoft.com/v1.0/users/#{URI.encode_path_segment(host)}/calendar/calendarView?startDateTime=2020-08-27T00%3A00%3A00-00%3A00&endDateTime=2020-08-27T23%3A59%3A59-00%3A00&%24filter=iCalUId+eq+%27#{ICAL_UID}%27&%24top=10000") + .to_return(body: %({"value": [#{event}]})) + end + def stub_permissions_check(system_id) WebMock.stub(:get, "http://toby.dev.place.tech/api/engine/v2/metadata/#{system_id}?name=permissions") .to_return(body: %({ diff --git a/src/controllers/events.cr b/src/controllers/events.cr index 9f67fd56..84fe608c 100644 --- a/src/controllers/events.cr +++ b/src/controllers/events.cr @@ -558,6 +558,18 @@ class Events < Application # when moving a room from one system to another, the `system_id` param should be # set to the current rooms associated system. # Then in the event body, the `system_id` field should be the new system. + # + # Changing `host` hands the meeting to that person. Office365 cannot change a + # meeting's organiser, so this does what a person does in Outlook: the meeting + # is cancelled and sent again from the new host's calendar, which re-invites + # the attendees and gives the event a new id and ical uid. Recurring meetings + # are refused. A `host` matching the requesting user is ignored, as front ends + # fall back to the current user when they cannot resolve the organiser. + # + # `extension_data.host_override` instead records who is hosting while leaving + # the meeting on the organiser's calendar — for deployments that book rooms as + # a service account. Send an empty string to clear it. Moving the meeting + # supersedes it, as the new host owns the meeting afterwards. @[AC::Route::PATCH("/:id", body: :changes)] @[AC::Route::PUT("/:id", body: :changes)] def update( @@ -631,6 +643,25 @@ class Events < Application attendees << host end + # Handing the meeting to someone else means moving it to their calendar, + # which Office365 only allows by cancelling and re-sending it (PPT-2375). + new_organiser = requested_organiser(changes, organiser: host, requester: user_email) + + # Naming a host without moving the meeting is recorded against the event + # metadata instead, for deployments where the organiser is a room or a + # service account rather than a person. A transfer supersedes it: the new + # host owns the meeting afterwards, so there is nothing left to override. + # Whoever it names still has to be in the meeting to host it, so that it + # reaches their calendar. + reassigned_host = host_override(changes.extension_data) unless new_organiser + if reassigned_host && !reassigned_host.in?(attendees) + new_host_attendee = PlaceCalendar::Event::Attendee.new(name: reassigned_host, email: reassigned_host, response_status: "none") + new_host_attendee.visit_expected = true + changes.attendees << new_host_attendee + attendees << reassigned_host + update_attendees = true + end + attendees << cal_id attendees.uniq! @@ -696,7 +727,15 @@ class Events < Application end end - updated_event = client.update_event(user_id: host, event: changes, calendar_id: host, notify_existing_attendees: notify_existing_attendees) + # The metadata belongs to the meeting rather than to the calendar entry, so + # it is looked up before a transfer replaces the event. + transferred_meta = get_event_metadata(event, system_id) if new_organiser && system_id + + updated_event = if new_organiser + transfer_event(changes, event, event_id, from: host, to: new_organiser, notify: notify_existing_attendees) + else + client.update_event(user_id: host, event: changes, calendar_id: host, notify_existing_attendees: notify_existing_attendees) + end raise Error::BadUpstreamResponse.new("failed to update event #{event_id} as #{host}") unless updated_event if system @@ -708,9 +747,20 @@ class Events < Application # start a fresh record, orphaning all of that and making every visitor # look newly invited (PPT-2375). moved_meta = previous_meta_for_signal if changing_room - meta = moved_meta || get_migrated_metadata(updated_event, system_id) || EventMetadata.new + meta = moved_meta || transferred_meta || get_migrated_metadata(updated_event, system_id) || EventMetadata.new - if extension_data = changes.extension_data + # A transferred meeting is a new calendar entry, so the record follows it. + if new_organiser && meta.persisted? + meta.event_id = updated_event.id.as(String) + meta.ical_uid = updated_event.ical_uid.as(String) + end + + # Captured before the merge below, as a reassignment overwrites it. + previous_record = previous_meta_for_signal || (meta if meta.persisted?) + previous_host = previous_record.try { |record| host_override(record.ext_data) || record.host_email } + + extension_data = changes.extension_data + if extension_data || reassigned_host || new_organiser meta_ext_data = meta.ext_data data = if (val = meta_ext_data) && val.as_h? val.as_h @@ -718,7 +768,21 @@ class Events < Application Hash(String, JSON::Any).new end # Updating extension data by merging into existing. - extension_data.as_h.each { |key, value| data[key] = value } + extension_data.as_h.each { |key, value| data[key] = value } if extension_data + data[HOST_OVERRIDE_KEY] = JSON::Any.new(reassigned_host) if reassigned_host + + if new_organiser + # the meeting is theirs now, so any override of it is spent + data.delete(HOST_OVERRIDE_KEY) + elsif (override = data[HOST_OVERRIDE_KEY]?) && (override_email = override.as_s?) + # an explicitly emptied override returns the event to its organiser + if override_email.blank? + data.delete(HOST_OVERRIDE_KEY) + else + data[HOST_OVERRIDE_KEY] = JSON::Any.new(override_email.downcase) + end + end + meta.ext_data = JSON::Any.new(data) meta.ext_data_will_change! end @@ -737,7 +801,11 @@ 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, previous_meta: previous_meta_for_signal) + notify_created_or_updated(:update, system, updated_event, meta, can_skip: false, is_host: true, previous_meta: previous_meta_for_signal, previous_host: previous_host) + + # Visitors are hosted by whoever the event was reassigned to, not by + # whichever mailbox the meeting happens to live on. + signal_host = effective_host(meta) # Grab the list of externals that might be attending if update_attendees || changing_room @@ -824,7 +892,7 @@ class Events < Application system_id: sys.id, event_id: event_id, event_ical_uid: updated_event.ical_uid, - host: host, + host: signal_host, resource: sys.email, event_title: updated_event.title, event_summary: updated_event.title, @@ -1302,14 +1370,15 @@ class Events < Application spawn do placeos_client.root.signal("staff/event/changed", { - action: :update, - system_id: system.id, - event_id: original_id, - event_ical_uid: meta.ical_uid, - host: meta.host_email, - resource: system.email, - event: event, - ext_data: meta.ext_data, + action: :update, + system_id: system.id, + event_id: original_id, + event_ical_uid: meta.ical_uid, + host: effective_host(meta), + organiser_email: meta.host_email, + resource: system.email, + event: event, + ext_data: meta.ext_data, }) end @@ -1959,7 +2028,7 @@ class Events < Application system_id: system_id, event_id: event_id, event_ical_uid: eventmeta.ical_uid, - host: event.host, + host: effective_host(eventmeta), resource: eventmeta.resource_calendar, event_title: event.title, event_summary: event.title, @@ -1981,7 +2050,78 @@ class Events < Application # a non-master mailbox copy is treated as a stale echo (PPT-2375). STALE_MIRROR_ECHO_WINDOW = 30.seconds - def notify_created_or_updated(action, system, event, meta = nil, can_skip = true, is_host = true, previous_meta : EventMetadata? = nil) + # Who is hosting a meeting that someone else's mailbox owns — a room or a + # service account in deployments that book that way, or a host reassignment + # Office365 will not let us make by changing the organiser (PPT-2375). Room + # panels resolve their host the same way. + HOST_OVERRIDE_KEY = "host_override" + + # Reads the key from event metadata, or from the extension data of an update. + protected def host_override(ext_data : JSON::Any?) : String? + ext_data.try(&.as_h?).try(&.[]?(HOST_OVERRIDE_KEY)).try(&.as_s?).try(&.downcase.presence) + end + + # Who an update is asking to take the meeting over, as opposed to hosting it + # on the organiser's behalf. Only a changed `host` means that: it names the + # mailbox the meeting should belong to. + protected def requested_organiser(changes : PlaceCalendar::Event, organiser : String, requester : String) : String? + changed_host = changes.host.try(&.downcase).presence + changed_host if changed_host && changed_host != organiser && changed_host != requester + end + + # Hands a meeting to a new host. + # + # Office365 has no way to change a meeting's organiser, so this does what a + # person does in Outlook: cancels the meeting and sends it again from the new + # host's calendar. The old one goes first, otherwise the room declines the new + # invitation as clashing with itself. + protected def transfer_event(changes : PlaceCalendar::Event, event : PlaceCalendar::Event, event_id : String, from : String, to : String, notify : Bool) : PlaceCalendar::Event + attendee_emails = changes.attendees.map(&.email.downcase) + raise Error::Forbidden.new("user #{user.email} does not have write access to #{to} calendar") unless can_create?(user.email.downcase, to, attendee_emails) + + # Every occurrence would have to be re-sent, and any exception to the series + # rebuilt, so this is refused rather than done badly. + raise Error::BadRequest.new("a recurring meeting cannot be moved to another host, as Office365 requires it to be cancelled and sent again") if event.recurring || event.recurring_event_id.presence + + # the new host organises the meeting, and organises it for themselves + changes.id = nil + changes.host = to + changes.attendees = changes.attendees.reject { |attendee| attendee.email.downcase == to } + new_host_attendee = PlaceCalendar::Event::Attendee.new(name: to, email: to, response_status: "accepted") + new_host_attendee.visit_expected = true + changes.attendees << new_host_attendee + + Log.info { "moving event #{event_id} from #{from} to #{to}" } + client.delete_event(user_id: from, id: event_id, calendar_id: from, notify: notify) + + begin + transferred = client.create_event(user_id: to, event: changes, calendar_id: to) + raise Error::BadUpstreamResponse.new("event was not created on #{to}") unless transferred + transferred + rescue error + # The meeting has already been cancelled at this point, so put it back + # where it was rather than leaving the room free and everyone uninvited. + Log.error(exception: error) { "failed to move event #{event_id} to #{to}, restoring it on #{from}" } + begin + event.id = nil + client.create_event(user_id: from, event: event, calendar_id: from) + rescue ex + Log.error(exception: ex) { "failed to restore event #{event_id} on #{from}" } + raise Error::BadUpstreamResponse.new("failed to move the meeting to #{to} and it could not be restored on #{from}, it must be booked again") + end + raise Error::BadUpstreamResponse.new("failed to move the meeting to #{to}, it remains with #{from}") + end + end + + # Who is hosting the event: the organiser, unless the host was reassigned. + protected def effective_host(meta : EventMetadata) : String + host_override(meta.ext_data) || meta.host_email + end + + # `previous_host` is who was hosting before the edit. Only the caller knows + # when a reassignment has just overwritten it, as a reassignment lives in the + # metadata rather than on the mailbox organiser. + def notify_created_or_updated(action, system, event, meta = nil, can_skip = true, is_host = true, previous_meta : EventMetadata? = nil, previous_host : String? = 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 @@ -2032,6 +2172,21 @@ class Events < Application meta.resource_master_id = event.recurring_event_id || event.id if event.recurring && !is_host meta.ical_uid = event.ical_uid.as(String) end + + # The calendar owns the organiser: when it reports a different one, any + # reassignment PlaceOS was holding is stale and gets dropped, so the two can + # never disagree about who is hosting (PPT-2375). + stale_reassignment = if meta.persisted? && meta.host_email.downcase != event.host.as(String).downcase + dropped = host_override(meta.ext_data) + if dropped + data = meta.ext_data.try(&.as_h?) || Hash(String, JSON::Any).new + data.delete(HOST_OVERRIDE_KEY) + meta.ext_data = JSON::Any.new(data) + meta.ext_data_will_change! + end + dropped + end + meta.system_id = system.id.as(String) meta.host_email = event.host.as(String).downcase meta.event_start = starting @@ -2046,6 +2201,13 @@ class Events < Application event.setup_event_id = meta.setup_event_id event.breakdown_event_id = meta.breakdown_event_id + # A reassignment can only be reported by the caller that performed it, or by + # the organiser change that just invalidated it. Otherwise an existing + # reassignment is both the previous and current host, so that a webhook for + # an unrelated edit doesn't look like the host changing back. + host_email = effective_host(meta) + previous_host_email = previous_host.presence || stale_reassignment || host_override(meta.ext_data) || previous_host_email + return if skip_signal spawn do @@ -2054,7 +2216,8 @@ class Events < Application system_id: system.id, event_id: meta.event_id, event_ical_uid: meta.ical_uid, - host: meta.host_email, + host: host_email, + organiser_email: meta.host_email, resource: meta.resource_calendar, event: event, ext_data: meta.try &.ext_data,