Skip to content
Merged
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
23 changes: 23 additions & 0 deletions src/components/Editor/AvatarParticipationStatus.vue
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,11 @@ export default {
default: false,
},

availability: {
type: String, // 'checking' | 'available' | 'unavailable' | null
default: null,
},

attendeeIsOrganizer: {
type: Boolean,
required: true,
Expand Down Expand Up @@ -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'),
Expand Down
116 changes: 115 additions & 1 deletion src/components/Editor/Resources/ResourceList.vue
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@
:resource="resource"
:organizerDisplayName="organizerDisplayName"
:isViewedByOrganizer="isViewedByOrganizer"
:availability="availabilityFor(resource)"
@removeResource="removeResource" />

<ResourceListItem
Expand All @@ -55,6 +56,7 @@ import { loadState } from '@nextcloud/initial-state'
import {
NcButton,
} from '@nextcloud/vue'
import debounce from 'debounce'
import { mapStores } from 'pinia'
import DoorOpenIcon from 'vue-material-design-icons/DoorOpen.vue'
import RoomAvailabilityList from '../FreeBusy/RoomAvailabilityList.vue'
Expand All @@ -64,7 +66,7 @@ import { advancedPrincipalPropertySearch } from '../../../services/caldavService
import { checkResourceAvailability } from '../../../services/freeBusyService.js'
import useCalendarObjectInstanceStore from '../../../store/calendarObjectInstance.js'
import usePrincipalsStore from '../../../store/principals.js'
import { organizerDisplayName, removeMailtoPrefix } from '../../../utils/attendee.js'
import { isPendingResourceBooking, organizerDisplayName, removeMailtoPrefix } from '../../../utils/attendee.js'
import logger from '../../../utils/logger.js'
export default {
name: 'ResourceList',
Expand Down Expand Up @@ -92,6 +94,10 @@ export default {
return {
suggestedRooms: [],
showRoomAvailabilityModal: false,
// email -> 'checking' | 'available' | 'unavailable'
resourceAvailabilities: {},
availabilityRequestToken: 0,
suggestionsRequestToken: 0,
}
},

Expand Down Expand Up @@ -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()
}
},
Expand Down Expand Up @@ -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
Expand All @@ -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 = {
Expand All @@ -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 = []
}
Expand Down
6 changes: 6 additions & 0 deletions src/components/Editor/Resources/ResourceListItem.vue
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
:isSuggestion="isSuggestion"
:participationStatus="participationStatus"
:scheduleStatus="scheduleStatus"
:availability="availability"
:organizerDisplayName="organizerDisplayName"
:commonName="commonName" />
<div class="resource-list-item__displayname">
Expand Down Expand Up @@ -126,6 +127,11 @@ export default {
type: Boolean,
default: false,
},

availability: {
type: String, // 'checking' | 'available' | 'unavailable' | null
default: null,
},
},

emits: ['addSuggestion', 'removeResource'],
Expand Down
5 changes: 4 additions & 1 deletion src/components/Editor/Resources/ResourceListSearch.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
1 change: 0 additions & 1 deletion src/services/freeBusyService.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
13 changes: 13 additions & 0 deletions src/utils/attendee.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
*
Expand Down
63 changes: 63 additions & 0 deletions tests/javascript/unit/services/freeBusyService.test.js
Original file line number Diff line number Diff line change
@@ -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')
})
})
15 changes: 15 additions & 0 deletions tests/javascript/unit/utils/attendee.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@

import {
addMailtoPrefix,
isPendingResourceBooking,
organizerDisplayName,
removeMailtoPrefix,
} from '../../../../src/utils/attendee.js'
Expand Down Expand Up @@ -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)
})
})
Loading