diff --git a/src/components/Editor/AvatarParticipationStatus.vue b/src/components/Editor/AvatarParticipationStatus.vue index 9268adefbd..c1cf348c1c 100644 --- a/src/components/Editor/AvatarParticipationStatus.vue +++ b/src/components/Editor/AvatarParticipationStatus.vue @@ -89,6 +89,11 @@ export default { default: false, }, + availability: { + type: String, // 'checking' | 'available' | 'unavailable' | null + default: null, + }, + attendeeIsOrganizer: { type: Boolean, required: true, @@ -202,6 +207,24 @@ export default { // No status or status 1.0 indicate that the invitation is pending if (!this.scheduleStatus || this.scheduleStatus === '1.0') { if (this.isResource) { + switch (this.availability) { + case 'available': + return { + ...acceptedIcon, + text: t('calendar', 'Still available'), + } + case 'unavailable': + return { + ...declinedIcon, + text: t('calendar', 'Already booked'), + } + case 'checking': + return { + icon: IconNoResponse, + text: t('calendar', 'Checking availability'), + } + } + return { ...noResponseIcon, text: t('calendar', 'Will be booked after saving, if available'), diff --git a/src/components/Editor/Resources/ResourceList.vue b/src/components/Editor/Resources/ResourceList.vue index 3f854ea822..4fd95518b6 100644 --- a/src/components/Editor/Resources/ResourceList.vue +++ b/src/components/Editor/Resources/ResourceList.vue @@ -37,6 +37,7 @@ :resource="resource" :organizerDisplayName="organizerDisplayName" :isViewedByOrganizer="isViewedByOrganizer" + :availability="availabilityFor(resource)" @removeResource="removeResource" /> 'checking' | 'available' | 'unavailable' + resourceAvailabilities: {}, + availabilityRequestToken: 0, + suggestionsRequestToken: 0, } }, @@ -142,18 +148,54 @@ export default { resourceBookingEnabled() { return loadState('calendar', 'resource_booking_enabled') }, + + /** + * Resources whose booking outcome is still unknown. Resources that + * already accepted or declined have an authoritative answer from the + * server. An accepted resource must not be probed again because its + * own reservation would show up as busy (VFREEBUSY carries no UIDs). + */ + pendingResources() { + return this.resources.filter((resource) => { + const scheduleStatus = resource.attendeeProperty?.getParameterFirstValue('SCHEDULE-STATUS') ?? '' + return isPendingResourceBooking(resource.participationStatus, scheduleStatus) + }) + }, + + /** + * Cheap watch source for the event time. The store replaces the + * startDate/endDate objects on every change. + */ + eventTimeRange() { + return `${this.calendarObjectInstance.startDate?.getTime()}/${this.calendarObjectInstance.endDate?.getTime()}` + }, }, watch: { resources() { if (this.isViewedByOrganizer) { this.loadRoomSuggestions() + this.checkAvailabilityDebounced() } }, + + eventTimeRange() { + this.loadRoomSuggestions() + this.checkAvailabilityDebounced() + }, + }, + + created() { + // Created per instance, since a debounced function shared across all + // ResourceList instances (e.g. via the methods object) would throw + // when called with a different `this` while a previous call is still + // pending. + this.checkAvailabilityDebounced = debounce(this.checkPendingResourceAvailability.bind(this), 700) }, async mounted() { if (this.isViewedByOrganizer) { + this.checkAvailabilityDebounced() await this.loadRoomSuggestions() } }, @@ -182,6 +224,67 @@ export default { }) }, + /** + * Get the predicted availability of a resource + * + * @param {object} resource The resource attendee object + * @return {?string} One of 'checking', 'available', 'unavailable' or null if unknown + */ + availabilityFor(resource) { + return this.resourceAvailabilities[removeMailtoPrefix(resource.uri)] ?? null + }, + + /** + * Predict the booking outcome of all pending resources with a + * free busy request. The result is only a prediction because the + * authoritative check happens on the server when the event is saved. + * Recurring events are only probed for the edited occurrence. + */ + async checkPendingResourceAvailability() { + if (this.isReadOnly || !this.isViewedByOrganizer || !this.resourceBookingEnabled) { + return + } + + // Invalidate results of any request still in flight + const token = ++this.availabilityRequestToken + + const options = this.pendingResources.map((resource) => ({ + email: removeMailtoPrefix(resource.uri), + isAvailable: true, + })) + if (options.length === 0) { + this.resourceAvailabilities = {} + return + } + + // Kept so a failed request can restore the last known result + // instead of dropping it in favour of an unknown state. + const previousAvailabilities = this.resourceAvailabilities + this.resourceAvailabilities = Object.fromEntries(options.map(({ email }) => [email, 'checking'])) + + try { + await checkResourceAvailability( + options, + this.principalsStore.getCurrentUserPrincipalEmail, + this.calendarObjectInstance.eventComponent.startDate, + this.calendarObjectInstance.eventComponent.endDate, + ) + + if (token !== this.availabilityRequestToken) { + return + } + + this.resourceAvailabilities = Object.fromEntries(options.map(({ email, isAvailable }) => [email, isAvailable ? 'available' : 'unavailable'])) + } catch (error) { + if (token !== this.availabilityRequestToken) { + return + } + + logger.warn('Could not check resource availability', { error }) + this.resourceAvailabilities = previousAvailabilities + } + }, + async loadRoomSuggestions() { if (!this.resourceBookingEnabled) { return @@ -192,6 +295,9 @@ export default { return } + // Invalidate results of any request still in flight + const token = ++this.suggestionsRequestToken + try { logger.info('fetching suggestions for ' + this.attendees.length + ' attendees') const query = { @@ -218,9 +324,17 @@ export default { ) logger.debug('availability of room suggestions fetched', { results }) + if (token !== this.suggestionsRequestToken) { + return + } + // Take the first three available options this.suggestedRooms = results.filter((room) => room.isAvailable).slice(0, 3) } catch (error) { + if (token !== this.suggestionsRequestToken) { + return + } + logger.error('Could not find resources', { error }) this.suggestedRooms = [] } diff --git a/src/components/Editor/Resources/ResourceListItem.vue b/src/components/Editor/Resources/ResourceListItem.vue index a2564a45ac..5ed6d9cd3d 100644 --- a/src/components/Editor/Resources/ResourceListItem.vue +++ b/src/components/Editor/Resources/ResourceListItem.vue @@ -12,6 +12,7 @@ :isSuggestion="isSuggestion" :participationStatus="participationStatus" :scheduleStatus="scheduleStatus" + :availability="availability" :organizerDisplayName="organizerDisplayName" :commonName="commonName" />
@@ -126,6 +127,11 @@ export default { type: Boolean, default: false, }, + + availability: { + type: String, // 'checking' | 'available' | 'unavailable' | null + default: null, + }, }, emits: ['addSuggestion', 'removeResource'], diff --git a/src/components/Editor/Resources/ResourceListSearch.vue b/src/components/Editor/Resources/ResourceListSearch.vue index 477755ebe9..104c91e312 100644 --- a/src/components/Editor/Resources/ResourceListSearch.vue +++ b/src/components/Editor/Resources/ResourceListSearch.vue @@ -111,7 +111,10 @@ export default { matches: [], capacity: NaN, roomType: '', - isAvailable: true, + // Show busy resources too (annotated per result) instead of + // hiding them, so the search doesn't hide the exact conflict + // information this feature is meant to surface. + isAvailable: false, isAccessible: false, hasProjector: false, hasWhiteboard: false, diff --git a/src/services/freeBusyService.js b/src/services/freeBusyService.js index f5265dac45..ac55045bf4 100644 --- a/src/services/freeBusyService.js +++ b/src/services/freeBusyService.js @@ -34,7 +34,6 @@ export async function checkResourceAvailability(options, principalEmail, start, const attendeeEmail = removeMailtoPrefix(attendeeProperty.email) for (const option of options) { if (removeMailtoPrefix(option.email) === attendeeEmail) { - options.participationStatus = '' option.isAvailable = false break } diff --git a/src/utils/attendee.js b/src/utils/attendee.js index 5b4faeb260..66dfd3f117 100644 --- a/src/utils/attendee.js +++ b/src/utils/attendee.js @@ -57,6 +57,19 @@ export function organizerDisplayName(organizer) { return removeMailtoPrefix(organizer.uri) } +/** + * Check if a resource booking is still pending, i.e. the resource has neither + * accepted nor declined and the server has not reported a scheduling result yet + * + * @param {string} participationStatus PARTSTAT of the resource attendee + * @param {string} scheduleStatus SCHEDULE-STATUS parameter value of the resource attendee + * @return {boolean} True if the booking outcome is still unknown + */ +export function isPendingResourceBooking(participationStatus, scheduleStatus) { + return !['ACCEPTED', 'DECLINED', 'TENTATIVE'].includes(participationStatus) + && (!scheduleStatus || scheduleStatus === '1.0') +} + /** * Check if the current user is an attendee * diff --git a/tests/javascript/unit/services/freeBusyService.test.js b/tests/javascript/unit/services/freeBusyService.test.js new file mode 100644 index 0000000000..27fd6e3699 --- /dev/null +++ b/tests/javascript/unit/services/freeBusyService.test.js @@ -0,0 +1,63 @@ +/** + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { AttendeeProperty } from '@nextcloud/calendar-js' +import { checkResourceAvailability } from '../../../../src/services/freeBusyService' +import { doFreeBusyRequest } from '../../../../src/utils/freebusy' + +vi.mock('../../../../src/utils/freebusy') + +describe('services/freeBusyService test suite', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should mark busy resources as unavailable and leave others available', async () => { + doFreeBusyRequest.mockImplementationOnce(async function* () { + yield [new AttendeeProperty('ATTENDEE', 'mailto:room1@localhost'), undefined] + }) + + const options = [ + { email: 'room1@localhost', isAvailable: true }, + { email: 'room2@localhost', isAvailable: true }, + ] + await checkResourceAvailability(options, 'user@localhost', undefined, undefined) + + expect(options[0].isAvailable).toEqual(false) + expect(options[1].isAvailable).toEqual(true) + }) + + it('should match attendees regardless of mailto prefixes', async () => { + doFreeBusyRequest.mockImplementationOnce(async function* () { + yield [new AttendeeProperty('ATTENDEE', 'mailto:room1@localhost'), undefined] + }) + + const options = [ + { email: 'mailto:room1@localhost', isAvailable: true }, + ] + await checkResourceAvailability(options, 'user@localhost', undefined, undefined) + + expect(options[0].isAvailable).toEqual(false) + }) + + it('should not send a request without options', async () => { + await checkResourceAvailability([], 'user@localhost', undefined, undefined) + + expect(doFreeBusyRequest).not.toHaveBeenCalled() + }) + + it('should not assign a participation status to the options array', async () => { + doFreeBusyRequest.mockImplementationOnce(async function* () { + yield [new AttendeeProperty('ATTENDEE', 'mailto:room1@localhost'), undefined] + }) + + const options = [ + { email: 'room1@localhost', isAvailable: true }, + ] + await checkResourceAvailability(options, 'user@localhost', undefined, undefined) + + expect(options).not.toHaveProperty('participationStatus') + }) +}) diff --git a/tests/javascript/unit/utils/attendee.test.js b/tests/javascript/unit/utils/attendee.test.js index 947ac185af..66a646c811 100644 --- a/tests/javascript/unit/utils/attendee.test.js +++ b/tests/javascript/unit/utils/attendee.test.js @@ -5,6 +5,7 @@ import { addMailtoPrefix, + isPendingResourceBooking, organizerDisplayName, removeMailtoPrefix, } from '../../../../src/utils/attendee.js' @@ -48,4 +49,18 @@ describe('utils/attendee test suite', () => { uri, })).toEqual(commonName) }) + + it('should detect pending resource bookings', () => { + expect(isPendingResourceBooking('NEEDS-ACTION', '')).toEqual(true) + expect(isPendingResourceBooking('NEEDS-ACTION', '1.0')).toEqual(true) + expect(isPendingResourceBooking('', '')).toEqual(true) + }) + + it('should not detect answered or failed resource bookings as pending', () => { + expect(isPendingResourceBooking('ACCEPTED', '')).toEqual(false) + expect(isPendingResourceBooking('DECLINED', '2.0')).toEqual(false) + expect(isPendingResourceBooking('TENTATIVE', '')).toEqual(false) + expect(isPendingResourceBooking('NEEDS-ACTION', '3.7')).toEqual(false) + expect(isPendingResourceBooking('NEEDS-ACTION', '5.1')).toEqual(false) + }) })