From 9cb8129b2d64064dbe652295cb2feedf37ee113e Mon Sep 17 00:00:00 2001 From: Oleksandr Dzhychko Date: Tue, 4 Aug 2026 17:55:35 +0000 Subject: [PATCH] feat(principal): map CalDAV room metadata properties Co-authored-by: Rik Dekker Assisted-by: Claude Opus 4.8 Signed-off-by: Oleksandr Dzhychko --- src/models/principal.js | 7 + src/models/principal/principal.ts | 90 +++++++++ src/types/models/principal.ts | 62 ++++++ .../javascript/unit/models/principal.test.js | 7 + .../unit/models/principal/principal.test.ts | 179 ++++++++++++++++++ 5 files changed, 345 insertions(+) create mode 100644 src/models/principal/principal.ts create mode 100644 src/types/models/principal.ts create mode 100644 tests/javascript/unit/models/principal/principal.test.ts diff --git a/src/models/principal.js b/src/models/principal.js index 1e50eaeed5..e9e053c467 100644 --- a/src/models/principal.js +++ b/src/models/principal.js @@ -10,6 +10,7 @@ import { PRINCIPAL_PREFIX_GROUP, PRINCIPAL_PREFIX_USER, } from './consts.js' +import { mapDavToRoomPrincipalProperties } from '@/models/principal/principal' /** * Creates a complete principal-object based on given props @@ -91,6 +92,11 @@ function mapDavToPrincipal(dav) { const url = dav.principalUrl const userId = dav.userId + let roomProperties + if (isCalendarRoom) { + roomProperties = mapDavToRoomPrincipalProperties(dav) + } + return getDefaultPrincipalObject({ id, calendarUserType, @@ -107,6 +113,7 @@ function mapDavToPrincipal(dav) { principalId, userId, scheduleDefaultCalendarUrl, + ...roomProperties, }) } diff --git a/src/models/principal/principal.ts b/src/models/principal/principal.ts new file mode 100644 index 0000000000..1040d6ea62 --- /dev/null +++ b/src/models/principal/principal.ts @@ -0,0 +1,90 @@ +/** + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import type { RoomPrincipalProperties } from '@/types/models/principal' + +import logger from '@/utils/logger' + +// NOTE File can be merged with @/models/principal.js after it was migrated to TypeScript. + +/** + * Extract properties specific to a room from a dav principal. + * + * @param dav cdav-library Principal object + * @see {@link https://github.com/nextcloud/cdav-library/blob/main/src/models/principal.js} for the available properties on {@link dav} + */ +function mapDavToRoomPrincipalProperties(dav: Record): RoomPrincipalProperties { + const properties: RoomPrincipalProperties = { + roomType: null, + roomSeatingCapacity: null, + roomBuildingAddress: null, + roomBuildingStory: null, + roomBuildingRoomNumber: null, + roomFeatures: null, + roomAddress: null, + } + + if (typeof dav.roomType === 'string') { + properties.roomType = dav.roomType + } else if (dav.roomType !== undefined) { + logger.warn('Could not extract `roomType`.', { dav }) + } + + if (typeof dav.roomSeatingCapacity === 'number') { + if (Number.isInteger(dav.roomSeatingCapacity) && dav.roomSeatingCapacity > 0) { + properties.roomSeatingCapacity = dav.roomSeatingCapacity + } else { + logger.warn('`roomSeatingCapacity` is not a positive integer.', { dav }) + } + } else if (typeof dav.roomSeatingCapacity === 'string') { + const parsed = Number(dav.roomSeatingCapacity) + if (Number.isInteger(parsed) && parsed > 0) { + properties.roomSeatingCapacity = parsed + } else { + logger.warn('Could not parse `roomSeatingCapacity` as a positive integer.', { dav }) + } + } else if (dav.roomSeatingCapacity !== undefined) { + logger.warn('Could not extract `roomSeatingCapacity`.', { dav }) + } + + if (typeof dav.roomBuildingAddress === 'string') { + properties.roomBuildingAddress = dav.roomBuildingAddress + } else if (dav.roomBuildingAddress !== undefined) { + logger.warn('Could not extract `roomBuildingAddress`.', { dav }) + } + + if (typeof dav.roomBuildingStory === 'string') { + properties.roomBuildingStory = dav.roomBuildingStory + } else if (dav.roomBuildingStory !== undefined) { + logger.warn('Could not extract `roomBuildingStory`.', { dav }) + } + + if (typeof dav.roomBuildingRoomNumber === 'string') { + properties.roomBuildingRoomNumber = dav.roomBuildingRoomNumber + } else if (dav.roomBuildingRoomNumber !== undefined) { + logger.warn('Could not extract `roomBuildingRoomNumber`.', { dav }) + } + + if (typeof dav.roomFeatures === 'string') { + const featureParts = dav.roomFeatures.split(',') + const features = featureParts + .map((feature) => feature.trim()) + .filter((feature) => feature.length > 0) + + properties.roomFeatures = features + } else if (dav.roomFeatures !== undefined) { + logger.warn('Could not extract `roomFeatures`.', { dav }) + } + + if (typeof dav.roomAddress === 'string') { + properties.roomAddress = dav.roomAddress + } else if (dav.roomAddress !== undefined) { + logger.warn('Could not extract `roomAddress`.', { dav }) + } + + return properties +} + +export { mapDavToRoomPrincipalProperties } diff --git a/src/types/models/principal.ts b/src/types/models/principal.ts new file mode 100644 index 0000000000..6d5695ba21 --- /dev/null +++ b/src/types/models/principal.ts @@ -0,0 +1,62 @@ +/** + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +/** + * Properties shared between all principal types. + */ +export interface BasePrincipalProperties { + id: string + displayname: string | null + emailAddress: string | null + calendarUserType: string +} + +export interface RoomPrincipal extends RoomPrincipalProperties, BasePrincipalProperties {} + +/** + * Properties specific to a {@link RoomPrincipal} + * + * @remarks + * Each property is `null` when it was not set on the original + * `cdav-library` dav principal object, or when the value was invalid. + */ +export interface RoomPrincipalProperties { + /** + * Type of room. + * `null` if not provided or invalid. + */ + roomType: string | null + /** + * Maximum number of people the room can seat. + * `null` if not provided or invalid. + */ + roomSeatingCapacity: number | null + /** + * Street address of the building the room is in. + * `null` if not provided or invalid. + */ + roomBuildingAddress: string | null + /** + * Floor/story of the building the room is on. + * `null` if not provided or invalid. + */ + roomBuildingStory: string | null + /** + * Room number within the building. + * `null` if not provided or invalid. + */ + roomBuildingRoomNumber: string | null + /** + * Features available in the room. + * `null` if not set or invalid. + * `[]` if set with no features. + */ + roomFeatures: string[] | null + /** + * Address of the room itself. + * `null` if not set or invalid. + */ + roomAddress: string | null +} diff --git a/tests/javascript/unit/models/principal.test.js b/tests/javascript/unit/models/principal.test.js index 12d65a3769..1aec7c4a1d 100644 --- a/tests/javascript/unit/models/principal.test.js +++ b/tests/javascript/unit/models/principal.test.js @@ -217,6 +217,13 @@ describe('Test suite: Principal model (models/principal.js)', () => { isCalendarRoom: true, principalId: 'room-123', userId: null, + roomAddress: null, + roomBuildingAddress: null, + roomBuildingStory: null, + roomBuildingRoomNumber: null, + roomFeatures: null, + roomSeatingCapacity: null, + roomType: null, }) }) diff --git a/tests/javascript/unit/models/principal/principal.test.ts b/tests/javascript/unit/models/principal/principal.test.ts new file mode 100644 index 0000000000..d4d3018ab8 --- /dev/null +++ b/tests/javascript/unit/models/principal/principal.test.ts @@ -0,0 +1,179 @@ +/** + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { mapDavToRoomPrincipalProperties } from '@/models/principal/principal' +import logger from '@/utils/logger' +vi.mock('@/utils/logger') + +describe('Test suite: Principal model (models/principal/principal.ts)', () => { + beforeEach(() => { + logger.warn.mockClear() + }) + + describe('mapDavToRoomPrincipalProperties', () => { + describe('roomType', () => { + it.for([ + ['meeting-room', 'meeting-room'], + [undefined, null], + ])('should accept %s', ([input, expected]) => { + const properties = mapDavToRoomPrincipalProperties({ roomType: input }) + + expect(properties).toMatchObject({ roomType: expected }) + expect(logger.warn).toHaveBeenCalledTimes(0) + }) + + it.for([ + [null, 'Could not extract `roomType`.'], + [true, 'Could not extract `roomType`.'], + ])('should reject %s', ([input, warning]) => { + const properties = mapDavToRoomPrincipalProperties({ roomType: input }) + + expect(properties).toMatchObject({ roomType: null }) + expect(logger.warn).toHaveBeenCalledExactlyOnceWith(warning, { dav: { roomType: input } }) + }) + }) + + describe('roomSeatingCapacity', () => { + it.for([ + [12, 12], + ['12', 12], + [undefined, null], + ])('should accept %s', ([input, expected]) => { + const properties = mapDavToRoomPrincipalProperties({ roomSeatingCapacity: input }) + + expect(properties).toMatchObject({ roomSeatingCapacity: expected }) + expect(logger.warn).toHaveBeenCalledTimes(0) + }) + + it.for([ + [null, 'Could not extract `roomSeatingCapacity`.'], + [0, '`roomSeatingCapacity` is not a positive integer.'], + [-5, '`roomSeatingCapacity` is not a positive integer.'], + [12.5, '`roomSeatingCapacity` is not a positive integer.'], + [true, 'Could not extract `roomSeatingCapacity`.'], + ['33p', 'Could not parse `roomSeatingCapacity` as a positive integer.'], + ['-33', 'Could not parse `roomSeatingCapacity` as a positive integer.'], + ])('should reject %s', ([input, warning]) => { + const properties = mapDavToRoomPrincipalProperties({ roomSeatingCapacity: input }) + + expect(properties).toMatchObject({ roomSeatingCapacity: null }) + expect(logger.warn).toHaveBeenCalledExactlyOnceWith(warning, { dav: { roomSeatingCapacity: input } }) + }) + }) + }) + + describe('roomBuildingAddress', () => { + it.for([ + ['anAdreess', 'anAdreess'], + [undefined, null], + ])('should accept %s', ([input, expected]) => { + const properties = mapDavToRoomPrincipalProperties({ roomBuildingAddress: input }) + + expect(properties).toMatchObject({ roomBuildingAddress: expected }) + expect(logger.warn).toHaveBeenCalledTimes(0) + }) + + it.for([ + [null, 'Could not extract `roomBuildingAddress`.'], + [true, 'Could not extract `roomBuildingAddress`.'], + ])('should reject %s', ([input, warning]) => { + const properties = mapDavToRoomPrincipalProperties({ roomBuildingAddress: input }) + + expect(properties).toMatchObject({ roomBuildingAddress: null }) + expect(logger.warn).toHaveBeenCalledExactlyOnceWith(warning, { dav: { roomBuildingAddress: input } }) + }) + }) + + describe('roomBroomBuildingStoryuildingAddress', () => { + it.for([ + ['aFloor', 'aFloor'], + [undefined, null], + ])('should accept %s', ([input, expected]) => { + const properties = mapDavToRoomPrincipalProperties({ roomBuildingStory: input }) + + expect(properties).toMatchObject({ roomBuildingStory: expected }) + expect(logger.warn).toHaveBeenCalledTimes(0) + }) + + it.for([ + [null, 'Could not extract `roomBuildingStory`.'], + [true, 'Could not extract `roomBuildingStory`.'], + ])('should reject %s', ([input, warning]) => { + const properties = mapDavToRoomPrincipalProperties({ roomBuildingStory: input }) + + expect(properties).toMatchObject({ roomBuildingStory: null }) + expect(logger.warn).toHaveBeenCalledExactlyOnceWith(warning, { dav: { roomBuildingStory: input } }) + }) + }) + + describe('roomBuildingRoomNumber', () => { + it.for([ + ['aRoomNumber', 'aRoomNumber'], + [undefined, null], + ])('should accept %s', ([input, expected]) => { + const properties = mapDavToRoomPrincipalProperties({ roomBuildingRoomNumber: input }) + + expect(properties).toMatchObject({ roomBuildingRoomNumber: expected }) + expect(logger.warn).toHaveBeenCalledTimes(0) + }) + + it.for([ + [null, 'Could not extract `roomBuildingRoomNumber`.'], + [true, 'Could not extract `roomBuildingRoomNumber`.'], + ])('should reject %s', ([input, warning]) => { + const properties = mapDavToRoomPrincipalProperties({ roomBuildingRoomNumber: input }) + + expect(properties).toMatchObject({ roomBuildingRoomNumber: null }) + expect(logger.warn).toHaveBeenCalledExactlyOnceWith(warning, { dav: { roomBuildingRoomNumber: input } }) + }) + }) + + describe('roomFeatures', () => { + it.for([ + ['feature1, feature 2', ['feature1', 'feature 2']], + ['feature1,', ['feature1']], + ['', []], + [undefined, null], + ])('should accept %s', ([input, expected]) => { + const properties = mapDavToRoomPrincipalProperties({ roomFeatures: input }) + + expect(properties).toMatchObject({ roomFeatures: expected }) + expect(logger.warn).toHaveBeenCalledTimes(0) + }) + + it.for([ + [null, 'Could not extract `roomFeatures`.'], + [[], 'Could not extract `roomFeatures`.'], + [true, 'Could not extract `roomFeatures`.'], + ])('should reject %s', ([input, warning]) => { + const properties = mapDavToRoomPrincipalProperties({ roomFeatures: input }) + + expect(properties).toMatchObject({ roomFeatures: null }) + expect(logger.warn).toHaveBeenCalledExactlyOnceWith(warning, { dav: { roomFeatures: input } }) + }) + }) + + describe('roomAddress', () => { + it.for([ + ['anAdreess', 'anAdreess'], + [undefined, null], + ])('should accept %s', ([input, expected]) => { + const properties = mapDavToRoomPrincipalProperties({ roomAddress: input }) + + expect(properties).toMatchObject({ roomAddress: expected }) + expect(logger.warn).toHaveBeenCalledTimes(0) + }) + + it.for([ + [null, 'Could not extract `roomAddress`.'], + [true, 'Could not extract `roomAddress`.'], + ])('should reject %s', ([input, warning]) => { + const properties = mapDavToRoomPrincipalProperties({ roomAddress: input }) + + expect(properties).toMatchObject({ roomAddress: null }) + expect(logger.warn).toHaveBeenCalledExactlyOnceWith(warning, { dav: { roomAddress: input } }) + }) + }) +})