From 7adfeb221874820c3614175d2820e090776b55a7 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 b7a26f7fe16..48497ffbb30 100644 --- a/docs/capabilities.md +++ b/docs/capabilities.md @@ -158,4 +158,5 @@ ## 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 diff --git a/lib/Capabilities.php b/lib/Capabilities.php index 24fe017da7d..bc7b5491575 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 e01a2b76787..8e97228ca48 100644 --- a/openapi-full.json +++ b/openapi-full.json @@ -8724,6 +8724,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)" } } } @@ -8825,6 +8830,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", @@ -9205,6 +9353,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 9408d9cdf55..0eccc2366e6 100644 --- a/openapi.json +++ b/openapi.json @@ -8611,6 +8611,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)" } } } @@ -8712,6 +8717,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", @@ -9092,6 +9240,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 c0ec4e62e1a..ce5391bcf7f 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; @@ -5112,6 +5132,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; }; }; }; @@ -5146,6 +5171,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; @@ -5295,6 +5379,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 1f1669b1314..849a61470cf 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; @@ -4593,6 +4613,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; }; }; }; @@ -4627,6 +4652,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; @@ -4776,6 +4860,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 0b2dff7a1f3..1d339adb2dd 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 e27449d302020b0980dab83bc9809f1f2cc15a59 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 7f55a770b35..d8f2332ec80 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 8e97228ca48..58b7241c95e 100644 --- a/openapi-full.json +++ b/openapi-full.json @@ -842,6 +842,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", @@ -864,12 +900,6 @@ "actorType": { "type": "string" }, - "details": { - "type": "array", - "items": { - "$ref": "#/components/schemas/PollVote" - } - }, "id": { "type": "integer", "format": "int64" @@ -878,10 +908,6 @@ "type": "integer", "format": "int64" }, - "numVoters": { - "type": "integer", - "format": "int64" - }, "options": { "type": "array", "items": { @@ -898,20 +924,6 @@ "status": { "type": "integer", "format": "int64" - }, - "votedSelf": { - "type": "array", - "items": { - "type": "integer", - "format": "int64" - } - }, - "votes": { - "type": "object", - "additionalProperties": { - "type": "integer", - "format": "int64" - } } } }, @@ -8904,7 +8916,7 @@ "data": { "type": "array", "items": { - "$ref": "#/components/schemas/Poll" + "$ref": "#/components/schemas/PollDraft" } } } diff --git a/openapi.json b/openapi.json index 0eccc2366e6..32f7c187d43 100644 --- a/openapi.json +++ b/openapi.json @@ -729,6 +729,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", @@ -751,12 +787,6 @@ "actorType": { "type": "string" }, - "details": { - "type": "array", - "items": { - "$ref": "#/components/schemas/PollVote" - } - }, "id": { "type": "integer", "format": "int64" @@ -765,10 +795,6 @@ "type": "integer", "format": "int64" }, - "numVoters": { - "type": "integer", - "format": "int64" - }, "options": { "type": "array", "items": { @@ -785,20 +811,6 @@ "status": { "type": "integer", "format": "int64" - }, - "votedSelf": { - "type": "array", - "items": { - "type": "integer", - "format": "int64" - } - }, - "votes": { - "type": "object", - "additionalProperties": { - "type": "integer", - "format": "int64" - } } } }, @@ -8791,7 +8803,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 ce5391bcf7f..a0f6d50b38d 100644 --- a/src/types/openapi/openapi-full.ts +++ b/src/types/openapi/openapi-full.ts @@ -2053,27 +2053,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; @@ -5195,7 +5197,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 849a61470cf..f264f69fb87 100644 --- a/src/types/openapi/openapi.ts +++ b/src/types/openapi/openapi.ts @@ -1534,27 +1534,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; @@ -4676,7 +4678,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 1d339adb2dd..cea73a4a49a 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 02e6a68dbb8fa369ab85489259113ba1399e968e 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 58b7241c95e..9c81e336415 100644 --- a/openapi-full.json +++ b/openapi-full.json @@ -8781,6 +8781,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 32f7c187d43..549631a5be1 100644 --- a/openapi.json +++ b/openapi.json @@ -8668,6 +8668,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 a0f6d50b38d..5b93e400235 100644 --- a/src/types/openapi/openapi-full.ts +++ b/src/types/openapi/openapi-full.ts @@ -5143,6 +5143,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 f264f69fb87..9365b8d19cc 100644 --- a/src/types/openapi/openapi.ts +++ b/src/types/openapi/openapi.ts @@ -4624,6 +4624,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 cea73a4a49a..9c3b52093c3 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 3e37c32bdd6233bcd6ad8a95c8334639a56b45ca 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 d8f2332ec80..28f97185aeb 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 9c81e336415..78ce1d2c395 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": [ @@ -857,7 +871,8 @@ }, "numVoters": { "type": "integer", - "format": "int64" + "format": "int64", + "minimum": 0 }, "votedSelf": { "type": "array", @@ -895,18 +910,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", @@ -915,15 +933,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 549631a5be1..e61672fee0d 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": [ @@ -744,7 +758,8 @@ }, "numVoters": { "type": "integer", - "format": "int64" + "format": "int64", + "minimum": 0 }, "votedSelf": { "type": "array", @@ -782,18 +797,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", @@ -802,15 +820,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 5b93e400235..3469d50f988 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; @@ -2065,17 +2067,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 9365b8d19cc..40389a7f3b4 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; @@ -1546,17 +1548,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 7c427d2cbb4bdb87a5c29a34c707cf5d7a3e8f5f 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 78ce1d2c395..3a17270c787 100644 --- a/openapi-full.json +++ b/openapi-full.json @@ -8889,7 +8889,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 e61672fee0d..11b00cbcb3e 100644 --- a/openapi.json +++ b/openapi.json @@ -8776,7 +8776,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 3469d50f988..b9a3b9df1af 100644 --- a/src/types/openapi/openapi-full.ts +++ b/src/types/openapi/openapi-full.ts @@ -5188,7 +5188,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 40389a7f3b4..a369c986d59 100644 --- a/src/types/openapi/openapi.ts +++ b/src/types/openapi/openapi.ts @@ -4669,7 +4669,10 @@ export interface operations { "application/json": { ocs: { meta: components["schemas"]["OCSMeta"]; - data: unknown; + data: { + /** @enum {string} */ + error: "draft" | "options" | "question" | "room"; + }; }; }; };