From 6c1d50f4380d7e7fee0fe54192fc516add585f85 Mon Sep 17 00:00:00 2001 From: Joas Schilling Date: Thu, 10 Oct 2024 11:01:32 +0200 Subject: [PATCH 1/5] feat(polls): Allow moderators to draft polls Signed-off-by: Joas Schilling --- appinfo/routes/routesPollController.php | 2 + docs/capabilities.md | 1 + lib/Capabilities.php | 1 + lib/Controller/PollController.php | 101 +++++++--- .../TalkV1/Controller/PollController.php | 35 +++- lib/Model/Poll.php | 1 + lib/Model/PollMapper.php | 14 ++ lib/Service/PollService.php | 13 +- openapi-full.json | 176 ++++++++++++++++++ openapi.json | 176 ++++++++++++++++++ src/types/openapi/openapi-full.ts | 98 ++++++++++ src/types/openapi/openapi.ts | 98 ++++++++++ .../features/bootstrap/FeatureContext.php | 5 + .../integration/features/chat-3/poll.feature | 41 ++++ 14 files changed, 736 insertions(+), 26 deletions(-) diff --git a/appinfo/routes/routesPollController.php b/appinfo/routes/routesPollController.php index e2faeeacb01..57f99d300d2 100644 --- a/appinfo/routes/routesPollController.php +++ b/appinfo/routes/routesPollController.php @@ -21,6 +21,8 @@ 'ocs' => [ /** @see \OCA\Talk\Controller\PollController::createPoll() */ ['name' => 'Poll#createPoll', 'url' => '/api/{apiVersion}/poll/{token}', 'verb' => 'POST', 'requirements' => $requirements], + /** @see \OCA\Talk\Controller\PollController::getAllDraftPolls() */ + ['name' => 'Poll#getAllDraftPolls', 'url' => '/api/{apiVersion}/poll/{token}/drafts', 'verb' => 'GET', 'requirements' => $requirements], /** @see \OCA\Talk\Controller\PollController::showPoll() */ ['name' => 'Poll#showPoll', 'url' => '/api/{apiVersion}/poll/{token}/{pollId}', 'verb' => 'GET', 'requirements' => $requirementsWithPollId], /** @see \OCA\Talk\Controller\PollController::votePoll() */ diff --git a/docs/capabilities.md b/docs/capabilities.md index e7cebf55a2b..0e3584a5a5e 100644 --- a/docs/capabilities.md +++ b/docs/capabilities.md @@ -158,5 +158,6 @@ ## 20.1 * `archived-conversations` (local) - Conversations can be marked as archived which will hide them from the conversation list by default +* `talk-polls-drafts` - Whether moderators can store and retrieve poll drafts * `config => call => start-without-media` (local) - Boolean, whether media should be disabled when starting or joining a conversation * `config => call => max-duration` - Integer, maximum call duration in seconds. Please note that this should only be used with system cron and with a reasonable high value, due to the expended duration until the background job ran. diff --git a/lib/Capabilities.php b/lib/Capabilities.php index 773e3c21d90..1ae5a29a02c 100644 --- a/lib/Capabilities.php +++ b/lib/Capabilities.php @@ -104,6 +104,7 @@ class Capabilities implements IPublicCapability { 'mention-permissions', 'edit-messages-note-to-self', 'archived-conversations', + 'talk-polls-drafts', ]; public const LOCAL_FEATURES = [ diff --git a/lib/Controller/PollController.php b/lib/Controller/PollController.php index 62d93b6c4d4..29f9d7c9b3a 100644 --- a/lib/Controller/PollController.php +++ b/lib/Controller/PollController.php @@ -14,6 +14,7 @@ use OCA\Talk\Exceptions\WrongPermissionsException; use OCA\Talk\Middleware\Attribute\FederationSupported; use OCA\Talk\Middleware\Attribute\RequireModeratorOrNoLobby; +use OCA\Talk\Middleware\Attribute\RequireModeratorParticipant; use OCA\Talk\Middleware\Attribute\RequireParticipant; use OCA\Talk\Middleware\Attribute\RequirePermission; use OCA\Talk\Middleware\Attribute\RequireReadWriteConversation; @@ -58,6 +59,7 @@ public function __construct( * @param 0|1 $resultMode Mode how the results will be shown * @psalm-param Poll::MODE_* $resultMode Mode how the results will be shown * @param int $maxVotes Number of maximum votes per voter + * @param bool $draft Whether the poll should be saved as a draft (only allowed for moderators and with `talk-polls-drafts` capability) * @return DataResponse|DataResponse, array{}> * * 201: Poll created successfully @@ -69,11 +71,11 @@ public function __construct( #[RequireParticipant] #[RequirePermission(permission: RequirePermission::CHAT)] #[RequireReadWriteConversation] - public function createPoll(string $question, array $options, int $resultMode, int $maxVotes): DataResponse { + public function createPoll(string $question, array $options, int $resultMode, int $maxVotes, bool $draft = false): DataResponse { if ($this->room->isFederatedConversation()) { /** @var \OCA\Talk\Federation\Proxy\TalkV1\Controller\PollController $proxy */ $proxy = \OCP\Server::get(\OCA\Talk\Federation\Proxy\TalkV1\Controller\PollController::class); - return $proxy->createPoll($this->room, $this->participant, $question, $options, $resultMode, $maxVotes); + return $proxy->createPoll($this->room, $this->participant, $question, $options, $resultMode, $maxVotes, $draft); } if ($this->room->getType() !== Room::TYPE_GROUP @@ -81,6 +83,10 @@ public function createPoll(string $question, array $options, int $resultMode, in return new DataResponse([], Http::STATUS_BAD_REQUEST); } + if ($draft === true && !$this->participant->hasModeratorPermissions()) { + return new DataResponse([], Http::STATUS_BAD_REQUEST); + } + $attendee = $this->participant->getAttendee(); try { $poll = $this->pollService->createPoll( @@ -91,33 +97,66 @@ public function createPoll(string $question, array $options, int $resultMode, in $question, $options, $resultMode, - $maxVotes + $maxVotes, + $draft, ); } catch (\Exception $e) { $this->logger->error('Error creating poll', ['exception' => $e]); return new DataResponse([], Http::STATUS_BAD_REQUEST); } - $message = json_encode([ - 'message' => 'object_shared', - 'parameters' => [ - 'objectType' => 'talk-poll', - 'objectId' => $poll->getId(), - 'metaData' => [ - 'type' => 'talk-poll', - 'id' => $poll->getId(), - 'name' => $question, - ] - ], - ], JSON_THROW_ON_ERROR); + if (!$draft) { + $message = json_encode([ + 'message' => 'object_shared', + 'parameters' => [ + 'objectType' => 'talk-poll', + 'objectId' => $poll->getId(), + 'metaData' => [ + 'type' => 'talk-poll', + 'id' => $poll->getId(), + 'name' => $question, + ] + ], + ], JSON_THROW_ON_ERROR); - try { - $this->chatManager->addSystemMessage($this->room, $attendee->getActorType(), $attendee->getActorId(), $message, $this->timeFactory->getDateTime(), true); - } catch (\Exception $e) { - $this->logger->error($e->getMessage(), ['exception' => $e]); + try { + $this->chatManager->addSystemMessage($this->room, $attendee->getActorType(), $attendee->getActorId(), $message, $this->timeFactory->getDateTime(), true); + } catch (\Exception $e) { + $this->logger->error($e->getMessage(), ['exception' => $e]); + } } - return new DataResponse($this->renderPoll($poll, []), Http::STATUS_CREATED); + return new DataResponse($this->renderPoll($poll), Http::STATUS_CREATED); + } + + /** + * Get all drafted polls + * + * Required capability: `talk-polls-drafts` + * + * @return DataResponse, array{}>|DataResponse, array{}> + * + * 200: Poll returned + * 403: User is not a moderator + * 404: Poll not found + */ + #[FederationSupported] + #[PublicPage] + #[RequireModeratorParticipant] + public function getAllDraftPolls(): DataResponse { + if ($this->room->isFederatedConversation()) { + /** @var \OCA\Talk\Federation\Proxy\TalkV1\Controller\PollController $proxy */ + $proxy = \OCP\Server::get(\OCA\Talk\Federation\Proxy\TalkV1\Controller\PollController::class); + return $proxy->getDraftsForRoom($this->room, $this->participant); + } + + $polls = $this->pollService->getDraftsForRoom($this->room->getId()); + $data = []; + foreach ($polls as $poll) { + $data[] = $this->renderPoll($poll); + } + + return new DataResponse($data); } /** @@ -143,7 +182,11 @@ public function showPoll(int $pollId): DataResponse { try { $poll = $this->pollService->getPoll($this->room->getId(), $pollId); - } catch (DoesNotExistException $e) { + } catch (DoesNotExistException) { + return new DataResponse([], Http::STATUS_NOT_FOUND); + } + + if ($poll->getStatus() === Poll::STATUS_DRAFT && !$this->participant->hasModeratorPermissions()) { return new DataResponse([], Http::STATUS_NOT_FOUND); } @@ -181,7 +224,11 @@ public function votePoll(int $pollId, array $optionIds = []): DataResponse { try { $poll = $this->pollService->getPoll($this->room->getId(), $pollId); - } catch (\Exception $e) { + } catch (DoesNotExistException) { + return new DataResponse([], Http::STATUS_NOT_FOUND); + } + + if ($poll->getStatus() === Poll::STATUS_DRAFT) { return new DataResponse([], Http::STATUS_NOT_FOUND); } @@ -222,9 +269,10 @@ public function votePoll(int $pollId, array $optionIds = []): DataResponse { * * @param int $pollId ID of the poll * @psalm-param non-negative-int $pollId - * @return DataResponse|DataResponse, array{}> + * @return DataResponse|DataResponse, array{}> * * 200: Poll closed successfully + * 202: Poll draft was deleted successfully * 400: Poll already closed * 403: Missing permissions to close poll * 404: Poll not found @@ -242,10 +290,15 @@ public function closePoll(int $pollId): DataResponse { try { $poll = $this->pollService->getPoll($this->room->getId(), $pollId); - } catch (\Exception $e) { + } catch (DoesNotExistException) { return new DataResponse([], Http::STATUS_NOT_FOUND); } + if ($poll->getStatus() === Poll::STATUS_DRAFT) { + $this->pollService->deleteByPollId($poll->getId()); + return new DataResponse([], Http::STATUS_ACCEPTED); + } + if ($poll->getStatus() === Poll::STATUS_CLOSED) { return new DataResponse([], Http::STATUS_BAD_REQUEST); } diff --git a/lib/Federation/Proxy/TalkV1/Controller/PollController.php b/lib/Federation/Proxy/TalkV1/Controller/PollController.php index 5eeec4189d4..9ab604cb3d6 100644 --- a/lib/Federation/Proxy/TalkV1/Controller/PollController.php +++ b/lib/Federation/Proxy/TalkV1/Controller/PollController.php @@ -28,6 +28,38 @@ public function __construct( ) { } + /** + * @return DataResponse, array{}>|DataResponse, array{}> + * @throws CannotReachRemoteException + * + * 200: Polls returned + * 404: Polls not found + * + * @see \OCA\Talk\Controller\PollController::showPoll() + */ + public function getDraftsForRoom(Room $room, Participant $participant): DataResponse { + $proxy = $this->proxy->get( + $participant->getAttendee()->getInvitedCloudId(), + $participant->getAttendee()->getAccessToken(), + $room->getRemoteServer() . '/ocs/v2.php/apps/spreed/api/v1/poll/' . $room->getRemoteToken() . '/drafts', + ); + + $status = $proxy->getStatusCode(); + if ($status === Http::STATUS_NOT_FOUND || $status === Http::STATUS_FORBIDDEN) { + return new DataResponse([], $status); + } + + /** @var list $list */ + $list = $this->proxy->getOCSData($proxy); + + $data = []; + foreach ($list as $poll) { + $data[] = $this->userConverter->convertPoll($room, $poll); + } + + return new DataResponse($data); + } + /** * @return DataResponse|DataResponse, array{}> * @throws CannotReachRemoteException @@ -101,7 +133,7 @@ public function votePoll(Room $room, Participant $participant, int $pollId, arra * * @see \OCA\Talk\Controller\PollController::createPoll() */ - public function createPoll(Room $room, Participant $participant, string $question, array $options, int $resultMode, int $maxVotes): DataResponse { + public function createPoll(Room $room, Participant $participant, string $question, array $options, int $resultMode, int $maxVotes, bool $draft): DataResponse { $proxy = $this->proxy->post( $participant->getAttendee()->getInvitedCloudId(), $participant->getAttendee()->getAccessToken(), @@ -111,6 +143,7 @@ public function createPoll(Room $room, Participant $participant, string $questio 'options' => $options, 'resultMode' => $resultMode, 'maxVotes' => $maxVotes, + 'draft' => $draft, ], ); diff --git a/lib/Model/Poll.php b/lib/Model/Poll.php index 9682dea6c72..63b123e9a25 100644 --- a/lib/Model/Poll.php +++ b/lib/Model/Poll.php @@ -41,6 +41,7 @@ class Poll extends Entity { public const STATUS_OPEN = 0; public const STATUS_CLOSED = 1; + public const STATUS_DRAFT = 2; public const MODE_PUBLIC = 0; public const MODE_HIDDEN = 1; public const MAX_VOTES_UNLIMITED = 0; diff --git a/lib/Model/PollMapper.php b/lib/Model/PollMapper.php index 91f1b0cdacf..deee8d32c11 100644 --- a/lib/Model/PollMapper.php +++ b/lib/Model/PollMapper.php @@ -27,6 +27,20 @@ public function __construct(IDBConnection $db) { parent::__construct($db, 'talk_polls', Poll::class); } + /** + * @return Poll[] + */ + public function getDraftsByRoomId(int $roomId): array { + $query = $this->db->getQueryBuilder(); + + $query->select('*') + ->from($this->getTableName()) + ->where($query->expr()->eq('room_id', $query->createNamedParameter($roomId, IQueryBuilder::PARAM_INT))) + ->andWhere($query->expr()->eq('status', $query->createNamedParameter(Poll::STATUS_DRAFT, IQueryBuilder::PARAM_INT))); + + return $this->findEntities($query); + } + /** * @param int $pollId * @return Poll diff --git a/lib/Service/PollService.php b/lib/Service/PollService.php index 217c11a57c8..a3fd7d6d595 100644 --- a/lib/Service/PollService.php +++ b/lib/Service/PollService.php @@ -29,7 +29,7 @@ public function __construct( ) { } - public function createPoll(int $roomId, string $actorType, string $actorId, string $displayName, string $question, array $options, int $resultMode, int $maxVotes): Poll { + public function createPoll(int $roomId, string $actorType, string $actorId, string $displayName, string $question, array $options, int $resultMode, int $maxVotes, bool $draft): Poll { $question = trim($question); if ($question === '' || strlen($question) > 32_000) { @@ -78,12 +78,23 @@ public function createPoll(int $roomId, string $actorType, string $actorId, stri $poll->setVotes(json_encode([])); $poll->setResultMode($resultMode); $poll->setMaxVotes($maxVotes); + if ($draft) { + $poll->setStatus(Poll::STATUS_DRAFT); + } $this->pollMapper->insert($poll); return $poll; } + /** + * @param int $roomId + * @return Poll[] + */ + public function getDraftsForRoom(int $roomId): array { + return $this->pollMapper->getDraftsByRoomId($roomId); + } + /** * @param int $roomId * @param int $pollId diff --git a/openapi-full.json b/openapi-full.json index 258b43457db..ed0fcd93142 100644 --- a/openapi-full.json +++ b/openapi-full.json @@ -8729,6 +8729,11 @@ "type": "integer", "format": "int64", "description": "Number of maximum votes per voter" + }, + "draft": { + "type": "boolean", + "default": false, + "description": "Whether the poll should be saved as a draft (only allowed for moderators and with `talk-polls-drafts` capability)" } } } @@ -8830,6 +8835,149 @@ } } }, + "/ocs/v2.php/apps/spreed/api/{apiVersion}/poll/{token}/drafts": { + "get": { + "operationId": "poll-get-all-draft-polls", + "summary": "Get all drafted polls", + "description": "Required capability: `talk-polls-drafts`", + "tags": [ + "poll" + ], + "security": [ + {}, + { + "bearer_auth": [] + }, + { + "basic_auth": [] + } + ], + "parameters": [ + { + "name": "apiVersion", + "in": "path", + "required": true, + "schema": { + "type": "string", + "enum": [ + "v1" + ], + "default": "v1" + } + }, + { + "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": "Poll returned", + "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/Poll" + } + } + } + } + } + } + } + } + }, + "403": { + "description": "User is not a moderator", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + } + } + }, + "404": { + "description": "Poll not found", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + } + } + } + } + } + }, "/ocs/v2.php/apps/spreed/api/{apiVersion}/poll/{token}/{pollId}": { "get": { "operationId": "poll-show-poll", @@ -9210,6 +9358,34 @@ } } }, + "202": { + "description": "Poll draft was deleted successfully", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + } + } + }, "400": { "description": "Poll already closed", "content": { diff --git a/openapi.json b/openapi.json index 5692d31d23e..3484334a3a8 100644 --- a/openapi.json +++ b/openapi.json @@ -8616,6 +8616,11 @@ "type": "integer", "format": "int64", "description": "Number of maximum votes per voter" + }, + "draft": { + "type": "boolean", + "default": false, + "description": "Whether the poll should be saved as a draft (only allowed for moderators and with `talk-polls-drafts` capability)" } } } @@ -8717,6 +8722,149 @@ } } }, + "/ocs/v2.php/apps/spreed/api/{apiVersion}/poll/{token}/drafts": { + "get": { + "operationId": "poll-get-all-draft-polls", + "summary": "Get all drafted polls", + "description": "Required capability: `talk-polls-drafts`", + "tags": [ + "poll" + ], + "security": [ + {}, + { + "bearer_auth": [] + }, + { + "basic_auth": [] + } + ], + "parameters": [ + { + "name": "apiVersion", + "in": "path", + "required": true, + "schema": { + "type": "string", + "enum": [ + "v1" + ], + "default": "v1" + } + }, + { + "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": "Poll returned", + "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/Poll" + } + } + } + } + } + } + } + } + }, + "403": { + "description": "User is not a moderator", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + } + } + }, + "404": { + "description": "Poll not found", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + } + } + } + } + } + }, "/ocs/v2.php/apps/spreed/api/{apiVersion}/poll/{token}/{pollId}": { "get": { "operationId": "poll-show-poll", @@ -9097,6 +9245,34 @@ } } }, + "202": { + "description": "Poll draft was deleted successfully", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + } + } + }, "400": { "description": "Poll already closed", "content": { diff --git a/src/types/openapi/openapi-full.ts b/src/types/openapi/openapi-full.ts index e2b98fab483..7725af319bf 100644 --- a/src/types/openapi/openapi-full.ts +++ b/src/types/openapi/openapi-full.ts @@ -583,6 +583,26 @@ export type paths = { patch?: never; trace?: never; }; + "/ocs/v2.php/apps/spreed/api/{apiVersion}/poll/{token}/drafts": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get all drafted polls + * @description Required capability: `talk-polls-drafts` + */ + get: operations["poll-get-all-draft-polls"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/ocs/v2.php/apps/spreed/api/{apiVersion}/poll/{token}/{pollId}": { parameters: { query?: never; @@ -5114,6 +5134,11 @@ export interface operations { * @description Number of maximum votes per voter */ maxVotes: number; + /** + * @description Whether the poll should be saved as a draft (only allowed for moderators and with `talk-polls-drafts` capability) + * @default false + */ + draft?: boolean; }; }; }; @@ -5148,6 +5173,65 @@ export interface operations { }; }; }; + "poll-get-all-draft-polls": { + parameters: { + query?: never; + header: { + /** @description Required to be true for the API request to pass */ + "OCS-APIRequest": boolean; + }; + path: { + apiVersion: "v1"; + token: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Poll returned */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + ocs: { + meta: components["schemas"]["OCSMeta"]; + data: components["schemas"]["Poll"][]; + }; + }; + }; + }; + /** @description User is not a moderator */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + ocs: { + meta: components["schemas"]["OCSMeta"]; + data: unknown; + }; + }; + }; + }; + /** @description Poll not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + ocs: { + meta: components["schemas"]["OCSMeta"]; + data: unknown; + }; + }; + }; + }; + }; + }; "poll-show-poll": { parameters: { query?: never; @@ -5297,6 +5381,20 @@ export interface operations { }; }; }; + /** @description Poll draft was deleted successfully */ + 202: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + ocs: { + meta: components["schemas"]["OCSMeta"]; + data: unknown; + }; + }; + }; + }; /** @description Poll already closed */ 400: { headers: { diff --git a/src/types/openapi/openapi.ts b/src/types/openapi/openapi.ts index aa9867f63a6..f66aea0c530 100644 --- a/src/types/openapi/openapi.ts +++ b/src/types/openapi/openapi.ts @@ -583,6 +583,26 @@ export type paths = { patch?: never; trace?: never; }; + "/ocs/v2.php/apps/spreed/api/{apiVersion}/poll/{token}/drafts": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get all drafted polls + * @description Required capability: `talk-polls-drafts` + */ + get: operations["poll-get-all-draft-polls"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/ocs/v2.php/apps/spreed/api/{apiVersion}/poll/{token}/{pollId}": { parameters: { query?: never; @@ -4595,6 +4615,11 @@ export interface operations { * @description Number of maximum votes per voter */ maxVotes: number; + /** + * @description Whether the poll should be saved as a draft (only allowed for moderators and with `talk-polls-drafts` capability) + * @default false + */ + draft?: boolean; }; }; }; @@ -4629,6 +4654,65 @@ export interface operations { }; }; }; + "poll-get-all-draft-polls": { + parameters: { + query?: never; + header: { + /** @description Required to be true for the API request to pass */ + "OCS-APIRequest": boolean; + }; + path: { + apiVersion: "v1"; + token: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Poll returned */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + ocs: { + meta: components["schemas"]["OCSMeta"]; + data: components["schemas"]["Poll"][]; + }; + }; + }; + }; + /** @description User is not a moderator */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + ocs: { + meta: components["schemas"]["OCSMeta"]; + data: unknown; + }; + }; + }; + }; + /** @description Poll not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + ocs: { + meta: components["schemas"]["OCSMeta"]; + data: unknown; + }; + }; + }; + }; + }; + }; "poll-show-poll": { parameters: { query?: never; @@ -4778,6 +4862,20 @@ export interface operations { }; }; }; + /** @description Poll draft was deleted successfully */ + 202: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + ocs: { + meta: components["schemas"]["OCSMeta"]; + data: unknown; + }; + }; + }; + }; /** @description Poll already closed */ 400: { headers: { diff --git a/tests/integration/features/bootstrap/FeatureContext.php b/tests/integration/features/bootstrap/FeatureContext.php index 237110ba516..811d4484452 100644 --- a/tests/integration/features/bootstrap/FeatureContext.php +++ b/tests/integration/features/bootstrap/FeatureContext.php @@ -2407,6 +2407,9 @@ public function createPoll(string $user, string $identifier, string $statusCode, if ($data['maxVotes'] === 'unlimited') { $data['maxVotes'] = 0; } + if (isset($data['draft'])) { + $data['draft'] = (bool)$data['draft']; + } $this->setCurrentUser($user); $this->sendRequest( @@ -2528,6 +2531,8 @@ protected function preparePollExpectedData(array $expected): array { $expected['status'] = 0; } elseif ($expected['status'] === 'closed') { $expected['status'] = 1; + } elseif ($expected['status'] === 'draft') { + $expected['status'] = 2; } if (str_ends_with($expected['actorId'], '@{$LOCAL_URL}')) { diff --git a/tests/integration/features/chat-3/poll.feature b/tests/integration/features/chat-3/poll.feature index e17749048b3..ce9f0270629 100644 --- a/tests/integration/features/chat-3/poll.feature +++ b/tests/integration/features/chat-3/poll.feature @@ -805,3 +805,44 @@ Feature: chat-2/poll Then user "participant1" sees the following system messages in room "room" with 200 (v1) | room | actorType | actorId | systemMessage | message | silent | messageParameters | | room | users | participant1 | history_cleared | You cleared the history of the conversation | !ISSET | {"actor":{"type":"user","id":"participant1","name":"participant1-displayname"}} | + + Scenario: Create a public poll without max votes limit + Given user "participant1" creates room "room" (v4) + | roomType | 2 | + | roomName | room | + When user "participant1" adds user "participant2" to room "room" with 200 (v4) + When user "participant1" creates a poll in room "room" with 201 + | question | What is the question? | + | options | ["Where are you?","How much is the fish?"] | + | resultMode | public | + | maxVotes | unlimited | + | draft | 1 | + Then user "participant1" sees the following messages in room "room" with 200 + | room | actorType | actorId | actorDisplayName | message | messageParameters | + Then user "participant1" sees poll "What is the question?" in room "room" with 200 + | id | POLL_ID(What is the question?) | + | question | What is the question? | + | options | ["Where are you?","How much is the fish?"] | + | votes | [] | + | numVoters | 0 | + | resultMode | public | + | maxVotes | unlimited | + | actorType | users | + | actorId | participant1 | + | actorDisplayName | participant1-displayname | + | status | draft | + | votedSelf | not voted | + Then user "participant2" sees poll "What is the question?" in room "room" with 404 + Then user "participant1" votes for options "[1]" on poll "What is the question?" in room "room" with 404 + Then user "participant2" votes for options "[1]" on poll "What is the question?" in room "room" with 404 + Then user "participant1" closes poll "What is the question?" in room "room" with 202 + Then user "participant1" sees poll "What is the question?" in room "room" with 404 + Then user "participant2" sees poll "What is the question?" in room "room" with 404 + Then user "participant1" sees the following system messages in room "room" with 200 (v1) + | room | actorType | actorId | systemMessage | message | silent | messageParameters | + | room | users | participant1 | user_added | You added {user} | !ISSET | {"actor":{"type":"user","id":"participant1","name":"participant1-displayname"},"user":{"type":"user","id":"participant2","name":"participant2-displayname"}} | + | room | users | participant1 | conversation_created | You created the conversation | !ISSET | {"actor":{"type":"user","id":"participant1","name":"participant1-displayname"}} | + Then user "participant2" sees the following system messages in room "room" with 200 (v1) + | room | actorType | actorId | systemMessage | message | silent | messageParameters | + | room | users | participant1 | user_added | {actor} added you | !ISSET | {"actor":{"type":"user","id":"participant1","name":"participant1-displayname"},"user":{"type":"user","id":"participant2","name":"participant2-displayname"}} | + | room | users | participant1 | conversation_created | {actor} created the conversation | !ISSET | {"actor":{"type":"user","id":"participant1","name":"participant1-displayname"}} | From f1353929198ae38a70ecaa2d2c994e9d5abc9547 Mon Sep 17 00:00:00 2001 From: Joas Schilling Date: Thu, 10 Oct 2024 13:44:53 +0200 Subject: [PATCH 2/5] feat(polls): Split draft model from normal polls Signed-off-by: Joas Schilling --- lib/Controller/PollController.php | 7 ++- .../TalkV1/Controller/PollController.php | 5 +- lib/Federation/Proxy/TalkV1/UserConverter.php | 8 ++- lib/Model/Poll.php | 18 ++++-- lib/Model/PollMapper.php | 3 +- lib/ResponseDefinitions.php | 9 ++- openapi-full.json | 62 +++++++++++-------- openapi.json | 62 +++++++++++-------- src/types/openapi/openapi-full.ts | 20 +++--- src/types/openapi/openapi.ts | 20 +++--- .../features/bootstrap/FeatureContext.php | 41 ++++++++++++ .../integration/features/chat-3/poll.feature | 23 ++++++- 12 files changed, 191 insertions(+), 87 deletions(-) diff --git a/lib/Controller/PollController.php b/lib/Controller/PollController.php index 29f9d7c9b3a..6efdc821d6f 100644 --- a/lib/Controller/PollController.php +++ b/lib/Controller/PollController.php @@ -35,6 +35,7 @@ /** * @psalm-import-type TalkPoll from ResponseDefinitions + * @psalm-import-type TalkPollDraft from ResponseDefinitions */ class PollController extends AEnvironmentAwareController { @@ -134,7 +135,7 @@ public function createPoll(string $question, array $options, int $resultMode, in * * Required capability: `talk-polls-drafts` * - * @return DataResponse, array{}>|DataResponse, array{}> + * @return DataResponse, array{}>|DataResponse, array{}> * * 200: Poll returned * 403: User is not a moderator @@ -153,7 +154,7 @@ public function getAllDraftPolls(): DataResponse { $polls = $this->pollService->getDraftsForRoom($this->room->getId()); $data = []; foreach ($polls as $poll) { - $data[] = $this->renderPoll($poll); + $data[] = $poll->renderAsDraft(); } return new DataResponse($data); @@ -346,7 +347,7 @@ public function closePoll(int $pollId): DataResponse { * @throws JsonException */ protected function renderPoll(Poll $poll, array $votedSelf = [], array $detailedVotes = []): array { - $data = $poll->asArray(); + $data = $poll->renderAsPoll(); $canSeeSummary = !empty($votedSelf) && $poll->getResultMode() === Poll::MODE_PUBLIC; diff --git a/lib/Federation/Proxy/TalkV1/Controller/PollController.php b/lib/Federation/Proxy/TalkV1/Controller/PollController.php index 9ab604cb3d6..32eceb66953 100644 --- a/lib/Federation/Proxy/TalkV1/Controller/PollController.php +++ b/lib/Federation/Proxy/TalkV1/Controller/PollController.php @@ -20,6 +20,7 @@ /** * @psalm-import-type TalkPoll from ResponseDefinitions + * @psalm-import-type TalkPollDraft from ResponseDefinitions */ class PollController { public function __construct( @@ -29,7 +30,7 @@ public function __construct( } /** - * @return DataResponse, array{}>|DataResponse, array{}> + * @return DataResponse, array{}>|DataResponse, array{}> * @throws CannotReachRemoteException * * 200: Polls returned @@ -49,7 +50,7 @@ public function getDraftsForRoom(Room $room, Participant $participant): DataResp return new DataResponse([], $status); } - /** @var list $list */ + /** @var list $list */ $list = $this->proxy->getOCSData($proxy); $data = []; diff --git a/lib/Federation/Proxy/TalkV1/UserConverter.php b/lib/Federation/Proxy/TalkV1/UserConverter.php index 5d5c6c9f480..6e435d38591 100644 --- a/lib/Federation/Proxy/TalkV1/UserConverter.php +++ b/lib/Federation/Proxy/TalkV1/UserConverter.php @@ -18,6 +18,7 @@ /** * @psalm-import-type TalkChatMessageWithParent from ResponseDefinitions * @psalm-import-type TalkPoll from ResponseDefinitions + * @psalm-import-type TalkPollDraft from ResponseDefinitions * @psalm-import-type TalkReaction from ResponseDefinitions */ class UserConverter { @@ -137,9 +138,12 @@ public function convertMessages(Room $room, array $messages): array { } /** + * @template T of TalkPoll|TalkPollDraft * @param Room $room - * @param TalkPoll $poll - * @return TalkPoll + * @param TalkPoll|TalkPollDraft $poll + * @psalm-param T $poll + * @return TalkPoll|TalkPollDraft + * @psalm-return T */ public function convertPoll(Room $room, array $poll): array { $poll = $this->convertAttendee($room, $poll, 'actorType', 'actorId', 'actorDisplayName'); diff --git a/lib/Model/Poll.php b/lib/Model/Poll.php index 63b123e9a25..b8531aaa537 100644 --- a/lib/Model/Poll.php +++ b/lib/Model/Poll.php @@ -37,6 +37,7 @@ * @method int getMaxVotes() * * @psalm-import-type TalkPoll from ResponseDefinitions + * @psalm-import-type TalkPollDraft from ResponseDefinitions */ class Poll extends Entity { public const STATUS_OPEN = 0; @@ -75,25 +76,32 @@ public function __construct() { /** * @return TalkPoll */ - public function asArray(): array { + public function renderAsPoll(): array { + $data = $this->renderAsDraft(); $votes = json_decode($this->getVotes(), true, 512, JSON_THROW_ON_ERROR); // Because PHP is turning arrays with sequent numeric keys "{"0":x,"1":y,"2":z}" into "[x,y,z]" // when json_encode() is used we have to prefix the keys with a string, // to prevent breaking in the mobile apps. - $prefixedVotes = []; + $data['votes'] = []; foreach ($votes as $option => $count) { - $prefixedVotes['option-' . $option] = $count; + $data['votes']['option-' . $option] = $count; } + $data['numVoters'] = $this->getNumVoters(); + return $data; + } + + /** + * @return TalkPollDraft + */ + public function renderAsDraft(): array { return [ 'id' => $this->getId(), // The room id is not needed on the API level but only internally for optimising database queries // 'roomId' => $this->getRoomId(), 'question' => $this->getQuestion(), 'options' => json_decode($this->getOptions(), true, 512, JSON_THROW_ON_ERROR), - 'votes' => $prefixedVotes, - 'numVoters' => $this->getNumVoters(), 'actorType' => $this->getActorType(), 'actorId' => $this->getActorId(), 'actorDisplayName' => $this->getDisplayName(), diff --git a/lib/Model/PollMapper.php b/lib/Model/PollMapper.php index deee8d32c11..ee59e9937a3 100644 --- a/lib/Model/PollMapper.php +++ b/lib/Model/PollMapper.php @@ -36,7 +36,8 @@ public function getDraftsByRoomId(int $roomId): array { $query->select('*') ->from($this->getTableName()) ->where($query->expr()->eq('room_id', $query->createNamedParameter($roomId, IQueryBuilder::PARAM_INT))) - ->andWhere($query->expr()->eq('status', $query->createNamedParameter(Poll::STATUS_DRAFT, IQueryBuilder::PARAM_INT))); + ->andWhere($query->expr()->eq('status', $query->createNamedParameter(Poll::STATUS_DRAFT, IQueryBuilder::PARAM_INT))) + ->orderBy('id', 'ASC'); return $this->findEntities($query); } diff --git a/lib/ResponseDefinitions.php b/lib/ResponseDefinitions.php index 01f9c0e8d78..7ae15fdfb2e 100644 --- a/lib/ResponseDefinitions.php +++ b/lib/ResponseDefinitions.php @@ -197,18 +197,21 @@ * optionId: int, * } * - * @psalm-type TalkPoll = array{ + * @psalm-type TalkPollDraft = array{ * actorDisplayName: string, * actorId: string, * actorType: string, - * details?: TalkPollVote[], * id: int, * maxVotes: int, - * numVoters?: int, * options: string[], * question: string, * resultMode: int, * status: int, + * } + * + * @psalm-type TalkPoll = TalkPollDraft&array{ + * details?: TalkPollVote[], + * numVoters?: int, * votedSelf?: int[], * votes?: array, * } diff --git a/openapi-full.json b/openapi-full.json index ed0fcd93142..920178c7865 100644 --- a/openapi-full.json +++ b/openapi-full.json @@ -847,6 +847,42 @@ } }, "Poll": { + "allOf": [ + { + "$ref": "#/components/schemas/PollDraft" + }, + { + "type": "object", + "properties": { + "details": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PollVote" + } + }, + "numVoters": { + "type": "integer", + "format": "int64" + }, + "votedSelf": { + "type": "array", + "items": { + "type": "integer", + "format": "int64" + } + }, + "votes": { + "type": "object", + "additionalProperties": { + "type": "integer", + "format": "int64" + } + } + } + } + ] + }, + "PollDraft": { "type": "object", "required": [ "actorDisplayName", @@ -869,12 +905,6 @@ "actorType": { "type": "string" }, - "details": { - "type": "array", - "items": { - "$ref": "#/components/schemas/PollVote" - } - }, "id": { "type": "integer", "format": "int64" @@ -883,10 +913,6 @@ "type": "integer", "format": "int64" }, - "numVoters": { - "type": "integer", - "format": "int64" - }, "options": { "type": "array", "items": { @@ -903,20 +929,6 @@ "status": { "type": "integer", "format": "int64" - }, - "votedSelf": { - "type": "array", - "items": { - "type": "integer", - "format": "int64" - } - }, - "votes": { - "type": "object", - "additionalProperties": { - "type": "integer", - "format": "int64" - } } } }, @@ -8909,7 +8921,7 @@ "data": { "type": "array", "items": { - "$ref": "#/components/schemas/Poll" + "$ref": "#/components/schemas/PollDraft" } } } diff --git a/openapi.json b/openapi.json index 3484334a3a8..6fc6913e261 100644 --- a/openapi.json +++ b/openapi.json @@ -734,6 +734,42 @@ } }, "Poll": { + "allOf": [ + { + "$ref": "#/components/schemas/PollDraft" + }, + { + "type": "object", + "properties": { + "details": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PollVote" + } + }, + "numVoters": { + "type": "integer", + "format": "int64" + }, + "votedSelf": { + "type": "array", + "items": { + "type": "integer", + "format": "int64" + } + }, + "votes": { + "type": "object", + "additionalProperties": { + "type": "integer", + "format": "int64" + } + } + } + } + ] + }, + "PollDraft": { "type": "object", "required": [ "actorDisplayName", @@ -756,12 +792,6 @@ "actorType": { "type": "string" }, - "details": { - "type": "array", - "items": { - "$ref": "#/components/schemas/PollVote" - } - }, "id": { "type": "integer", "format": "int64" @@ -770,10 +800,6 @@ "type": "integer", "format": "int64" }, - "numVoters": { - "type": "integer", - "format": "int64" - }, "options": { "type": "array", "items": { @@ -790,20 +816,6 @@ "status": { "type": "integer", "format": "int64" - }, - "votedSelf": { - "type": "array", - "items": { - "type": "integer", - "format": "int64" - } - }, - "votes": { - "type": "object", - "additionalProperties": { - "type": "integer", - "format": "int64" - } } } }, @@ -8796,7 +8808,7 @@ "data": { "type": "array", "items": { - "$ref": "#/components/schemas/Poll" + "$ref": "#/components/schemas/PollDraft" } } } diff --git a/src/types/openapi/openapi-full.ts b/src/types/openapi/openapi-full.ts index 7725af319bf..829f7a73baf 100644 --- a/src/types/openapi/openapi-full.ts +++ b/src/types/openapi/openapi-full.ts @@ -2055,27 +2055,29 @@ export type components = { phoneNumber?: string | null; callId?: string | null; }; - Poll: { + Poll: components["schemas"]["PollDraft"] & { + details?: components["schemas"]["PollVote"][]; + /** Format: int64 */ + numVoters?: number; + votedSelf?: number[]; + votes?: { + [key: string]: number; + }; + }; + PollDraft: { actorDisplayName: string; actorId: string; actorType: string; - details?: components["schemas"]["PollVote"][]; /** Format: int64 */ id: number; /** Format: int64 */ maxVotes: number; - /** Format: int64 */ - numVoters?: number; options: string[]; question: string; /** Format: int64 */ resultMode: number; /** Format: int64 */ status: number; - votedSelf?: number[]; - votes?: { - [key: string]: number; - }; }; PollVote: { actorDisplayName: string; @@ -5197,7 +5199,7 @@ export interface operations { "application/json": { ocs: { meta: components["schemas"]["OCSMeta"]; - data: components["schemas"]["Poll"][]; + data: components["schemas"]["PollDraft"][]; }; }; }; diff --git a/src/types/openapi/openapi.ts b/src/types/openapi/openapi.ts index f66aea0c530..4d8ebd26649 100644 --- a/src/types/openapi/openapi.ts +++ b/src/types/openapi/openapi.ts @@ -1536,27 +1536,29 @@ export type components = { phoneNumber?: string | null; callId?: string | null; }; - Poll: { + Poll: components["schemas"]["PollDraft"] & { + details?: components["schemas"]["PollVote"][]; + /** Format: int64 */ + numVoters?: number; + votedSelf?: number[]; + votes?: { + [key: string]: number; + }; + }; + PollDraft: { actorDisplayName: string; actorId: string; actorType: string; - details?: components["schemas"]["PollVote"][]; /** Format: int64 */ id: number; /** Format: int64 */ maxVotes: number; - /** Format: int64 */ - numVoters?: number; options: string[]; question: string; /** Format: int64 */ resultMode: number; /** Format: int64 */ status: number; - votedSelf?: number[]; - votes?: { - [key: string]: number; - }; }; PollVote: { actorDisplayName: string; @@ -4678,7 +4680,7 @@ export interface operations { "application/json": { ocs: { meta: components["schemas"]["OCSMeta"]; - data: components["schemas"]["Poll"][]; + data: components["schemas"]["PollDraft"][]; }; }; }; diff --git a/tests/integration/features/bootstrap/FeatureContext.php b/tests/integration/features/bootstrap/FeatureContext.php index 811d4484452..8d45529de21 100644 --- a/tests/integration/features/bootstrap/FeatureContext.php +++ b/tests/integration/features/bootstrap/FeatureContext.php @@ -2428,6 +2428,47 @@ public function createPoll(string $user, string $identifier, string $statusCode, } } + /** + * @Then /^user "([^"]*)" gets poll drafts for room "([^"]*)" with (\d+)(?: \((v1)\))?$/ + * + * @param string $user + * @param string $identifier + * @param string $statusCode + * @param string $apiVersion + */ + public function getPollDrafts(string $user, string $identifier, string $statusCode, string $apiVersion = 'v1', ?TableNode $formData = null): void { + $this->setCurrentUser($user); + $this->sendRequest('GET', '/apps/spreed/api/' . $apiVersion . '/poll/' . self::$identifierToToken[$identifier] . '/drafts'); + $this->assertStatusCode($this->response, $statusCode); + + if ($statusCode !== '200') { + return; + } + + $response = $this->getDataFromResponse($this->response); + $data = array_map(static function (array $poll): array { + $result = preg_match('/POLL_ID\(([^)]+)\)/', $poll['id'], $matches); + if ($result) { + $poll['id'] = self::$questionToPollId[$matches[1]]; + } + $poll['resultMode'] = match($poll['resultMode']) { + 'public' => 0, + 'hidden' => 1, + }; + $poll['status'] = match($poll['status']) { + 'open' => 0, + 'closed' => 1, + 'draft' => 2, + }; + $poll['maxVotes'] = (int)$poll['maxVotes']; + $poll['options'] = json_decode($poll['options'], true, flags: JSON_THROW_ON_ERROR); + return $poll; + }, $formData->getColumnsHash()); + + Assert::assertCount(count($data), $response); + Assert::assertSame($data, $response); + } + /** * @Then /^user "([^"]*)" sees poll "([^"]*)" in room "([^"]*)" with (\d+)(?: \((v1)\))?$/ * diff --git a/tests/integration/features/chat-3/poll.feature b/tests/integration/features/chat-3/poll.feature index ce9f0270629..7406aefeab8 100644 --- a/tests/integration/features/chat-3/poll.feature +++ b/tests/integration/features/chat-3/poll.feature @@ -806,23 +806,40 @@ Feature: chat-2/poll | room | actorType | actorId | systemMessage | message | silent | messageParameters | | room | users | participant1 | history_cleared | You cleared the history of the conversation | !ISSET | {"actor":{"type":"user","id":"participant1","name":"participant1-displayname"}} | - Scenario: Create a public poll without max votes limit + Scenario: Drafts Given user "participant1" creates room "room" (v4) | roomType | 2 | | roomName | room | When user "participant1" adds user "participant2" to room "room" with 200 (v4) When user "participant1" creates a poll in room "room" with 201 | question | What is the question? | - | options | ["Where are you?","How much is the fish?"] | + | options | ["You","me"] | | resultMode | public | | maxVotes | unlimited | | draft | 1 | + When user "participant1" creates a poll in room "room" with 201 + | question | Shall we draft 2 questions? | + | options | ["Yes","No"] | + | resultMode | hidden | + | maxVotes | 1 | + | draft | 1 | + When user "participant1" creates a poll in room "room" with 201 + | question | This is not a draft! | + | options | ["Yes!","Ok!"] | + | resultMode | public | + | maxVotes | 1 | + | draft | 0 | + When user "participant1" gets poll drafts for room "room" with 200 + | id | question | options | actorType | actorId | actorDisplayName | status | resultMode | maxVotes | + | POLL_ID(What is the question?) | What is the question? | ["You","me"] | users | participant1 | participant1-displayname | draft | public | 0 | + | POLL_ID(Shall we draft 2 questions?) | Shall we draft 2 questions? | ["Yes","No"] | users | participant1 | participant1-displayname | draft | hidden | 1 | Then user "participant1" sees the following messages in room "room" with 200 | room | actorType | actorId | actorDisplayName | message | messageParameters | + | room | users | participant1 | participant1-displayname | {object} | {"actor":{"type":"user","id":"participant1","name":"participant1-displayname"},"object":{"type":"talk-poll","id":POLL_ID(This is not a draft!),"name":"This is not a draft!"}} | Then user "participant1" sees poll "What is the question?" in room "room" with 200 | id | POLL_ID(What is the question?) | | question | What is the question? | - | options | ["Where are you?","How much is the fish?"] | + | options | ["You","me"] | | votes | [] | | numVoters | 0 | | resultMode | public | From 92f38b687fca5c5dbd19303133ef7810607eed45 Mon Sep 17 00:00:00 2001 From: Joas Schilling Date: Fri, 11 Oct 2024 12:35:50 +0200 Subject: [PATCH 3/5] feat(polls): Use different status code for drafts Signed-off-by: Joas Schilling --- lib/Controller/PollController.php | 41 ++++++++++--------- .../TalkV1/Controller/PollController.php | 10 +++-- openapi-full.json | 30 ++++++++++++++ openapi.json | 30 ++++++++++++++ src/types/openapi/openapi-full.ts | 14 +++++++ src/types/openapi/openapi.ts | 14 +++++++ .../features/bootstrap/FeatureContext.php | 2 +- .../integration/features/chat-3/poll.feature | 4 +- 8 files changed, 120 insertions(+), 25 deletions(-) diff --git a/lib/Controller/PollController.php b/lib/Controller/PollController.php index 6efdc821d6f..80aaa280368 100644 --- a/lib/Controller/PollController.php +++ b/lib/Controller/PollController.php @@ -61,8 +61,9 @@ public function __construct( * @psalm-param Poll::MODE_* $resultMode Mode how the results will be shown * @param int $maxVotes Number of maximum votes per voter * @param bool $draft Whether the poll should be saved as a draft (only allowed for moderators and with `talk-polls-drafts` capability) - * @return DataResponse|DataResponse, array{}> + * @return DataResponse|DataResponse|DataResponse, array{}> * + * 200: Draft created successfully * 201: Poll created successfully * 400: Creating poll is not possible */ @@ -106,25 +107,27 @@ public function createPoll(string $question, array $options, int $resultMode, in return new DataResponse([], Http::STATUS_BAD_REQUEST); } - if (!$draft) { - $message = json_encode([ - 'message' => 'object_shared', - 'parameters' => [ - 'objectType' => 'talk-poll', - 'objectId' => $poll->getId(), - 'metaData' => [ - 'type' => 'talk-poll', - 'id' => $poll->getId(), - 'name' => $question, - ] - ], - ], JSON_THROW_ON_ERROR); + if ($draft) { + return new DataResponse($poll->renderAsDraft()); + } - try { - $this->chatManager->addSystemMessage($this->room, $attendee->getActorType(), $attendee->getActorId(), $message, $this->timeFactory->getDateTime(), true); - } catch (\Exception $e) { - $this->logger->error($e->getMessage(), ['exception' => $e]); - } + $message = json_encode([ + 'message' => 'object_shared', + 'parameters' => [ + 'objectType' => 'talk-poll', + 'objectId' => $poll->getId(), + 'metaData' => [ + 'type' => 'talk-poll', + 'id' => $poll->getId(), + 'name' => $question, + ] + ], + ], JSON_THROW_ON_ERROR); + + try { + $this->chatManager->addSystemMessage($this->room, $attendee->getActorType(), $attendee->getActorId(), $message, $this->timeFactory->getDateTime(), true); + } catch (\Exception $e) { + $this->logger->error($e->getMessage(), ['exception' => $e]); } return new DataResponse($this->renderPoll($poll), Http::STATUS_CREATED); diff --git a/lib/Federation/Proxy/TalkV1/Controller/PollController.php b/lib/Federation/Proxy/TalkV1/Controller/PollController.php index 32eceb66953..bc70c4f60a3 100644 --- a/lib/Federation/Proxy/TalkV1/Controller/PollController.php +++ b/lib/Federation/Proxy/TalkV1/Controller/PollController.php @@ -126,7 +126,7 @@ public function votePoll(Room $room, Participant $participant, int $pollId, arra /** - * @return DataResponse|DataResponse, array{}> + * @return DataResponse|DataResponse|DataResponse, array{}> * @throws CannotReachRemoteException * * 201: Poll created successfully @@ -148,14 +148,18 @@ public function createPoll(Room $room, Participant $participant, string $questio ], ); - if ($proxy->getStatusCode() === Http::STATUS_BAD_REQUEST) { + $status = $proxy->getStatusCode(); + if ($status === Http::STATUS_BAD_REQUEST) { return new DataResponse([], Http::STATUS_BAD_REQUEST); } /** @var TalkPoll $data */ - $data = $this->proxy->getOCSData($proxy, [Http::STATUS_CREATED]); + $data = $this->proxy->getOCSData($proxy, [Http::STATUS_OK, Http::STATUS_CREATED]); $data = $this->userConverter->convertPoll($room, $data); + if ($status === Http::STATUS_OK) { + return new DataResponse($data); + } return new DataResponse($data, Http::STATUS_CREATED); } diff --git a/openapi-full.json b/openapi-full.json index 920178c7865..61aeefe7b71 100644 --- a/openapi-full.json +++ b/openapi-full.json @@ -8786,6 +8786,36 @@ } ], "responses": { + "200": { + "description": "Draft created successfully", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "$ref": "#/components/schemas/PollDraft" + } + } + } + } + } + } + } + }, "201": { "description": "Poll created successfully", "content": { diff --git a/openapi.json b/openapi.json index 6fc6913e261..bc58e361b3c 100644 --- a/openapi.json +++ b/openapi.json @@ -8673,6 +8673,36 @@ } ], "responses": { + "200": { + "description": "Draft created successfully", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "$ref": "#/components/schemas/PollDraft" + } + } + } + } + } + } + } + }, "201": { "description": "Poll created successfully", "content": { diff --git a/src/types/openapi/openapi-full.ts b/src/types/openapi/openapi-full.ts index 829f7a73baf..97ec10c683a 100644 --- a/src/types/openapi/openapi-full.ts +++ b/src/types/openapi/openapi-full.ts @@ -5145,6 +5145,20 @@ export interface operations { }; }; responses: { + /** @description Draft created successfully */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + ocs: { + meta: components["schemas"]["OCSMeta"]; + data: components["schemas"]["PollDraft"]; + }; + }; + }; + }; /** @description Poll created successfully */ 201: { headers: { diff --git a/src/types/openapi/openapi.ts b/src/types/openapi/openapi.ts index 4d8ebd26649..277dc18b31d 100644 --- a/src/types/openapi/openapi.ts +++ b/src/types/openapi/openapi.ts @@ -4626,6 +4626,20 @@ export interface operations { }; }; responses: { + /** @description Draft created successfully */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + ocs: { + meta: components["schemas"]["OCSMeta"]; + data: components["schemas"]["PollDraft"]; + }; + }; + }; + }; /** @description Poll created successfully */ 201: { headers: { diff --git a/tests/integration/features/bootstrap/FeatureContext.php b/tests/integration/features/bootstrap/FeatureContext.php index 8d45529de21..6271733bc69 100644 --- a/tests/integration/features/bootstrap/FeatureContext.php +++ b/tests/integration/features/bootstrap/FeatureContext.php @@ -2418,7 +2418,7 @@ public function createPoll(string $user, string $identifier, string $statusCode, ); $this->assertStatusCode($this->response, $statusCode); - if ($statusCode !== '201') { + if ($statusCode !== '200' && $statusCode !== '201') { return; } diff --git a/tests/integration/features/chat-3/poll.feature b/tests/integration/features/chat-3/poll.feature index 7406aefeab8..ab274d0193d 100644 --- a/tests/integration/features/chat-3/poll.feature +++ b/tests/integration/features/chat-3/poll.feature @@ -811,13 +811,13 @@ Feature: chat-2/poll | roomType | 2 | | roomName | room | When user "participant1" adds user "participant2" to room "room" with 200 (v4) - When user "participant1" creates a poll in room "room" with 201 + When user "participant1" creates a poll in room "room" with 200 | question | What is the question? | | options | ["You","me"] | | resultMode | public | | maxVotes | unlimited | | draft | 1 | - When user "participant1" creates a poll in room "room" with 201 + When user "participant1" creates a poll in room "room" with 200 | question | Shall we draft 2 questions? | | options | ["Yes","No"] | | resultMode | hidden | From dfb44f9689861be935f52ff7c6fc12f531e9c3b4 Mon Sep 17 00:00:00 2001 From: Joas Schilling Date: Fri, 11 Oct 2024 13:45:52 +0200 Subject: [PATCH 4/5] feat(polls): Stronger API typing Signed-off-by: Joas Schilling --- lib/Model/Poll.php | 10 +++++++ lib/ResponseDefinitions.php | 20 +++++++------- openapi-full.json | 44 +++++++++++++++++++++++++------ openapi.json | 44 +++++++++++++++++++++++++------ src/types/openapi/openapi-full.ts | 18 +++++++++---- src/types/openapi/openapi.ts | 18 +++++++++---- 6 files changed, 119 insertions(+), 35 deletions(-) diff --git a/lib/Model/Poll.php b/lib/Model/Poll.php index b8531aaa537..d53c0b05d45 100644 --- a/lib/Model/Poll.php +++ b/lib/Model/Poll.php @@ -13,29 +13,39 @@ use OCP\AppFramework\Db\Entity; /** + * @psalm-method int<1, max> getId() * @method void setRoomId(int $roomId) * @method int getRoomId() + * @psalm-method int<1, max> getRoomId() * @method void setQuestion(string $question) * @method string getQuestion() + * @psalm-method non-empty-string getQuestion() * @method void setOptions(string $options) * @method string getOptions() * @method void setVotes(string $votes) * @method string getVotes() * @method void setNumVoters(int $numVoters) * @method int getNumVoters() + * @psalm-method int<0, max> getNumVoters() * @method void setActorType(string $actorType) * @method string getActorType() + * @psalm-method TalkActorTypes getActorType() * @method void setActorId(string $actorId) * @method string getActorId() + * @psalm-method non-empty-string getActorId() * @method void setDisplayName(string $displayName) * @method string getDisplayName() * @method void setStatus(int $status) * @method int getStatus() + * @psalm-method self::STATUS_* getStatus() * @method void setResultMode(int $resultMode) * @method int getResultMode() + * @psalm-method self::MODE_* getResultMode() * @method void setMaxVotes(int $maxVotes) * @method int getMaxVotes() + * @psalm-method int<0, max> getMaxVotes() * + * @psalm-import-type TalkActorTypes from ResponseDefinitions * @psalm-import-type TalkPoll from ResponseDefinitions * @psalm-import-type TalkPollDraft from ResponseDefinitions */ diff --git a/lib/ResponseDefinitions.php b/lib/ResponseDefinitions.php index 7ae15fdfb2e..3b9e5a90451 100644 --- a/lib/ResponseDefinitions.php +++ b/lib/ResponseDefinitions.php @@ -10,6 +10,8 @@ namespace OCA\Talk; /** + * @psalm-type TalkActorTypes = 'users'|'groups'|'guests'|'emails'|'circles'|'bridged'|'bots'|'federated_users'|'phones' + * * @psalm-type TalkBan = array{ * id: int, * moderatorActorType: string, @@ -199,19 +201,19 @@ * * @psalm-type TalkPollDraft = array{ * actorDisplayName: string, - * actorId: string, - * actorType: string, - * id: int, - * maxVotes: int, - * options: string[], - * question: string, - * resultMode: int, - * status: int, + * actorId: non-empty-string, + * actorType: TalkActorTypes, + * id: int<1, max>, + * maxVotes: int<0, max>, + * options: list, + * question: non-empty-string, + * resultMode: 0|1, + * status: 0|1|2, * } * * @psalm-type TalkPoll = TalkPollDraft&array{ * details?: TalkPollVote[], - * numVoters?: int, + * numVoters?: int<0, max>, * votedSelf?: int[], * votes?: array, * } diff --git a/openapi-full.json b/openapi-full.json index 61aeefe7b71..788965c366b 100644 --- a/openapi-full.json +++ b/openapi-full.json @@ -20,6 +20,20 @@ } }, "schemas": { + "ActorTypes": { + "type": "string", + "enum": [ + "users", + "groups", + "guests", + "emails", + "circles", + "bridged", + "bots", + "federated_users", + "phones" + ] + }, "Ban": { "type": "object", "required": [ @@ -862,7 +876,8 @@ }, "numVoters": { "type": "integer", - "format": "int64" + "format": "int64", + "minimum": 0 }, "votedSelf": { "type": "array", @@ -900,18 +915,21 @@ "type": "string" }, "actorId": { - "type": "string" + "type": "string", + "minLength": 1 }, "actorType": { - "type": "string" + "$ref": "#/components/schemas/ActorTypes" }, "id": { "type": "integer", - "format": "int64" + "format": "int64", + "minimum": 1 }, "maxVotes": { "type": "integer", - "format": "int64" + "format": "int64", + "minimum": 0 }, "options": { "type": "array", @@ -920,15 +938,25 @@ } }, "question": { - "type": "string" + "type": "string", + "minLength": 1 }, "resultMode": { "type": "integer", - "format": "int64" + "format": "int64", + "enum": [ + 0, + 1 + ] }, "status": { "type": "integer", - "format": "int64" + "format": "int64", + "enum": [ + 0, + 1, + 2 + ] } } }, diff --git a/openapi.json b/openapi.json index bc58e361b3c..24e669cc911 100644 --- a/openapi.json +++ b/openapi.json @@ -20,6 +20,20 @@ } }, "schemas": { + "ActorTypes": { + "type": "string", + "enum": [ + "users", + "groups", + "guests", + "emails", + "circles", + "bridged", + "bots", + "federated_users", + "phones" + ] + }, "Ban": { "type": "object", "required": [ @@ -749,7 +763,8 @@ }, "numVoters": { "type": "integer", - "format": "int64" + "format": "int64", + "minimum": 0 }, "votedSelf": { "type": "array", @@ -787,18 +802,21 @@ "type": "string" }, "actorId": { - "type": "string" + "type": "string", + "minLength": 1 }, "actorType": { - "type": "string" + "$ref": "#/components/schemas/ActorTypes" }, "id": { "type": "integer", - "format": "int64" + "format": "int64", + "minimum": 1 }, "maxVotes": { "type": "integer", - "format": "int64" + "format": "int64", + "minimum": 0 }, "options": { "type": "array", @@ -807,15 +825,25 @@ } }, "question": { - "type": "string" + "type": "string", + "minLength": 1 }, "resultMode": { "type": "integer", - "format": "int64" + "format": "int64", + "enum": [ + 0, + 1 + ] }, "status": { "type": "integer", - "format": "int64" + "format": "int64", + "enum": [ + 0, + 1, + 2 + ] } } }, diff --git a/src/types/openapi/openapi-full.ts b/src/types/openapi/openapi-full.ts index 97ec10c683a..9072b8dbfa2 100644 --- a/src/types/openapi/openapi-full.ts +++ b/src/types/openapi/openapi-full.ts @@ -1825,6 +1825,8 @@ export type paths = { export type webhooks = Record; export type components = { schemas: { + /** @enum {string} */ + ActorTypes: "users" | "groups" | "guests" | "emails" | "circles" | "bridged" | "bots" | "federated_users" | "phones"; Ban: { /** Format: int64 */ id: number; @@ -2067,17 +2069,23 @@ export type components = { PollDraft: { actorDisplayName: string; actorId: string; - actorType: string; + actorType: components["schemas"]["ActorTypes"]; /** Format: int64 */ id: number; /** Format: int64 */ maxVotes: number; options: string[]; question: string; - /** Format: int64 */ - resultMode: number; - /** Format: int64 */ - status: number; + /** + * Format: int64 + * @enum {integer} + */ + resultMode: 0 | 1; + /** + * Format: int64 + * @enum {integer} + */ + status: 0 | 1 | 2; }; PollVote: { actorDisplayName: string; diff --git a/src/types/openapi/openapi.ts b/src/types/openapi/openapi.ts index 277dc18b31d..283d8013dc9 100644 --- a/src/types/openapi/openapi.ts +++ b/src/types/openapi/openapi.ts @@ -1336,6 +1336,8 @@ export type paths = { export type webhooks = Record; export type components = { schemas: { + /** @enum {string} */ + ActorTypes: "users" | "groups" | "guests" | "emails" | "circles" | "bridged" | "bots" | "federated_users" | "phones"; Ban: { /** Format: int64 */ id: number; @@ -1548,17 +1550,23 @@ export type components = { PollDraft: { actorDisplayName: string; actorId: string; - actorType: string; + actorType: components["schemas"]["ActorTypes"]; /** Format: int64 */ id: number; /** Format: int64 */ maxVotes: number; options: string[]; question: string; - /** Format: int64 */ - resultMode: number; - /** Format: int64 */ - status: number; + /** + * Format: int64 + * @enum {integer} + */ + resultMode: 0 | 1; + /** + * Format: int64 + * @enum {integer} + */ + status: 0 | 1 | 2; }; PollVote: { actorDisplayName: string; From 41b4cd5b3a8fe82a26b988e5da4c38f138abe967 Mon Sep 17 00:00:00 2001 From: Joas Schilling Date: Fri, 11 Oct 2024 14:02:30 +0200 Subject: [PATCH 5/5] feat(polls): Define returned errors Signed-off-by: Joas Schilling --- lib/Controller/PollController.php | 11 ++++--- lib/Exceptions/PollPropertyException.php | 32 +++++++++++++++++++ .../TalkV1/Controller/PollController.php | 6 ++-- lib/Service/PollService.php | 20 +++++++----- openapi-full.json | 18 ++++++++++- openapi.json | 18 ++++++++++- src/types/openapi/openapi-full.ts | 5 ++- src/types/openapi/openapi.ts | 5 ++- 8 files changed, 96 insertions(+), 19 deletions(-) create mode 100644 lib/Exceptions/PollPropertyException.php diff --git a/lib/Controller/PollController.php b/lib/Controller/PollController.php index 80aaa280368..0b4722d47c6 100644 --- a/lib/Controller/PollController.php +++ b/lib/Controller/PollController.php @@ -11,6 +11,7 @@ use JsonException; use OCA\Talk\Chat\ChatManager; +use OCA\Talk\Exceptions\PollPropertyException; use OCA\Talk\Exceptions\WrongPermissionsException; use OCA\Talk\Middleware\Attribute\FederationSupported; use OCA\Talk\Middleware\Attribute\RequireModeratorOrNoLobby; @@ -61,7 +62,7 @@ public function __construct( * @psalm-param Poll::MODE_* $resultMode Mode how the results will be shown * @param int $maxVotes Number of maximum votes per voter * @param bool $draft Whether the poll should be saved as a draft (only allowed for moderators and with `talk-polls-drafts` capability) - * @return DataResponse|DataResponse|DataResponse, array{}> + * @return DataResponse|DataResponse|DataResponse * * 200: Draft created successfully * 201: Poll created successfully @@ -82,11 +83,11 @@ public function createPoll(string $question, array $options, int $resultMode, in if ($this->room->getType() !== Room::TYPE_GROUP && $this->room->getType() !== Room::TYPE_PUBLIC) { - return new DataResponse([], Http::STATUS_BAD_REQUEST); + return new DataResponse(['error' => PollPropertyException::REASON_ROOM], Http::STATUS_BAD_REQUEST); } if ($draft === true && !$this->participant->hasModeratorPermissions()) { - return new DataResponse([], Http::STATUS_BAD_REQUEST); + return new DataResponse(['error' => PollPropertyException::REASON_DRAFT], Http::STATUS_BAD_REQUEST); } $attendee = $this->participant->getAttendee(); @@ -102,9 +103,9 @@ public function createPoll(string $question, array $options, int $resultMode, in $maxVotes, $draft, ); - } catch (\Exception $e) { + } catch (PollPropertyException $e) { $this->logger->error('Error creating poll', ['exception' => $e]); - return new DataResponse([], Http::STATUS_BAD_REQUEST); + return new DataResponse(['error' => $e->getReason()], Http::STATUS_BAD_REQUEST); } if ($draft) { diff --git a/lib/Exceptions/PollPropertyException.php b/lib/Exceptions/PollPropertyException.php new file mode 100644 index 00000000000..56200dfe2e1 --- /dev/null +++ b/lib/Exceptions/PollPropertyException.php @@ -0,0 +1,32 @@ +reason; + } +} diff --git a/lib/Federation/Proxy/TalkV1/Controller/PollController.php b/lib/Federation/Proxy/TalkV1/Controller/PollController.php index bc70c4f60a3..658c87f548c 100644 --- a/lib/Federation/Proxy/TalkV1/Controller/PollController.php +++ b/lib/Federation/Proxy/TalkV1/Controller/PollController.php @@ -126,9 +126,10 @@ public function votePoll(Room $room, Participant $participant, int $pollId, arra /** - * @return DataResponse|DataResponse|DataResponse, array{}> + * @return DataResponse|DataResponse|DataResponse * @throws CannotReachRemoteException * + * 200: Draft created successfully * 201: Poll created successfully * 400: Creating poll is not possible * @@ -150,7 +151,8 @@ public function createPoll(Room $room, Participant $participant, string $questio $status = $proxy->getStatusCode(); if ($status === Http::STATUS_BAD_REQUEST) { - return new DataResponse([], Http::STATUS_BAD_REQUEST); + $data = $this->proxy->getOCSData($proxy, [Http::STATUS_BAD_REQUEST]); + return new DataResponse($data, Http::STATUS_BAD_REQUEST); } /** @var TalkPoll $data */ diff --git a/lib/Service/PollService.php b/lib/Service/PollService.php index a3fd7d6d595..8f226bf3606 100644 --- a/lib/Service/PollService.php +++ b/lib/Service/PollService.php @@ -8,6 +8,7 @@ namespace OCA\Talk\Service; +use OCA\Talk\Exceptions\PollPropertyException; use OCA\Talk\Exceptions\WrongPermissionsException; use OCA\Talk\Model\Poll; use OCA\Talk\Model\PollMapper; @@ -29,23 +30,26 @@ public function __construct( ) { } + /** + * @throws PollPropertyException + */ public function createPoll(int $roomId, string $actorType, string $actorId, string $displayName, string $question, array $options, int $resultMode, int $maxVotes, bool $draft): Poll { $question = trim($question); if ($question === '' || strlen($question) > 32_000) { - throw new \UnexpectedValueException(); + throw new PollPropertyException(PollPropertyException::REASON_QUESTION); } try { json_encode($options, JSON_THROW_ON_ERROR, 1); - } catch (\Exception $e) { - throw new \RuntimeException(); + } catch (\Exception) { + throw new PollPropertyException(PollPropertyException::REASON_OPTIONS); } $validOptions = []; foreach ($options as $option) { if (!is_string($option)) { - throw new \RuntimeException(); + throw new PollPropertyException(PollPropertyException::REASON_OPTIONS); } $option = trim($option); @@ -55,17 +59,17 @@ public function createPoll(int $roomId, string $actorType, string $actorId, stri } if (count($validOptions) < 2) { - throw new \RuntimeException(); + throw new PollPropertyException(PollPropertyException::REASON_OPTIONS); } try { $jsonOptions = json_encode($validOptions, JSON_THROW_ON_ERROR, 1); - } catch (\Exception $e) { - throw new \RuntimeException(); + } catch (\Exception) { + throw new PollPropertyException(PollPropertyException::REASON_OPTIONS); } if (strlen($jsonOptions) > 60_000) { - throw new \UnexpectedValueException(); + throw new PollPropertyException(PollPropertyException::REASON_OPTIONS); } $poll = new Poll(); diff --git a/openapi-full.json b/openapi-full.json index 788965c366b..833e386cc43 100644 --- a/openapi-full.json +++ b/openapi-full.json @@ -8894,7 +8894,23 @@ "meta": { "$ref": "#/components/schemas/OCSMeta" }, - "data": {} + "data": { + "type": "object", + "required": [ + "error" + ], + "properties": { + "error": { + "type": "string", + "enum": [ + "draft", + "options", + "question", + "room" + ] + } + } + } } } } diff --git a/openapi.json b/openapi.json index 24e669cc911..7ed277ef427 100644 --- a/openapi.json +++ b/openapi.json @@ -8781,7 +8781,23 @@ "meta": { "$ref": "#/components/schemas/OCSMeta" }, - "data": {} + "data": { + "type": "object", + "required": [ + "error" + ], + "properties": { + "error": { + "type": "string", + "enum": [ + "draft", + "options", + "question", + "room" + ] + } + } + } } } } diff --git a/src/types/openapi/openapi-full.ts b/src/types/openapi/openapi-full.ts index 9072b8dbfa2..e5adee94e4e 100644 --- a/src/types/openapi/openapi-full.ts +++ b/src/types/openapi/openapi-full.ts @@ -5190,7 +5190,10 @@ export interface operations { "application/json": { ocs: { meta: components["schemas"]["OCSMeta"]; - data: unknown; + data: { + /** @enum {string} */ + error: "draft" | "options" | "question" | "room"; + }; }; }; }; diff --git a/src/types/openapi/openapi.ts b/src/types/openapi/openapi.ts index 283d8013dc9..e67bfe9f28a 100644 --- a/src/types/openapi/openapi.ts +++ b/src/types/openapi/openapi.ts @@ -4671,7 +4671,10 @@ export interface operations { "application/json": { ocs: { meta: components["schemas"]["OCSMeta"]; - data: unknown; + data: { + /** @enum {string} */ + error: "draft" | "options" | "question" | "room"; + }; }; }; };