diff --git a/OPENAPI_DOC.yml b/OPENAPI_DOC.yml index 968b0c4f..7372d00c 100644 --- a/OPENAPI_DOC.yml +++ b/OPENAPI_DOC.yml @@ -6390,6 +6390,124 @@ paths: application/json: schema: $ref: '#/components/schemas/Application__CommonError' + /api/staff/v1/events/clashing-assets: + post: + summary: lists conflicting system_ids based on event time range + description: 'lists conflicting system_ids based on event time range + + will return a list of system_ids that are already booked during the specified + time range + + if system_ids is set, then it will only return booked systems from that list' + tags: + - Events + operationId: Events_clashing_assets + parameters: + - name: period_start + in: query + description: event period start as a unix epoch + example: "1661725146" + required: true + schema: + type: integer + format: Int64 + - name: period_end + in: query + description: event period end as a unix epoch + example: "1661743123" + required: true + schema: + type: integer + format: Int64 + - name: system_ids + in: query + description: comma separated list of system_ids to check for clashes + example: sys-1234,sys-5678 + schema: + type: string + nullable: true + - name: return_available + in: query + description: return available systems, this requires system_ids be set to + the full list + example: "false" + schema: + type: boolean + - name: include_clash_time + in: query + description: include the clash times, this is not compatible with return_available + example: "false" + schema: + type: boolean + responses: + 200: + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/_Array_NamedTuple_system_id__String__event_start__Int64__event_end__Int64_____Array_String__' + 429: + description: Too Many Requests + content: + application/json: + schema: + $ref: '#/components/schemas/Application__CommonError' + 400: + description: Bad Request + content: + application/json: + schema: + $ref: '#/components/schemas/Application__CommonError' + 401: + description: Unauthorized + content: + application/json: + schema: + $ref: '#/components/schemas/Application__CommonError' + 403: + description: Forbidden + 404: + description: Not Found + content: + application/json: + schema: + $ref: '#/components/schemas/Application__CommonError' + 511: + description: Network Authentication Required + content: + application/json: + schema: + $ref: '#/components/schemas/Application__CommonError' + 406: + description: Not Acceptable + content: + application/json: + schema: + $ref: '#/components/schemas/Application__ContentError' + 415: + description: Unsupported Media Type + content: + application/json: + schema: + $ref: '#/components/schemas/Application__ContentError' + 422: + description: Unprocessable Entity + content: + application/json: + schema: + $ref: '#/components/schemas/Application__ValidationError' + 500: + description: Internal Server Error + content: + application/json: + schema: + $ref: '#/components/schemas/Application__CommonError' + 405: + description: Method Not Allowed + content: + application/json: + schema: + $ref: '#/components/schemas/Application__CommonError' /api/staff/v1/events/{id}/guests/{guest_id}/check_in: post: summary: a guest has arrived for a meeting in person. @@ -13272,6 +13390,8 @@ components: type: integer format: Int64 nullable: true + _Array_NamedTuple_system_id__String__event_start__Int64__event_end__Int64_____Array_String__: + type: array PlaceCalendar__Group: type: object properties: diff --git a/spec/controllers/bookings_spec.cr b/spec/controllers/bookings_spec.cr index d2ebb662..bd2d82c1 100644 --- a/spec/controllers/bookings_spec.cr +++ b/spec/controllers/bookings_spec.cr @@ -649,7 +649,7 @@ describe Bookings do tenant = get_tenant booking1 = BookingsHelper.create_booking(tenant.id.not_nil!) - sleep 1 + sleep 1.second booking2 = BookingsHelper.create_booking(tenant.id.not_nil!) starting = 5.minutes.from_now.to_unix @@ -691,7 +691,7 @@ describe Bookings do booking_start: 5.minutes.from_now.to_unix, booking_end: 35.minutes.from_now.to_unix, asset_id: "desk-2") - sleep 1 + sleep 1.second zones_string = "#{booking1.zones.not_nil!.first},#{booking2.zones.not_nil!.first}" route = "#{BOOKINGS_BASE}/booked?period_start=#{starting}&period_end=#{ending}&type=desk&zones=#{zones_string}" @@ -720,7 +720,7 @@ describe Bookings do booking_end: 35.minutes.from_now.to_unix, asset_id: "desk-2") client.delete("#{BOOKINGS_BASE}/#{booking2.id}", headers: headers) - sleep 1 + sleep 1.second zones_string = "#{booking1.zones.not_nil!.first},#{booking2.zones.not_nil!.first}" route = "#{BOOKINGS_BASE}/booked?period_start=#{starting}&period_end=#{ending}&type=desk&zones=#{zones_string}" @@ -2120,7 +2120,7 @@ describe Bookings do ).status_code created.should eq(201) - sleep 3 + sleep 3.seconds client.post("#{BOOKINGS_BASE}/", headers: headers, body: %({"asset_id":"some_desk","booking_start":#{starting},"booking_end":#{ending},"booking_type":"desk","attendees": [ { @@ -2156,7 +2156,7 @@ describe Bookings do ).status_code created.should eq(201) - sleep 3 + sleep 3.seconds client.post("#{BOOKINGS_BASE}/", headers: headers, body: %({"asset_ids":["desk2"],"booking_start":#{starting},"booking_end":#{ending},"booking_type":"desk","attendees": [ { @@ -2535,7 +2535,7 @@ describe Bookings do booking.checked_out_at = 10.minutes.from_now.to_unix booking.save! - sleep 2 + sleep 2.seconds should_create = BookingsHelper.http_create_booking( booking_start: 15.minutes.from_now.to_unix, @@ -2562,7 +2562,7 @@ describe Bookings do BookingsHelper.create_booking(tenant.id.not_nil!, 15.minutes.from_now.to_unix, 25.minutes.from_now.to_unix, asset_id) - sleep 2 + sleep 2.seconds not_checked_in = client.post("#{BOOKINGS_BASE}/#{booking.id}/check_in", headers: headers).status_code not_checked_in.should eq(405) diff --git a/spec/controllers/events_spec.cr b/spec/controllers/events_spec.cr index 70ad7d43..c94b6d32 100644 --- a/spec/controllers/events_spec.cr +++ b/spec/controllers/events_spec.cr @@ -1336,4 +1336,111 @@ describe Events, tags: ["event"] do histories.first.changed_fields.should eq(["ext_data.notes"]) end end + + describe "#clashing_assets" do + it "returns clashing system_ids for overlapping events" do + tenant = get_tenant + event_start = 10.minutes.from_now.to_unix + event_end = 30.minutes.from_now.to_unix + + # Create events with different system_ids + EventMetadatasHelper.create_event(tenant.id, system_id: "sys-1234", event_start: event_start, event_end: event_end) + EventMetadatasHelper.create_event(tenant.id, system_id: "sys-5678", event_start: event_start + 5, event_end: event_end + 5) + EventMetadatasHelper.create_event(tenant.id, system_id: "sys-9999", event_start: event_end + 100, event_end: event_end + 200) + + response = client.post( + "#{EVENTS_BASE}/clashing-assets?period_start=#{event_start}&period_end=#{event_end}", + headers: headers + ) + response.status_code.should eq(200) + + clashing = Array(String).from_json(response.body) + clashing.size.should eq(2) + clashing.should contain("sys-1234") + clashing.should contain("sys-5678") + clashing.should_not contain("sys-9999") + end + + it "returns only clashing system_ids from provided list" do + tenant = get_tenant + event_start = 10.minutes.from_now.to_unix + event_end = 30.minutes.from_now.to_unix + + EventMetadatasHelper.create_event(tenant.id, system_id: "sys-1234", event_start: event_start, event_end: event_end) + EventMetadatasHelper.create_event(tenant.id, system_id: "sys-5678", event_start: event_start, event_end: event_end) + + response = client.post( + "#{EVENTS_BASE}/clashing-assets?period_start=#{event_start}&period_end=#{event_end}&system_ids=sys-1234,sys-9999", + headers: headers + ) + response.status_code.should eq(200) + + clashing = Array(String).from_json(response.body) + clashing.size.should eq(1) + clashing.should contain("sys-1234") + clashing.should_not contain("sys-5678") + end + + it "returns available system_ids when return_available is true" do + tenant = get_tenant + event_start = 10.minutes.from_now.to_unix + event_end = 30.minutes.from_now.to_unix + + EventMetadatasHelper.create_event(tenant.id, system_id: "sys-1234", event_start: event_start, event_end: event_end) + + response = client.post( + "#{EVENTS_BASE}/clashing-assets?period_start=#{event_start}&period_end=#{event_end}&system_ids=sys-1234,sys-5678,sys-9999&return_available=true", + headers: headers + ) + response.status_code.should eq(200) + + available = Array(String).from_json(response.body) + available.size.should eq(2) + available.should contain("sys-5678") + available.should contain("sys-9999") + available.should_not contain("sys-1234") + end + + it "returns clash times when include_clash_time is true" do + tenant = get_tenant + event_start = 10.minutes.from_now.to_unix + event_end = 30.minutes.from_now.to_unix + + EventMetadatasHelper.create_event(tenant.id, system_id: "sys-1234", event_start: event_start, event_end: event_end) + + response = client.post( + "#{EVENTS_BASE}/clashing-assets?period_start=#{event_start}&period_end=#{event_end}&include_clash_time=true", + headers: headers + ) + response.status_code.should eq(200) + + clashing = Array(NamedTuple(system_id: String, event_start: Int64, event_end: Int64)).from_json(response.body) + clashing.size.should eq(1) + clashing.first[:system_id].should eq("sys-1234") + clashing.first[:event_start].should eq(event_start) + clashing.first[:event_end].should eq(event_end) + end + + it "excludes cancelled events from clashing results" do + tenant = get_tenant + event_start = 10.minutes.from_now.to_unix + event_end = 30.minutes.from_now.to_unix + + EventMetadatasHelper.create_event(tenant.id, system_id: "sys-1234", event_start: event_start, event_end: event_end) + cancelled_event = EventMetadatasHelper.create_event(tenant.id, system_id: "sys-5678", event_start: event_start, event_end: event_end) + cancelled_event.cancelled = true + cancelled_event.save! + + response = client.post( + "#{EVENTS_BASE}/clashing-assets?period_start=#{event_start}&period_end=#{event_end}", + headers: headers + ) + response.status_code.should eq(200) + + clashing = Array(String).from_json(response.body) + clashing.size.should eq(1) + clashing.should contain("sys-1234") + clashing.should_not contain("sys-5678") + end + end end diff --git a/spec/controllers/recurring_bookings_spec.cr b/spec/controllers/recurring_bookings_spec.cr index 8e8a8fd6..3d6c57ca 100644 --- a/spec/controllers/recurring_bookings_spec.cr +++ b/spec/controllers/recurring_bookings_spec.cr @@ -17,9 +17,9 @@ describe Bookings do tenant = get_tenant booking1 = BookingsHelper.create_booking(tenant.id.not_nil!) - sleep 1 + sleep 1.second booking2 = BookingsHelper.create_booking(tenant.id.not_nil!) - sleep 1 + sleep 1.second booking3 = BookingsHelper.create_booking(tenant.id.not_nil!) booking1.recurrence_type = :daily diff --git a/spec/controllers/staff_spec.cr b/spec/controllers/staff_spec.cr index 9c0f369c..088631ca 100644 --- a/spec/controllers/staff_spec.cr +++ b/spec/controllers/staff_spec.cr @@ -31,7 +31,7 @@ describe Staff do WebMock.stub(:post, "https://login.microsoftonline.com/bb89674a-238b-4b7d-91ec-6bebad83553a/oauth2/v2.0/token") .to_return(body: File.read("./spec/fixtures/tokens/o365_token.json")) - WebMock.stub(:get, "https://graph.microsoft.com/v1.0/users/#{user_id}") + WebMock.stub(:get, "https://graph.microsoft.com/v1.0/users/#{user_id}?%24select=id%2CuserPrincipalName%2Csurname%2CpreferredLanguage%2CofficeLocation%2CmobilePhone%2Cmail%2CjobTitle%2CgivenName%2CdisplayName%2CbusinessPhones%2CaccountEnabled%2CmailNickname") .to_return(body: File.read("./spec/fixtures/staff/show.json")) body = PlaceCalendar::User.from_json(client.get("#{STAFF_BASE}/#{user_id}", headers: headers).body) diff --git a/spec/spec_helper.cr b/spec/spec_helper.cr index 20759d25..d6858ca1 100644 --- a/spec/spec_helper.cr +++ b/spec/spec_helper.cr @@ -26,7 +26,7 @@ Spec.before_suite do {% if flag?(:quiet) %} ::Log.setup(:warn) {% else %} - ::Log.setup(:debug) + ::Log.setup(:info) {% end %} end diff --git a/src/controllers/events.cr b/src/controllers/events.cr index c57c7337..780dd289 100644 --- a/src/controllers/events.cr +++ b/src/controllers/events.cr @@ -1739,6 +1739,63 @@ class Events < Application query.limit(10_000).to_a end + # lists conflicting system_ids based on event time range + # will return a list of system_ids that are already booked during the specified time range + # if system_ids is set, then it will only return booked systems from that list + @[AC::Route::POST("/clashing-assets")] + def clashing_assets( + @[AC::Param::Info(name: "period_start", description: "event period start as a unix epoch", example: "1661725146")] + starting : Int64, + @[AC::Param::Info(name: "period_end", description: "event period end as a unix epoch", example: "1661743123")] + ending : Int64, + @[AC::Param::Info(description: "comma separated list of system_ids to check for clashes", example: "sys-1234,sys-5678")] + system_ids : String? = nil, + @[AC::Param::Info(description: "return available systems, this requires system_ids be set to the full list", example: "false")] + return_available : Bool = false, + @[AC::Param::Info(description: "include the clash times, this is not compatible with return_available", example: "false")] + include_clash_time : Bool = false, + ) : Array(String) | Array(NamedTuple(system_id: String, event_start: Int64, event_end: Int64)) + if return_available && system_ids.nil? + raise Error::ModelValidation.new([{field: "system_ids".as(String?), reason: "Missing system_ids"}], "error validating event data") + end + + if return_available && include_clash_time + raise AC::Route::Param::Error.new("include_clash_time and return_available cannot be used together") + end + + # Parse system_ids if provided + sys_ids = system_ids.try(&.split(',').map(&.strip).reject(&.empty?)) || [] of String + + # Query for clashing events + query = EventMetadata + .by_tenant(tenant.id) + .where("event_end > ? AND event_start < ?", starting, ending) + .where("cancelled = ? OR cancelled IS NULL", false) + + # Filter by system_ids if provided + query = query.where({:system_id => sys_ids}) unless sys_ids.empty? + + clashing_events = query.to_a + + if include_clash_time + result = [] of NamedTuple(system_id: String, event_start: Int64, event_end: Int64) + clashing_events.each do |event| + if sys_id = event.system_id + result << {system_id: sys_id, event_start: event.event_start, event_end: event.event_end} + end + end + result + else + clashing_system_ids = clashing_events.compact_map(&.system_id).uniq + + if return_available + clashing_system_ids = sys_ids - clashing_system_ids + end + + clashing_system_ids + end + end + # a guest has arrived for a meeting in person. # This route can be used to notify hosts @[AC::Route::POST("/:id/guests/:guest_id/check_in")]