From 0c60eb1b18ef8112c4ee4e3f366e5b1ee32c2834 Mon Sep 17 00:00:00 2001
From: Maksim Sukharev <antreesy.web@gmail.com>
Date: Mon, 16 Dec 2024 15:25:37 +0100
Subject: [PATCH] fix(integrations): move groupware-related API to dedicated
 store

Signed-off-by: Maksim Sukharev <antreesy.web@gmail.com>
---
 src/components/NewMessage/NewMessage.vue |   8 +-
 src/components/TopBar/TopBar.vue         |   8 +-
 src/services/conversationsService.js     |  14 ---
 src/services/coreService.ts              |  11 ---
 src/services/groupwareService.ts         |  37 ++++++++
 src/store/conversationsStore.js          |   3 +
 src/stores/__tests__/chatExtras.spec.js  |  64 --------------
 src/stores/__tests__/groupware.spec.js   |  95 ++++++++++++++++++++
 src/stores/chatExtras.js                 |  55 ------------
 src/stores/groupware.ts                  | 105 +++++++++++++++++++++++
 src/types/index.ts                       |  38 +++++---
 11 files changed, 275 insertions(+), 163 deletions(-)
 create mode 100644 src/services/groupwareService.ts
 create mode 100644 src/stores/__tests__/groupware.spec.js
 create mode 100644 src/stores/groupware.ts

diff --git a/src/components/NewMessage/NewMessage.vue b/src/components/NewMessage/NewMessage.vue
index b127a3ce98d..4bf9ecb15dd 100644
--- a/src/components/NewMessage/NewMessage.vue
+++ b/src/components/NewMessage/NewMessage.vue
@@ -218,6 +218,7 @@ import { getTalkConfig, hasTalkFeature } from '../../services/CapabilitiesManage
 import { EventBus } from '../../services/EventBus.ts'
 import { shareFile } from '../../services/filesSharingServices.js'
 import { useChatExtrasStore } from '../../stores/chatExtras.js'
+import { useGroupwareStore } from '../../stores/groupware.ts'
 import { useSettingsStore } from '../../stores/settings.js'
 import { fetchClipboardContent } from '../../utils/clipboard.js'
 import { parseSpecialSymbols } from '../../utils/textParse.ts'
