feat(sidebar): mutual upcoming events API

Signed-off-by: Anna Larch <anna@nextcloud.com>
This commit is contained in:
Anna Larch 2025-05-05 19:23:16 +02:00
parent 779aceb7a1
commit e324c076f7
16 changed files with 1017 additions and 443 deletions

View file

@ -12,10 +12,10 @@ return array_merge_recursive(
include(__DIR__ . '/routes/routesBanController.php'),
include(__DIR__ . '/routes/routesBotController.php'),
include(__DIR__ . '/routes/routesBreakoutRoomController.php'),
include(__DIR__ . '/routes/routesCalendarIntegrationController.php'),
include(__DIR__ . '/routes/routesCallController.php'),
include(__DIR__ . '/routes/routesCertificateController.php'),
include(__DIR__ . '/routes/routesChatController.php'),
include(__DIR__ . '/routes/routesDashboardController.php'),
include(__DIR__ . '/routes/routesFederationController.php'),
include(__DIR__ . '/routes/routesFilesIntegrationController.php'),
include(__DIR__ . '/routes/routesGuestController.php'),

View file

@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
$requirements = [
'apiVersion' => '(v4)',
];
$requirementsWithToken = [
'apiVersion' => '(v4)',
'token' => '[a-z0-9]{4,30}',
];
return [
'ocs' => [
/** @see \OCA\Talk\Controller\CalendarIntegrationController::getDashboardEvents() */
['name' => 'CalendarIntegration#getDashboardEvents', 'url' => '/api/{apiVersion}/dashboard/events', 'verb' => 'GET', 'requirements' => $requirements],
/** @see \OCA\Talk\Controller\CalendarIntegrationController::getMutualEvents() */
['name' => 'CalendarIntegration#getMutualEvents', 'url' => '/api/{apiVersion}/room/{token}/mutual-events', 'verb' => 'GET', 'requirements' => $requirementsWithToken],
],
];

View file

@ -1,18 +0,0 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
$requirements = [
'apiVersion' => '(v4)',
];
return [
'ocs' => [
/** @see \OCA\Talk\Controller\DashboardController::getEventRooms() */
['name' => 'Dashboard#getEventRooms', 'url' => '/api/{apiVersion}/dashboard/events', 'verb' => 'GET', 'requirements' => $requirements],
],
];

View file

@ -185,3 +185,4 @@
* `important-conversations` (local) - Whether important conversations are supported
* `config => call => predefined-backgrounds-v2` (local) - Whether virtual backgrounds should be read from the theming directory
* `dashboard-event-rooms` (local) - Whether Talk APIs offer functionality for Dashboard requests
* `mutual-calendar-events` (local) - Whether Talk APIs offer mutual calendar events for 1:1 rooms

View file

@ -118,6 +118,7 @@ class Capabilities implements IPublicCapability {
'important-conversations',
'sip-direct-dialin',
'dashboard-event-rooms',
'mutual-calendar-events',
];
public const CONDITIONAL_FEATURES = [
@ -145,6 +146,7 @@ class Capabilities implements IPublicCapability {
'important-conversations',
'sip-direct-dialin',
'dashboard-event-rooms',
'mutual-calendar-events',
];
public const LOCAL_CONFIGS = [

View file

@ -0,0 +1,77 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCA\Talk\Controller;
use OCA\Talk\Exceptions\InvalidRoomException;
use OCA\Talk\Exceptions\ParticipantNotFoundException;
use OCA\Talk\Middleware\Attribute\RequireParticipant;
use OCA\Talk\ResponseDefinitions;
use OCA\Talk\Service\CalendarIntegrationService;
use OCP\AppFramework\Http;
use OCP\AppFramework\Http\Attribute\NoAdminRequired;
use OCP\AppFramework\Http\DataResponse;
use OCP\IRequest;
use OCP\IUserSession;
use Psr\Log\LoggerInterface;
/**
* @psalm-import-type TalkDashboardEvent from ResponseDefinitions
*/
class CalendarIntegrationController extends AEnvironmentAwareOCSController {
public function __construct(
string $appName,
IRequest $request,
protected IUserSession $userSession,
protected LoggerInterface $logger,
protected CalendarIntegrationService $service,
) {
parent::__construct($appName, $request);
}
/**
* Get up to 10 rooms that have events in the next 7 days
* sorted by their start timestamp ascending
*
* Required capability: `dashboard-event-rooms`
*
* @return DataResponse<Http::STATUS_OK, list<TalkDashboardEvent>, array{}>
*
* 200: A list of dashboard entries or an empty array
*/
#[NoAdminRequired]
public function getDashboardEvents(): DataResponse {
$userId = $this->userSession->getUser()?->getUID();
$entries = $this->service->getDashboardEvents($userId);
return new DataResponse($entries);
}
/**
* Get up to 3 events in the next 7 days
* sorted by their start timestamp ascending
*
* Required capability: `mutual-calendar-events`
*
* @return DataResponse<Http::STATUS_OK, list<TalkDashboardEvent>, array{}>|DataResponse<Http::STATUS_FORBIDDEN, null, array{}>
*
* 200: A list of dashboard entries or an empty array
* 403: Room is not a 1 to 1 room, room is invalid, or user is not participant
*/
#[NoAdminRequired]
#[RequireParticipant]
public function getMutualEvents(): DataResponse {
$userId = $this->userSession->getUser()?->getUID();
try {
$entries = $this->service->getMutualEvents($userId, $this->room);
} catch (InvalidRoomException|ParticipantNotFoundException) {
return new DataResponse(null, Http::STATUS_FORBIDDEN);
}
return new DataResponse($entries);
}
}

View file

@ -1,55 +0,0 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCA\Talk\Controller;
use OCA\Talk\ResponseDefinitions;
use OCA\Talk\Service\DashboardService;
use OCA\Talk\Service\ParticipantService;
use OCA\Talk\Service\RoomFormatter;
use OCP\AppFramework\Http;
use OCP\AppFramework\Http\Attribute\NoAdminRequired;
use OCP\AppFramework\Http\DataResponse;
use OCP\IRequest;
use OCP\IUserSession;
use Psr\Log\LoggerInterface;
/**
* @psalm-import-type TalkDashboardEvent from ResponseDefinitions
*/
class DashboardController extends AEnvironmentAwareOCSController {
public function __construct(
string $appName,
IRequest $request,
protected IUserSession $userSession,
protected LoggerInterface $logger,
protected DashboardService $service,
protected ParticipantService $participantService,
protected RoomFormatter $formatter,
) {
parent::__construct($appName, $request);
}
/**
* Get up to 10 rooms that have events in the next 7 days
* sorted by their start timestamp ascending
*
* Required capability: `dashboard-event-rooms`
*
* @return DataResponse<Http::STATUS_OK, list<TalkDashboardEvent>, array{}>
*
* 200: A list of dashboard entries or an empty array
*/
#[NoAdminRequired]
public function getEventRooms(): DataResponse {
$userId = $this->userSession->getUser()?->getUID();
$entries = $this->service->getEvents($userId);
return new DataResponse($entries);
}
}

View file

@ -10,20 +10,24 @@ declare(strict_types=1);
namespace OCA\Talk\Service;
use OCA\Talk\Dashboard\Event;
use OCA\Talk\Exceptions\InvalidRoomException;
use OCA\Talk\Exceptions\ParticipantNotFoundException;
use OCA\Talk\Exceptions\RoomNotFoundException;
use OCA\Talk\Manager;
use OCA\Talk\ResponseDefinitions;
use OCA\Talk\Room;
use OCP\AppFramework\Utility\ITimeFactory;
use OCP\Calendar\ICalendar;
use OCP\Calendar\IManager;
use OCP\IDateTimeZone;
use OCP\IURLGenerator;
use OCP\IUserManager;
use Psr\Log\LoggerInterface;
/**
* @psalm-import-type TalkDashboardEvent from ResponseDefinitions
*/
class DashboardService {
class CalendarIntegrationService {
public function __construct(
private Manager $manager,
private IManager $calendarManager,
@ -33,6 +37,7 @@ class DashboardService {
private IDateTimeZone $dateTimeZone,
private AvatarService $avatarService,
private IURLGenerator $urlGenerator,
private IUserManager $userManager,
) {
}
@ -41,7 +46,7 @@ class DashboardService {
* @param string $userId
* @return list<TalkDashboardEvent>
*/
public function getEvents(string $userId): array {
public function getDashboardEvents(string $userId): array {
$principaluri = 'principals/users/' . $userId;
$calendars = $this->calendarManager->getCalendarsForPrincipal($principaluri);
if (count($calendars) === 0) {
@ -177,4 +182,151 @@ class DashboardService {
return $event->jsonSerialize();
}, array_slice($events, 0, 10));
}
/**
* @param string $userId
* @param Room $room
* @return list<TalkDashboardEvent>
*/
public function getMutualEvents(string $userId, Room $room): array {
if ($room->getType() !== Room::TYPE_ONE_TO_ONE) {
throw new InvalidRoomException();
}
try {
$userIds = json_decode($room->getName(), false, 512, JSON_THROW_ON_ERROR);
} catch (\JsonException) {
throw new InvalidRoomException();
}
$participants = array_filter($userIds, static function (string $participantId) use ($userId) {
return $participantId !== $userId;
});
if (count($participants) !== 1) {
throw new InvalidRoomException();
}
$otherParticipant = $this->userManager->get(array_pop($participants));
if ($otherParticipant === null) {
// Change to correct exception
throw new ParticipantNotFoundException();
}
$pattern = $otherParticipant->getEMailAddress();
if ($pattern === null) {
return [];
}
$principaluri = 'principals/users/' . $userId;
$calendars = $this->calendarManager->getCalendarsForPrincipal($principaluri);
if (count($calendars) === 0) {
return [];
}
// Only use personal calendars
$calendars = array_filter($calendars, static function (ICalendar $calendar) {
if (method_exists($calendar, 'isShared')) {
return $calendar->isShared() === false;
}
return true;
});
$start = $this->timeFactory->getDateTime();
$end = clone($start);
$end = $end->add(\DateInterval::createFromDateString('1 week'));
$options = [
'timerange' => [
'start' => $start,
'end' => $end,
],
];
$userTimezone = $this->dateTimeZone->getTimezone();
$searchProperties = ['ATTENDEE', 'ORGANIZER'];
$events = [];
/** @var ICalendar $calendar */
foreach ($calendars as $calendar) {
$searchResult = $calendar->search($pattern, $searchProperties, $options);
foreach ($searchResult as $calendarEvent) {
// Find first recurrence in the future
$event = null;
$dashboardEvent = new Event();
foreach ($calendarEvent['objects'] as $object) {
$dashboardEvent->setStart(\DateTime::createFromImmutable($object['DTSTART'][0])->setTimezone($userTimezone)->getTimestamp());
$dashboardEvent->setEnd(\DateTime::createFromImmutable($object['DTEND'][0])->setTimezone($userTimezone)->getTimestamp());
if ($dashboardEvent->getStart() >= $start->getTimestamp()) {
$event = $object;
break;
}
}
if ($event === null) {
continue;
}
$dashboardEvent->setEventName($event['SUMMARY'][0] ?? '');
$dashboardEvent->setEventDescription($event['DESCRIPTION'][0] ?? null);
$dashboardEvent->addCalendar($calendar->getUri(), $calendar->getDisplayName(), $calendar->getDisplayColor());
$location = $event['LOCATION'][0] ?? null;
if ($location !== null && str_contains($location, '/call/') === true) {
try {
$token = $this->roomService->parseRoomTokenFromUrl($location);
// Already returns public / open conversations
$eventRoom = $this->manager->getRoomForUserByToken($token, $userId);
} catch (RoomNotFoundException) {
$this->logger->debug("Room for url $location not found in dashboard service");
continue;
}
$dashboardEvent->setRoomType($eventRoom->getType());
$dashboardEvent->setRoomName($eventRoom->getName());
$dashboardEvent->setRoomToken($eventRoom->getToken());
$dashboardEvent->setRoomDisplayName($eventRoom->getDisplayName($userId));
$dashboardEvent->setRoomAvatarVersion($this->avatarService->getAvatarVersion($eventRoom));
$dashboardEvent->setRoomActiveSince($eventRoom->getActiveSince()?->getTimestamp());
}
if (isset($event['ATTENDEE'])) {
$dashboardEvent->generateAttendance($event['ATTENDEE']);
}
if (isset($event['ATTACH'])) {
$dashboardEvent->handleCalendarAttachments($calendar->getUri(), $event['ATTACH']);
}
$objectId = base64_encode($this->urlGenerator->getWebroot() . '/remote.php/dav/calendars/' . $userId . '/' . $calendar->getUri() . '/' . $calendarEvent['uri']);
if (isset($event['RECURRENCE-ID'])) {
$dashboardEvent->setEventLink(
$this->urlGenerator->linkToRouteAbsolute(
'calendar.view.indexdirect.edit',
[
'objectId' => $objectId,
'recurrenceId' => $event['RECURRENCE-ID'][0],
]
)
);
} else {
$dashboardEvent->setEventLink(
$this->urlGenerator->linkToRouteAbsolute('calendar.view.indexdirect.edit', ['objectId' => $objectId])
);
}
$events[] = $dashboardEvent;
}
}
if (empty($events)) {
return $events;
}
usort($events, static function (Event $a, Event $b) {
return $a->getStart() - $b->getStart();
});
return array_map(static function (Event $event) {
return $event->jsonSerialize();
}, array_slice($events, 0, 3));
}
}

View file

@ -4304,6 +4304,199 @@
}
}
},
"/ocs/v2.php/apps/spreed/api/{apiVersion}/dashboard/events": {
"get": {
"operationId": "calendar_integration-get-dashboard-events",
"summary": "Get up to 10 rooms that have events in the next 7 days sorted by their start timestamp ascending",
"description": "Required capability: `dashboard-event-rooms`",
"tags": [
"calendar_integration"
],
"security": [
{
"bearer_auth": []
},
{
"basic_auth": []
}
],
"parameters": [
{
"name": "apiVersion",
"in": "path",
"required": true,
"schema": {
"type": "string",
"enum": [
"v4"
],
"default": "v4"
}
},
{
"name": "OCS-APIRequest",
"in": "header",
"description": "Required to be true for the API request to pass",
"required": true,
"schema": {
"type": "boolean",
"default": true
}
}
],
"responses": {
"200": {
"description": "A list of dashboard entries or an empty array",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"ocs"
],
"properties": {
"ocs": {
"type": "object",
"required": [
"meta",
"data"
],
"properties": {
"meta": {
"$ref": "#/components/schemas/OCSMeta"
},
"data": {
"type": "array",
"items": {
"$ref": "#/components/schemas/DashboardEvent"
}
}
}
}
}
}
}
}
}
}
}
},
"/ocs/v2.php/apps/spreed/api/{apiVersion}/room/{token}/mutual-events": {
"get": {
"operationId": "calendar_integration-get-mutual-events",
"summary": "Get up to 3 events in the next 7 days sorted by their start timestamp ascending",
"description": "Required capability: `mutual-calendar-events`",
"tags": [
"calendar_integration"
],
"security": [
{
"bearer_auth": []
},
{
"basic_auth": []
}
],
"parameters": [
{
"name": "apiVersion",
"in": "path",
"required": true,
"schema": {
"type": "string",
"enum": [
"v4"
],
"default": "v4"
}
},
{
"name": "token",
"in": "path",
"required": true,
"schema": {
"type": "string",
"pattern": "^[a-z0-9]{4,30}$"
}
},
{
"name": "OCS-APIRequest",
"in": "header",
"description": "Required to be true for the API request to pass",
"required": true,
"schema": {
"type": "boolean",
"default": true
}
}
],
"responses": {
"200": {
"description": "A list of dashboard entries or an empty array",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"ocs"
],
"properties": {
"ocs": {
"type": "object",
"required": [
"meta",
"data"
],
"properties": {
"meta": {
"$ref": "#/components/schemas/OCSMeta"
},
"data": {
"type": "array",
"items": {
"$ref": "#/components/schemas/DashboardEvent"
}
}
}
}
}
}
}
}
},
"403": {
"description": "Room is not a 1 to 1 room, room is invalid, or user is not participant",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"ocs"
],
"properties": {
"ocs": {
"type": "object",
"required": [
"meta",
"data"
],
"properties": {
"meta": {
"$ref": "#/components/schemas/OCSMeta"
},
"data": {
"nullable": true
}
}
}
}
}
}
}
}
}
}
},
"/ocs/v2.php/apps/spreed/api/{apiVersion}/call/{token}": {
"get": {
"operationId": "call-get-peers-for-call",
@ -8716,83 +8909,6 @@
}
}
},
"/ocs/v2.php/apps/spreed/api/{apiVersion}/dashboard/events": {
"get": {
"operationId": "dashboard-get-event-rooms",
"summary": "Get up to 10 rooms that have events in the next 7 days sorted by their start timestamp ascending",
"description": "Required capability: `dashboard-event-rooms`",
"tags": [
"dashboard"
],
"security": [
{
"bearer_auth": []
},
{
"basic_auth": []
}
],
"parameters": [
{
"name": "apiVersion",
"in": "path",
"required": true,
"schema": {
"type": "string",
"enum": [
"v4"
],
"default": "v4"
}
},
{
"name": "OCS-APIRequest",
"in": "header",
"description": "Required to be true for the API request to pass",
"required": true,
"schema": {
"type": "boolean",
"default": true
}
}
],
"responses": {
"200": {
"description": "A list of dashboard entries or an empty array",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"ocs"
],
"properties": {
"ocs": {
"type": "object",
"required": [
"meta",
"data"
],
"properties": {
"meta": {
"$ref": "#/components/schemas/OCSMeta"
},
"data": {
"type": "array",
"items": {
"$ref": "#/components/schemas/DashboardEvent"
}
}
}
}
}
}
}
}
}
}
}
},
"/ocs/v2.php/apps/spreed/api/{apiVersion}/file/{fileId}": {
"get": {
"operationId": "files_integration-get-room-by-file-id",

View file

@ -4209,6 +4209,199 @@
}
}
},
"/ocs/v2.php/apps/spreed/api/{apiVersion}/dashboard/events": {
"get": {
"operationId": "calendar_integration-get-dashboard-events",
"summary": "Get up to 10 rooms that have events in the next 7 days sorted by their start timestamp ascending",
"description": "Required capability: `dashboard-event-rooms`",
"tags": [
"calendar_integration"
],
"security": [
{
"bearer_auth": []
},
{
"basic_auth": []
}
],
"parameters": [
{
"name": "apiVersion",
"in": "path",
"required": true,
"schema": {
"type": "string",
"enum": [
"v4"
],
"default": "v4"
}
},
{
"name": "OCS-APIRequest",
"in": "header",
"description": "Required to be true for the API request to pass",
"required": true,
"schema": {
"type": "boolean",
"default": true
}
}
],
"responses": {
"200": {
"description": "A list of dashboard entries or an empty array",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"ocs"
],
"properties": {
"ocs": {
"type": "object",
"required": [
"meta",
"data"
],
"properties": {
"meta": {
"$ref": "#/components/schemas/OCSMeta"
},
"data": {
"type": "array",
"items": {
"$ref": "#/components/schemas/DashboardEvent"
}
}
}
}
}
}
}
}
}
}
}
},
"/ocs/v2.php/apps/spreed/api/{apiVersion}/room/{token}/mutual-events": {
"get": {
"operationId": "calendar_integration-get-mutual-events",
"summary": "Get up to 3 events in the next 7 days sorted by their start timestamp ascending",
"description": "Required capability: `mutual-calendar-events`",
"tags": [
"calendar_integration"
],
"security": [
{
"bearer_auth": []
},
{
"basic_auth": []
}
],
"parameters": [
{
"name": "apiVersion",
"in": "path",
"required": true,
"schema": {
"type": "string",
"enum": [
"v4"
],
"default": "v4"
}
},
{
"name": "token",
"in": "path",
"required": true,
"schema": {
"type": "string",
"pattern": "^[a-z0-9]{4,30}$"
}
},
{
"name": "OCS-APIRequest",
"in": "header",
"description": "Required to be true for the API request to pass",
"required": true,
"schema": {
"type": "boolean",
"default": true
}
}
],
"responses": {
"200": {
"description": "A list of dashboard entries or an empty array",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"ocs"
],
"properties": {
"ocs": {
"type": "object",
"required": [
"meta",
"data"
],
"properties": {
"meta": {
"$ref": "#/components/schemas/OCSMeta"
},
"data": {
"type": "array",
"items": {
"$ref": "#/components/schemas/DashboardEvent"
}
}
}
}
}
}
}
}
},
"403": {
"description": "Room is not a 1 to 1 room, room is invalid, or user is not participant",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"ocs"
],
"properties": {
"ocs": {
"type": "object",
"required": [
"meta",
"data"
],
"properties": {
"meta": {
"$ref": "#/components/schemas/OCSMeta"
},
"data": {
"nullable": true
}
}
}
}
}
}
}
}
}
}
},
"/ocs/v2.php/apps/spreed/api/{apiVersion}/call/{token}": {
"get": {
"operationId": "call-get-peers-for-call",
@ -8621,83 +8814,6 @@
}
}
},
"/ocs/v2.php/apps/spreed/api/{apiVersion}/dashboard/events": {
"get": {
"operationId": "dashboard-get-event-rooms",
"summary": "Get up to 10 rooms that have events in the next 7 days sorted by their start timestamp ascending",
"description": "Required capability: `dashboard-event-rooms`",
"tags": [
"dashboard"
],
"security": [
{
"bearer_auth": []
},
{
"basic_auth": []
}
],
"parameters": [
{
"name": "apiVersion",
"in": "path",
"required": true,
"schema": {
"type": "string",
"enum": [
"v4"
],
"default": "v4"
}
},
{
"name": "OCS-APIRequest",
"in": "header",
"description": "Required to be true for the API request to pass",
"required": true,
"schema": {
"type": "boolean",
"default": true
}
}
],
"responses": {
"200": {
"description": "A list of dashboard entries or an empty array",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"ocs"
],
"properties": {
"ocs": {
"type": "object",
"required": [
"meta",
"data"
],
"properties": {
"meta": {
"$ref": "#/components/schemas/OCSMeta"
},
"data": {
"type": "array",
"items": {
"$ref": "#/components/schemas/DashboardEvent"
}
}
}
}
}
}
}
}
}
}
}
},
"/ocs/v2.php/apps/spreed/api/{apiVersion}/file/{fileId}": {
"get": {
"operationId": "files_integration-get-room-by-file-id",

View file

@ -241,6 +241,46 @@ export type paths = {
patch?: never;
trace?: never;
};
"/ocs/v2.php/apps/spreed/api/{apiVersion}/dashboard/events": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/**
* Get up to 10 rooms that have events in the next 7 days sorted by their start timestamp ascending
* @description Required capability: `dashboard-event-rooms`
*/
get: operations["calendar_integration-get-dashboard-events"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/ocs/v2.php/apps/spreed/api/{apiVersion}/room/{token}/mutual-events": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/**
* Get up to 3 events in the next 7 days sorted by their start timestamp ascending
* @description Required capability: `mutual-calendar-events`
*/
get: operations["calendar_integration-get-mutual-events"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/ocs/v2.php/apps/spreed/api/{apiVersion}/call/{token}": {
parameters: {
query?: never;
@ -528,26 +568,6 @@ export type paths = {
patch?: never;
trace?: never;
};
"/ocs/v2.php/apps/spreed/api/{apiVersion}/dashboard/events": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/**
* Get up to 10 rooms that have events in the next 7 days sorted by their start timestamp ascending
* @description Required capability: `dashboard-event-rooms`
*/
get: operations["dashboard-get-event-rooms"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/ocs/v2.php/apps/spreed/api/{apiVersion}/file/{fileId}": {
parameters: {
query?: never;
@ -3593,6 +3613,81 @@ export interface operations {
};
};
};
"calendar_integration-get-dashboard-events": {
parameters: {
query?: never;
header: {
/** @description Required to be true for the API request to pass */
"OCS-APIRequest": boolean;
};
path: {
apiVersion: "v4";
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description A list of dashboard entries or an empty array */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": {
ocs: {
meta: components["schemas"]["OCSMeta"];
data: components["schemas"]["DashboardEvent"][];
};
};
};
};
};
};
"calendar_integration-get-mutual-events": {
parameters: {
query?: never;
header: {
/** @description Required to be true for the API request to pass */
"OCS-APIRequest": boolean;
};
path: {
apiVersion: "v4";
token: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description A list of dashboard entries or an empty array */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": {
ocs: {
meta: components["schemas"]["OCSMeta"];
data: components["schemas"]["DashboardEvent"][];
};
};
};
};
/** @description Room is not a 1 to 1 room, room is invalid, or user is not participant */
403: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": {
ocs: {
meta: components["schemas"]["OCSMeta"];
data: unknown;
};
};
};
};
};
};
"call-get-peers-for-call": {
parameters: {
query?: never;
@ -5343,36 +5438,6 @@ export interface operations {
};
};
};
"dashboard-get-event-rooms": {
parameters: {
query?: never;
header: {
/** @description Required to be true for the API request to pass */
"OCS-APIRequest": boolean;
};
path: {
apiVersion: "v4";
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description A list of dashboard entries or an empty array */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": {
ocs: {
meta: components["schemas"]["OCSMeta"];
data: components["schemas"]["DashboardEvent"][];
};
};
};
};
};
};
"files_integration-get-room-by-file-id": {
parameters: {
query?: never;

View file

@ -241,6 +241,46 @@ export type paths = {
patch?: never;
trace?: never;
};
"/ocs/v2.php/apps/spreed/api/{apiVersion}/dashboard/events": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/**
* Get up to 10 rooms that have events in the next 7 days sorted by their start timestamp ascending
* @description Required capability: `dashboard-event-rooms`
*/
get: operations["calendar_integration-get-dashboard-events"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/ocs/v2.php/apps/spreed/api/{apiVersion}/room/{token}/mutual-events": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/**
* Get up to 3 events in the next 7 days sorted by their start timestamp ascending
* @description Required capability: `mutual-calendar-events`
*/
get: operations["calendar_integration-get-mutual-events"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/ocs/v2.php/apps/spreed/api/{apiVersion}/call/{token}": {
parameters: {
query?: never;
@ -528,26 +568,6 @@ export type paths = {
patch?: never;
trace?: never;
};
"/ocs/v2.php/apps/spreed/api/{apiVersion}/dashboard/events": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/**
* Get up to 10 rooms that have events in the next 7 days sorted by their start timestamp ascending
* @description Required capability: `dashboard-event-rooms`
*/
get: operations["dashboard-get-event-rooms"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/ocs/v2.php/apps/spreed/api/{apiVersion}/file/{fileId}": {
parameters: {
query?: never;
@ -3055,6 +3075,81 @@ export interface operations {
};
};
};
"calendar_integration-get-dashboard-events": {
parameters: {
query?: never;
header: {
/** @description Required to be true for the API request to pass */
"OCS-APIRequest": boolean;
};
path: {
apiVersion: "v4";
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description A list of dashboard entries or an empty array */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": {
ocs: {
meta: components["schemas"]["OCSMeta"];
data: components["schemas"]["DashboardEvent"][];
};
};
};
};
};
};
"calendar_integration-get-mutual-events": {
parameters: {
query?: never;
header: {
/** @description Required to be true for the API request to pass */
"OCS-APIRequest": boolean;
};
path: {
apiVersion: "v4";
token: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description A list of dashboard entries or an empty array */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": {
ocs: {
meta: components["schemas"]["OCSMeta"];
data: components["schemas"]["DashboardEvent"][];
};
};
};
};
/** @description Room is not a 1 to 1 room, room is invalid, or user is not participant */
403: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": {
ocs: {
meta: components["schemas"]["OCSMeta"];
data: unknown;
};
};
};
};
};
};
"call-get-peers-for-call": {
parameters: {
query?: never;
@ -4805,36 +4900,6 @@ export interface operations {
};
};
};
"dashboard-get-event-rooms": {
parameters: {
query?: never;
header: {
/** @description Required to be true for the API request to pass */
"OCS-APIRequest": boolean;
};
path: {
apiVersion: "v4";
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description A list of dashboard entries or an empty array */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": {
ocs: {
meta: components["schemas"]["OCSMeta"];
data: components["schemas"]["DashboardEvent"][];
};
};
};
};
};
};
"files_integration-get-room-by-file-id": {
parameters: {
query?: never;

View file

@ -3456,6 +3456,20 @@ class FeatureContext implements Context, SnippetAcceptingContext {
}
}
#[Given('/^user "([^"]*)" exists and has an email address$/')]
public function assureUserExistsAndHasEmail(string $user): void {
$response = $this->userExists($user);
if ($response->getStatusCode() !== 200) {
$this->createUser($user);
// Set a display name different than the user ID to be able to
// ensure in the tests that the right value was returned.
$this->setUserDisplayName($user);
$response = $this->userExists($user);
$this->assertStatusCode($response, 200);
}
$this->setUserEmail($user);
}
#[Given('/^(enable|disable) brute force protection$/')]
public function enableDisableBruteForceProtection(string $enable): void {
if ($enable === 'enable') {
@ -3660,6 +3674,15 @@ class FeatureContext implements Context, SnippetAcceptingContext {
$this->setCurrentUser($currentUser);
}
private function setUserEmail(string $user): void {
$currentUser = $this->setCurrentUser('admin');
$this->sendRequest('PUT', '/cloud/users/' . $user, [
'key' => 'email',
'value' => $user . '@example.tld'
]);
$this->setCurrentUser($currentUser);
}
#[Given('/^group "([^"]*)" exists$/')]
public function assureGroupExists(string $group): void {
$currentUser = $this->setCurrentUser('admin');
@ -4378,70 +4401,15 @@ class FeatureContext implements Context, SnippetAcceptingContext {
}
}
/**
* @param string $user
* @param string $identifier
* @param int $statusCode
* @param string $apiVersion
* @param TableNode|null $formData
* @return void
*
* @Given /^user "([^"]*)" creates conversation with event "([^"]*)" \((v4)\)$/
*/
public function createCalendarEventConversation(string $user, string $identifier, string $apiVersion = 'v1', ?TableNode $formData = null): void {
$body = $formData->getRowsHash();
$startTime = time() + 86400;
$endTime = time() + 90000;
if (isset($body['objectId'])) {
[$start, $end] = explode('#', $body['objectId']);
$startTime = time() + (int)$start;
$endTime = time() + (int)$end;
$body['objectId'] = $startTime . '#' . $endTime;
self::$identifierToObjectId[$identifier] = $body['objectId'];
}
$body['roomName'] = $identifier;
$this->setCurrentUser($user);
$this->sendRequest('POST', '/apps/spreed/api/' . $apiVersion . '/room', $body);
$this->assertStatusCode($this->response, 201);
$response = $this->getDataFromResponse($this->response);
self::$identifierToToken[$identifier] = $response['token'];
self::$identifierToId[$identifier] = $response['id'];
self::$tokenToIdentifier[$response['token']] = $identifier;
$location = self::getRoomLocationForToken($identifier);
$this->sendRequest('POST', '/apps/spreedcheats/calendar', [
'name' => $identifier,
'location' => $location,
'start' => $startTime,
'end' => $endTime,
]);
$this->assertStatusCode($this->response, 200);
}
/**
* @param string $user
* @param string $identifier
* @param int $statusCode
* @param string $apiVersion
* @param TableNode|null $formData
* @return void
*
* @Given /^user "([^"]*)" creates calendar events for a room "([^"]*)" \((v4)\)$/
*/
#[Given('/^user "([^"]*)" creates calendar events for a room "([^"]*)" \((v4)\)$/')]
public function createCalendarEntriesWithRoom(string $user, string $identifier, string $apiVersion = 'v1', ?TableNode $formData = null): void {
$body = $formData->getRowsHash();
$this->setCurrentUser($user);
$body = $formData->getRowsHash();
$body['roomName'] = $identifier;
if (!isset(self::$tokenToIdentifier[$identifier])) {
$this->setCurrentUser($user);
$this->sendRequest('POST', '/apps/spreed/api/' . $apiVersion . '/room', $body);
$this->assertStatusCode($this->response, 201);
$response = $this->getDataFromResponse($this->response);
self::$identifierToToken[$identifier] = $response['token'];
@ -4449,7 +4417,6 @@ class FeatureContext implements Context, SnippetAcceptingContext {
self::$tokenToIdentifier[$response['token']] = $identifier;
}
$location = self::getRoomLocationForToken($identifier);
$this->sendRequest('POST', '/apps/spreedcheats/dashboardEvents', [
'name' => $identifier,
@ -4459,6 +4426,17 @@ class FeatureContext implements Context, SnippetAcceptingContext {
$this->assertStatusCode($this->response, 200);
}
#[Given('/^user "([^"]*)" creates calendar events inviting user "([^"]*)" \((v4)\)$/')]
public function createEventsForOneToOne(string $user, string $participant, string $apiVersion = 'v1', ?TableNode $formData = null): void {
$this->setCurrentUser($user);
$this->sendRequest('POST', '/apps/spreedcheats/mutualEvents', [
'organizer' => $user,
'attendee' => $participant,
]);
$this->assertStatusCode($this->response, 200);
}
#[Then('/^user "([^"]*)" sees the following entry when loading the dashboard conversations \((v4)\)$/')]
public function userGetsEventConversationsForTalkDashboard(string $user, string $apiVersion, ?TableNode $formData = null): void {
$this->setCurrentUser($user);
@ -4474,6 +4452,23 @@ class FeatureContext implements Context, SnippetAcceptingContext {
$this->assertDashboardData($data, $formData);
}
#[Then('/^user "([^"]*)" sees the following entry when loading mutual events in room "([^"]*)" \((v4)\)$/')]
public function userGetsMutualEventConversations(string $user, string $identifier, string $apiVersion, ?TableNode $formData = null): void {
$this->setCurrentUser($user);
$token = self::$identifierToToken[$identifier];
$this->sendRequest('GET', '/apps/spreed/api/' . $apiVersion . '/room/' . $token . '/mutual-events');
$this->assertStatusCode($this->response, 200);
$data = $this->getDataFromResponse($this->response);
if (!$formData instanceof TableNode) {
Assert::assertEmpty($data);
return;
}
$this->assertDashboardData($data, $formData);
}
/**
* @param array $dashboardEvents
* @param TableNode $formData

View file

@ -13,3 +13,28 @@ Feature: integration/dashboard-talk
|dashboardRoom | 2 | dashboardRoom-attachment | null | null | null | null | 1 | 1 |
|dashboardRoom | 2 | dashboardRoom-attendees | 1 | 1 | null | null | 0 | 1 |
|dashboardRoom | 2 | dashboardRoom-recurring | null | null | null | null | 0 | 1 |
Scenario: User gets mutual events for a one to one conversation
Given user "participant1" exists and has an email address
Given user "participant2" exists and has an email address
Given user "participant1" creates room "room1" (v4)
| roomType | 1 |
| invite | participant2 |
Given user "participant2" creates room "room1" with 200 (v4)
| roomType | 1 |
| invite | participant1 |
Then user "participant1" is participant of room "room1" (v4)
And user "participant2" is participant of room "room1" (v4)
And user "participant1" joins room "room1" with 200 (v4)
Given user "participant1" creates calendar events inviting user "participant2" (v4)
Then user "participant1" sees the following entry when loading mutual events in room "room1" (v4)
| eventName | eventDescription | invited | accepted | declined | tentative | eventAttachments | calendars | roomType |
| Test | Test | 1 | null | null | null | 0 | 1 | 0 |
| Test | Test | 1 | null | null | null | 0 | 1 | 0 |
| Test | Test | 1 | null | null | null | 0 | 1 | 0 |
Then user "participant2" joins room "room1" with 200 (v4)
Then user "participant2" sees the following entry when loading mutual events in room "room1" (v4)
| eventName | eventDescription | invited | accepted | declined | tentative | eventAttachments | calendars | roomType |
| Test | Test | 1 | null | null | null | 0 | 1 | 0 |
| Test | Test | 1 | null | null | null | 0 | 1 | 0 |
| Test | Test | 1 | null | null | null | 0 | 1 | 0 |

View file

@ -12,5 +12,6 @@ return [
['name' => 'Api#ageChat', 'url' => '/age', 'verb' => 'POST'],
['name' => 'Api#createEventInCalendar', 'url' => '/calendar', 'verb' => 'POST'],
['name' => 'Api#createDashboardEvents', 'url' => '/dashboardEvents', 'verb' => 'POST'],
['name' => 'Api#createEventAndInviteParticipant', 'url' => '/mutualEvents', 'verb' => 'POST'],
],
];

View file

@ -20,6 +20,7 @@ use OCP\DB\QueryBuilder\IQueryBuilder;
use OCP\IDBConnection;
use OCP\IRequest;
use OCP\Share\IShare;
use Sabre\VObject\Component\VCalendar;
use Sabre\VObject\UUIDUtil;
class ApiController extends OCSController {
@ -180,59 +181,6 @@ class ApiController extends OCSController {
return new DataResponse();
}
#[NoAdminRequired]
public function createEventInCalendar(string $name, string $location, string $start, string $end): DataResponse {
if ($this->userId === null) {
return new DataResponse(null, Http::STATUS_UNAUTHORIZED);
}
$calendar = null;
// Create a calendar event with LOCATION and time via OCP
$calendars = $this->calendarManager->getCalendarsForPrincipal('principals/users/' . $this->userId);
foreach ($calendars as $c) {
if ($c instanceof ICreateFromString) {
$calendar = $c;
}
}
if ($calendar === null) {
return new DataResponse(null, Http::STATUS_NOT_FOUND);
}
$calData = <<<EOF
BEGIN:VCALENDAR
PRODID:-//IDN nextcloud.com//Calendar app 5.2.0-dev.1//EN
CALSCALE:GREGORIAN
VERSION:2.0
BEGIN:VEVENT
CREATED:20250310T171800Z
DTSTAMP:20250310T171819Z
LAST-MODIFIED:20250310T171819Z
SEQUENCE:2
UID:{{{UID}}}
DTSTART:{{{START}}}
DTEND:{{{END}}}
STATUS:CONFIRMED
SUMMARY:{{{NAME}}}
LOCATION:{{{LOCATION}}}
END:VEVENT
END:VCALENDAR
EOF;
$start = (new \DateTime())->setTimestamp((int)$start)->format('Ymd\THis');
$end = (new \DateTime())->setTimestamp((int)$end)->format('Ymd\THis');
$uid = UUIDUtil::getUUID();
$calData = str_replace(['{{{NAME}}}', '{{{START}}}', '{{{END}}}', '{{{UID}}}', '{{{LOCATION}}}'], [$name, $start, $end, $uid, $location], $calData);
try {
/** @var ICreateFromString $calendar */
$calendar->createFromString((string)random_int(0, 10000), $calData);
} catch (CalendarException) {
return new DataResponse(null, Http::STATUS_FORBIDDEN);
}
return new DataResponse();
}
#[NoAdminRequired]
public function createDashboardEvents(string $name, string $location): DataResponse {
if ($this->userId === null) {
@ -258,7 +206,7 @@ EOF;
foreach ($events as $event) {
try {
/** @var ICreateFromString $calendar */
$calendar->createFromString((string)random_int(0, 10000), $event);
$calendar->createFromString(random_int(0, 10000) . '.ics', $event);
} catch (CalendarException) {
return new DataResponse(null, Http::STATUS_FORBIDDEN);
}
@ -266,4 +214,63 @@ EOF;
return new DataResponse();
}
#[NoAdminRequired]
public function createEventAndInviteParticipant(string $organizer, string $attendee): DataResponse {
if ($this->userId === null) {
return new DataResponse(null, Http::STATUS_UNAUTHORIZED);
}
$calendar = null;
// Create a calendar event with LOCATION and time via OCP
$calendars = $this->calendarManager->getCalendarsForPrincipal('principals/users/' . $this->userId);
foreach ($calendars as $c) {
if ($c instanceof ICreateFromString) {
$calendar = $c;
}
}
if ($calendar === null) {
return new DataResponse(null, Http::STATUS_NOT_FOUND);
}
$start = time();
$end = $start + 3600;
$startTime = (new \DateTime())->setTimestamp($start);
$endTime = (new \DateTime())->setTimestamp($end);
for ($i = 0; $i < 3; $i++) {
$interval = new \DateInterval('PT2H');
$startTime->add($interval);
$endTime->add($interval);
$vCalendar = new VCalendar();
$vevent = $vCalendar->createComponent('VEVENT');
$vevent->add('UID', UUIDUtil::getUUID());
$vevent->add('DTSTART');
$vevent->DTSTART->setDateTime($startTime);
$vevent->add('DTEND');
$vevent->DTEND->setDateTime($endTime);
$vevent->add('SUMMARY', 'Test');
$vevent->add('DESCRIPTION', 'Test');
$vevent->add('ORGANIZER', 'mailto:' . $organizer . '@example.tld', ['CN' => $organizer]);
$vevent->add('ATTENDEE', 'mailto:' . $attendee . '@example.tld', [
'CN' => $attendee,
'CUTYPE' => 'INDIVIDUAL',
'PARTSTAT' => 'NEEDS-ACTION',
'ROLE' => 'REQ-PARTICIPANT',
'RSVP' => 'TRUE'
]);
$vevent->add('STATUS', 'CONFIRMED');
$vCalendar->add($vevent);
$cal = $vCalendar->serialize();
try {
/** @var ICreateFromString $calendar */
$calendar->createFromString(random_int(0, 10000) . '.ics', $cal);
} catch (CalendarException) {
return new DataResponse(null, Http::STATUS_FORBIDDEN);
}
}
return new DataResponse();
}
}