@@ -312,6 +313,7 @@ export default {
 		const { createTemporaryMessage } = useTemporaryMessage()
 		return {
 			chatExtrasStore: useChatExtrasStore(),
+			groupwareStore: useGroupwareStore(),
 			settingsStore: useSettingsStore(),
 			supportTypingStatus,
 			autoComplete,
@@ -474,7 +476,7 @@ export default {
 		},
 
 		userAbsence() {
-			return this.chatExtrasStore.absence[this.token]
+			return this.groupwareStore.absence[this.token]
 		},
 
 		showChatSummary() {
@@ -988,13 +990,13 @@ export default {
 			// TODO replace with status message id 'vacationing'
 			if (this.conversation.status === 'dnd') {
 				// Fetch actual absence status from server
-				await this.chatExtrasStore.getUserAbsence({
+				await this.groupwareStore.getUserAbsence({
 					token: this.token,
 					userId: this.conversation.name,
 				})
 			} else {
 				// Remove stored absence status
-				this.chatExtrasStore.removeUserAbsence(this.token)
+				this.groupwareStore.removeUserAbsence(this.token)
 			}
 		},
 
diff --git a/src/components/TopBar/TopBar.vue b/src/components/TopBar/TopBar.vue
index 7808665eca3..d6db40ead24 100644
--- a/src/components/TopBar/TopBar.vue
+++ b/src/components/TopBar/TopBar.vue
@@ -144,7 +144,7 @@ import ConversationIcon from '../ConversationIcon.vue'
 import { useGetParticipants } from '../../composables/useGetParticipants.js'
 import { AVATAR, CONVERSATION } from '../../constants.js'
 import { getTalkConfig } from '../../services/CapabilitiesManager.ts'
-import { useChatExtrasStore } from '../../stores/chatExtras.js'
+import { useGroupwareStore } from '../../stores/groupware.ts'
 import { useSidebarStore } from '../../stores/sidebar.js'
 import { getStatusMessage } from '../../utils/userStatus.ts'
 import { localCallParticipantModel, localMediaModel } from '../../utils/webrtc/index.js'
@@ -193,7 +193,7 @@ export default {
 			AVATAR,
 			localCallParticipantModel,
 			localMediaModel,
-			chatExtrasStore: useChatExtrasStore(),
+			groupwareStore: useGroupwareStore(),
 			sidebarStore: useSidebarStore(),
 			isMobile: useIsMobile(),
 			CONVERSATION,
@@ -284,7 +284,7 @@ export default {
 		},
 
 		nextEvent() {
-			return this.chatExtrasStore.getNextEvent(this.token)
+			return this.groupwareStore.getNextEvent(this.token)
 		},
 
 		eventInfo() {
@@ -318,7 +318,7 @@ export default {
 					// Do not fetch upcoming events for guests (401 unauthorzied) or in sidebar
 					return
 				}
-				this.chatExtrasStore.getUpcomingEvents(value)
+				this.groupwareStore.getUpcomingEvents(value)
 			}
 		},
 	},
diff --git a/src/services/conversationsService.js b/src/services/conversationsService.js
index 1a595acef8d..200a65f64e9 100644
--- a/src/services/conversationsService.js
+++ b/src/services/conversationsService.js
@@ -21,19 +21,6 @@ const fetchConversations = async function(options) {
 	return axios.get(generateOcsUrl('apps/spreed/api/v4/room'), options)
 }
 
-/**
- * fetch future events for a given conversation within the next 31 days.
- *
- * @param {string} location room's absolute url
- */
-const getUpcomingEvents = async (location) => {
-	return axios.get(generateOcsUrl('/apps/dav/api/v1/events/upcoming'), {
-		params: {
-			location,
-		},
-	})
-}
-
 /**
  * Fetches a conversation from the server.
  *
@@ -356,7 +343,6 @@ export {
 	fetchConversations,
 	fetchConversation,
 	fetchNoteToSelfConversation,
-	getUpcomingEvents,
 	searchListedConversations,
 	createOneToOneConversation,
 	createGroupConversation,
diff --git a/src/services/coreService.ts b/src/services/coreService.ts
index a9f155905b3..5010d0dfa05 100644
--- a/src/services/coreService.ts
+++ b/src/services/coreService.ts
@@ -9,7 +9,6 @@ import { generateOcsUrl } from '@nextcloud/router'
 import { getTalkConfig, hasTalkFeature } from './CapabilitiesManager.ts'
 import { SHARE } from '../constants.js'
 import type {
-	OutOfOfficeResponse,
 	TaskProcessingResponse,
 } from '../types/index.ts'
 
@@ -64,18 +63,8 @@ const deleteTaskById = async function(id: number, options?: object): Promise<nul
 	return axios.delete(generateOcsUrl('taskprocessing/task/{id}', { id }), options)
 }
 
-/**
- * Get absence information for a user (in a given 1-1 conversation).
- *
- * @param userId user id
- */
-const getUserAbsence = async (userId: string): OutOfOfficeResponse => {
-	return axios.get(generateOcsUrl('/apps/dav/api/v1/outOfOffice/{userId}/now', { userId }))
-}
-
 export {
 	autocompleteQuery,
 	getTaskById,
 	deleteTaskById,
-	getUserAbsence,
 }
diff --git a/src/services/groupwareService.ts b/src/services/groupwareService.ts
new file mode 100644
index 00000000000..4a1a9cd53e9
--- /dev/null
+++ b/src/services/groupwareService.ts
@@ -0,0 +1,37 @@
+/**
+ * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import axios from '@nextcloud/axios'
+import { generateOcsUrl } from '@nextcloud/router'
+
+import type {
+	OutOfOfficeResponse,
+	UpcomingEventsResponse,
+} from '../types/index.ts'
+
+/**
+ * Get upcoming events for a given conversation within the next 31 days.
+ * @param location conversation's absolute URL
+ */
+const getUpcomingEvents = async (location: string): UpcomingEventsResponse => {
+	return axios.get(generateOcsUrl('/apps/dav/api/v1/events/upcoming'), {
+		params: {
+			location,
+		},
+	})
+}
+
+/**
+ * Get absence information for a user (in a given 1-1 conversation).
+ * @param userId user id
+ */
+const getUserAbsence = async (userId: string): OutOfOfficeResponse => {
+	return axios.get(generateOcsUrl('/apps/dav/api/v1/outOfOffice/{userId}/now', { userId }))
+}
+
+export {
+	getUpcomingEvents,
+	getUserAbsence,
+}
diff --git a/src/store/conversationsStore.js b/src/store/conversationsStore.js
index 76979c6556e..d87d4b05168 100644
--- a/src/store/conversationsStore.js
+++ b/src/store/conversationsStore.js
@@ -63,6 +63,7 @@ import { talkBroadcastChannel } from '../services/talkBroadcastChannel.js'
 import { useBreakoutRoomsStore } from '../stores/breakoutRooms.ts'
 import { useChatExtrasStore } from '../stores/chatExtras.js'
 import { useFederationStore } from '../stores/federation.ts'
+import { useGroupwareStore } from '../stores/groupware.ts'
 import { useReactionsStore } from '../stores/reactions.js'
 import { useTalkHashStore } from '../stores/talkHash.js'
 
@@ -370,6 +371,8 @@ const actions = {
 		// FIXME: rename to deleteConversationsFromStore or a better name
 		const chatExtrasStore = useChatExtrasStore()
 		chatExtrasStore.purgeChatExtras(token)
+		const groupwareStore = useGroupwareStore()
+		groupwareStore.purgeGroupwareStore(token)
 		const reactionsStore = useReactionsStore()
 		reactionsStore.purgeReactionsStore(token)
 		context.dispatch('purgeMessagesStore', token)
diff --git a/src/stores/__tests__/chatExtras.spec.js b/src/stores/__tests__/chatExtras.spec.js
index b330991daff..41af7af03fa 100644
--- a/src/stores/__tests__/chatExtras.spec.js
+++ b/src/stores/__tests__/chatExtras.spec.js
@@ -5,19 +5,11 @@
 import { setActivePinia, createPinia } from 'pinia'
 
 import BrowserStorage from '../../services/BrowserStorage.js'
-import { getUserAbsence } from '../../services/coreService.ts'
 import { EventBus } from '../../services/EventBus.ts'
-import { generateOCSErrorResponse, generateOCSResponse } from '../../test-helpers.js'
 import { useChatExtrasStore } from '../chatExtras.js'
 
-jest.mock('../../services/coreService', () => ({
-	getUserAbsence: jest.fn(),
-}))
-
 describe('chatExtrasStore', () => {
 	const token = 'TOKEN'
-	const userId = 'alice'
-	const payload = { id: 1, userId: 'alice', firstDay: '2023-11-15', lastDay: '2023-11-17', status: 'absence status', message: 'absence message' }
 	let chatExtrasStore
 
 	beforeEach(async () => {
@@ -29,57 +21,6 @@ describe('chatExtrasStore', () => {
 		jest.clearAllMocks()
 	})
 
-	describe('absence status', () => {
-		it('processes a response from server and stores absence status', async () => {
-			// Arrange
-			const response = generateOCSResponse({ payload })
-			getUserAbsence.mockResolvedValueOnce(response)
-
-			// Act
-			await chatExtrasStore.getUserAbsence({ token, userId })
-
-			// Assert
-			expect(getUserAbsence).toHaveBeenCalledWith(userId)
-			expect(chatExtrasStore.absence[token]).toEqual(payload)
-		})
-
-		it('does not show error if absence status is not found', async () => {
-			// Arrange
-			const errorNotFound = generateOCSErrorResponse({ payload: null, status: 404 })
-			const errorOther = generateOCSErrorResponse({ payload: null, status: 500 })
-			getUserAbsence
-				.mockRejectedValueOnce(errorNotFound)
-				.mockRejectedValueOnce(errorOther)
-			console.error = jest.fn()
-
-			// Act
-			await chatExtrasStore.getUserAbsence({ token, userId })
-			await chatExtrasStore.getUserAbsence({ token, userId })
-
-			// Assert
-			expect(getUserAbsence).toHaveBeenCalledTimes(2)
-			expect(console.error).toHaveBeenCalledTimes(1)
-			expect(chatExtrasStore.absence[token]).toEqual(null)
-		})
-
-		it('removes absence status from the store', async () => {
-			// Arrange
-			const response = generateOCSResponse({ payload })
-			getUserAbsence.mockResolvedValueOnce(response)
-			const token2 = 'TOKEN_2'
-
-			// Act
-			await chatExtrasStore.getUserAbsence({ token, userId })
-			chatExtrasStore.removeUserAbsence(token)
-			chatExtrasStore.removeUserAbsence(token2)
-
-			// Assert
-			expect(chatExtrasStore.absence[token]).not.toBeDefined()
-			expect(chatExtrasStore.absence[token2]).not.toBeDefined()
-		})
-
-	})
-
 	describe('reply message', () => {
 		it('adds reply message id to the store', () => {
 			// Act
@@ -171,10 +112,6 @@ describe('chatExtrasStore', () => {
 	describe('purge store', () => {
 		it('clears store for provided token', async () => {
 			// Arrange
-			const response = generateOCSResponse({ payload })
-			getUserAbsence.mockResolvedValueOnce(response)
-
-			await chatExtrasStore.getUserAbsence({ token: 'token-1', userId })
 			chatExtrasStore.setParentIdToReply({ token: 'token-1', id: 101 })
 			chatExtrasStore.setChatInput({ token: 'token-1', text: 'message-1' })
 
@@ -182,7 +119,6 @@ describe('chatExtrasStore', () => {
 			chatExtrasStore.purgeChatExtras('token-1')
 
 			// Assert
-			expect(chatExtrasStore.absence['token-1']).not.toBeDefined()
 			expect(chatExtrasStore.parentToReply['token-1']).not.toBeDefined()
 			expect(chatExtrasStore.chatInput['token-1']).not.toBeDefined()
 		})
diff --git a/src/stores/__tests__/groupware.spec.js b/src/stores/__tests__/groupware.spec.js
new file mode 100644
index 00000000000..21cc22438b8
--- /dev/null
+++ b/src/stores/__tests__/groupware.spec.js
@@ -0,0 +1,95 @@
+/**
+ * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+import { setActivePinia, createPinia } from 'pinia'
+
+import { getUserAbsence } from '../../services/groupwareService.ts'
+import { generateOCSErrorResponse, generateOCSResponse } from '../../test-helpers.js'
+import { useGroupwareStore } from '../groupware.ts'
+
+jest.mock('../../services/groupwareService', () => ({
+	getUserAbsence: jest.fn(),
+}))
+
+describe('groupwareStore', () => {
+	const token = 'TOKEN'
+	const userId = 'alice'
+	const payload = { id: 1, userId: 'alice', firstDay: '2023-11-15', lastDay: '2023-11-17', status: 'absence status', message: 'absence message' }
+	let groupwareStore
+
+	beforeEach(async () => {
+		setActivePinia(createPinia())
+		groupwareStore = useGroupwareStore()
+	})
+
+	afterEach(async () => {
+		jest.clearAllMocks()
+	})
+
+	describe('absence status', () => {
+		it('processes a response from server and stores absence status', async () => {
+			// Arrange
+			const response = generateOCSResponse({ payload })
+			getUserAbsence.mockResolvedValueOnce(response)
+
+			// Act
+			await groupwareStore.getUserAbsence({ token, userId })
+
+			// Assert
+			expect(getUserAbsence).toHaveBeenCalledWith(userId)
+			expect(groupwareStore.absence[token]).toEqual(payload)
+		})
+
+		it('does not show error if absence status is not found', async () => {
+			// Arrange
+			const errorNotFound = generateOCSErrorResponse({ payload: null, status: 404 })
+			const errorOther = generateOCSErrorResponse({ payload: null, status: 500 })
+			getUserAbsence
+				.mockRejectedValueOnce(errorNotFound)
+				.mockRejectedValueOnce(errorOther)
+			console.error = jest.fn()
+
+			// Act
+			await groupwareStore.getUserAbsence({ token, userId })
+			await groupwareStore.getUserAbsence({ token, userId })
+
+			// Assert
+			expect(getUserAbsence).toHaveBeenCalledTimes(2)
+			expect(console.error).toHaveBeenCalledTimes(1)
+			expect(groupwareStore.absence[token]).toEqual(null)
+		})
+
+		it('removes absence status from the store', async () => {
+			// Arrange
+			const response = generateOCSResponse({ payload })
+			getUserAbsence.mockResolvedValueOnce(response)
+			const token2 = 'TOKEN_2'
+
+			// Act
+			await groupwareStore.getUserAbsence({ token, userId })
+			groupwareStore.removeUserAbsence(token)
+			groupwareStore.removeUserAbsence(token2)
+
+			// Assert
+			expect(groupwareStore.absence[token]).not.toBeDefined()
+			expect(groupwareStore.absence[token2]).not.toBeDefined()
+		})
+	})
+
+	describe('purge store', () => {
+		it('clears store for provided token', async () => {
+			// Arrange
+			const response = generateOCSResponse({ payload })
+			getUserAbsence.mockResolvedValueOnce(response)
+
+			await groupwareStore.getUserAbsence({ token: 'token-1', userId })
+
+			// Act
+			groupwareStore.purgeGroupwareStore('token-1')
+
+			// Assert
+			expect(groupwareStore.absence['token-1']).not.toBeDefined()
+		})
+	})
+})
diff --git a/src/stores/chatExtras.js b/src/stores/chatExtras.js
index 099ed652e9a..057b1b4dfd1 100644
--- a/src/stores/chatExtras.js
+++ b/src/stores/chatExtras.js
@@ -7,11 +7,8 @@ import { defineStore } from 'pinia'
 import Vue from 'vue'
 
 import { t } from '@nextcloud/l10n'
-import { generateUrl, getBaseUrl } from '@nextcloud/router'
 
 import BrowserStorage from '../services/BrowserStorage.js'
-import { getUpcomingEvents } from '../services/conversationsService.js'
-import { getUserAbsence } from '../services/coreService.ts'
 import { EventBus } from '../services/EventBus.ts'
 import { summarizeChat } from '../services/messagesService.ts'
 import { parseSpecialSymbols, parseMentions } from '../utils/textParse.ts'
@@ -22,7 +19,6 @@ import { parseSpecialSymbols, parseMentions } from '../utils/textParse.ts'
 
 /**
  * @typedef {object} State
- * @property {{[key: Token]: object}} absence - The absence status per conversation.
  * @property {{[key: Token]: number}} parentToReply - The parent message id to reply per conversation.
  * @property {{[key: Token]: string}} chatInput -The input value per conversation.
  */
@@ -35,9 +31,7 @@ import { parseSpecialSymbols, parseMentions } from '../utils/textParse.ts'
  */
 export const useChatExtrasStore = defineStore('chatExtras', {
 	state: () => ({
-		absence: {},
 		parentToReply: {},
-		upcomingEvents: {},
 		chatInput: {},
 		messageIdToEdit: {},
 		chatEditInput: {},
@@ -61,10 +55,6 @@ export const useChatExtrasStore = defineStore('chatExtras', {
 			return state.messageIdToEdit[token]
 		},
 
-		getNextEvent: (state) => (token) => {
-			return state.upcomingEvents[token]?.[0]
-		},
-
 		getChatSummaryTaskQueue: (state) => (token) => {
 			return Object.values(Object(state.chatSummary[token]))
 		},
@@ -93,50 +83,6 @@ export const useChatExtrasStore = defineStore('chatExtras', {
 			return this.chatInput[token] ?? ''
 		},
 
-		/**
-		 * Fetch an absence status for user and save to store
-		 *
-		 * @param {object} payload action payload
-		 * @param {string} payload.token The conversation token
-		 * @param {string} payload.userId The id of user
-		 *
-		 */
-		async getUserAbsence({ token, userId }) {
-			try {
-				const response = await getUserAbsence(userId)
-				Vue.set(this.absence, token, response.data.ocs.data)
-				return this.absence[token]
-			} catch (error) {
-				if (error?.response?.status === 404) {
-					Vue.set(this.absence, token, null)
-					return null
-				}
-				console.error(error)
-			}
-		},
-
-		async getUpcomingEvents(token) {
-			const location = generateUrl('call/{token}', { token }, { baseURL: getBaseUrl() })
-			try {
-				const response = await getUpcomingEvents(location)
-				Vue.set(this.upcomingEvents, token, response.data.ocs.data.events)
-			} catch (error) {
-				console.error(error)
-			}
-		},
-
-		/**
-		 * Drop an absence status from the store
-		 *
-		 * @param {string} token The conversation token
-		 *
-		 */
-		removeUserAbsence(token) {
-			if (this.absence[token]) {
-				Vue.delete(this.absence, token)
-			}
-		},
-
 		/**
 		 * Add a reply message id to the store
 		 *
@@ -255,7 +201,6 @@ export const useChatExtrasStore = defineStore('chatExtras', {
 		 */
 		purgeChatExtras(token) {
 			this.removeParentIdToReply(token)
-			this.removeUserAbsence(token)
 			this.removeChatInput(token)
 		},
 
diff --git a/src/stores/groupware.ts b/src/stores/groupware.ts
new file mode 100644
index 00000000000..b51c7a591dd
--- /dev/null
+++ b/src/stores/groupware.ts
@@ -0,0 +1,105 @@
+/**
+ * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import { defineStore } from 'pinia'
+import Vue from 'vue'
+
+import type { AxiosError } from '@nextcloud/axios'
+import { generateUrl, getBaseUrl } from '@nextcloud/router'
+
+import {
+	getUpcomingEvents,
+	getUserAbsence,
+} from '../services/groupwareService.ts'
+import type {
+	OutOfOfficeResult,
+	UpcomingEvent,
+} from '../types/index.ts'
+
+type State = {
+	absence: Record<string, OutOfOfficeResult>
+	upcomingEvents: Record<string, UpcomingEvent[]>
+}
+
+export const useGroupwareStore = defineStore('groupware', {
+	state: (): State => ({
+		absence: {},
+		upcomingEvents: {},
+	}),
+
+	getters: {
+		getAllEvents: (state) => (token: string) => {
+			return state.upcomingEvents[token]
+		},
+		getNextEvent: (state) => (token: string) => {
+			return state.upcomingEvents[token]?.[0]
+		},
+	},
+
+	actions: {
+		/**
+		 * Fetch an absence status for user and save to store
+		 * @param payload action payload
+		 * @param payload.token The conversation token
+		 * @param payload.userId The id of user
+		 */
+		async getUserAbsence({ token, userId } : { token: string, userId: string}) {
+			try {
+				const response = await getUserAbsence(userId)
+				Vue.set(this.absence, token, response.data.ocs.data)
+				return this.absence[token]
+			} catch (error) {
+				if ((error as AxiosError)?.response?.status === 404) {
+					Vue.set(this.absence, token, null)
+					return null
+				}
+				console.error(error)
+			}
+		},
+
+		/**
+		 * Fetch upcoming events for conversation and save to store
+		 * @param token The conversation token
+		 */
+		async getUpcomingEvents(token: string) {
+			const location = generateUrl('call/{token}', { token }, { baseURL: getBaseUrl() })
+			try {
+				const response = await getUpcomingEvents(location)
+				Vue.set(this.upcomingEvents, token, response.data.ocs.data.events)
+			} catch (error) {
+				console.error(error)
+			}
+		},
+
+		/**
+		 * Drop an absence status from the store
+		 * @param token The conversation token
+		 */
+		removeUserAbsence(token: string) {
+			if (this.absence[token]) {
+				Vue.delete(this.absence, token)
+			}
+		},
+
+		/**
+		 * Drop upcoming events from the store
+		 * @param token The conversation token
+		 */
+		removeUpcomingEvents(token: string) {
+			if (this.upcomingEvents[token]) {
+				Vue.delete(this.upcomingEvents, token)
+			}
+		},
+
+		/**
+		 * Clears store for a deleted conversation
+		 * @param token The conversation token
+		 */
+		purgeGroupwareStore(token: string) {
+			this.removeUserAbsence(token)
+			this.removeUpcomingEvents(token)
+		},
+	},
+})
diff --git a/src/types/index.ts b/src/types/index.ts
index 37f7269ab12..22c5447a934 100644
--- a/src/types/index.ts
+++ b/src/types/index.ts
@@ -231,20 +231,34 @@ export type TaskProcessingResponse = ApiResponseUnwrapped<{
 	}
 }>
 
+// Groupware
+// Upcoming events response
+// From https://github.com/nextcloud/server/blob/master/apps/dav/lib/CalDAV/UpcomingEvent.php
+export type UpcomingEvent = {
+	uri: string,
+	calendarUri: string,
+	/** Format: int64 */
+	start: number | null,
+	summary: string | null,
+	location: string | null,
+	recurrenceId?: number | null,
+	calendarAppUrl?: string | null,
+};
+export type UpcomingEventsResponse = ApiResponseUnwrapped<{ events: UpcomingEvent[] }>
+
 // Out of office response
 // From https://docs.nextcloud.com/server/latest/developer_manual/client_apis/OCS/ocs-out-of-office-api.html
-export type OutOfOfficeResponse = ApiResponseUnwrapped<{
-	task: {
-		id: string,
-		userId: string,
-		startDate: number,
-		endDate: number,
-		shortMessage: string,
-		message: string,
-		replacementUserId?: string|null,
-		replacementUserDisplayName?: string|null,
-	}
-}>
+export type OutOfOfficeResult = {
+	id: string,
+	userId: string,
+	startDate: number,
+	endDate: number,
+	shortMessage: string,
+	message: string,
+	replacementUserId?: string|null,
+	replacementUserDisplayName?: string|null,
+}
+export type OutOfOfficeResponse = ApiResponseUnwrapped<OutOfOfficeResult>
 
 // User preferences response
 // from https://docs.nextcloud.com/server/latest/developer_manual/client_apis/OCS/ocs-user-preferences-api.html