diff --git a/docs/poll.md b/docs/poll.md index 7ea130a3adf..9f863d7e796 100644 --- a/docs/poll.md +++ b/docs/poll.md @@ -30,6 +30,34 @@ Base endpoint is: `/ocs/v2.php/apps/spreed/api/v1` See [Poll data](#poll-data) +# Edit a draft poll in a conversation + +* Federation capability: `federation-v1` +* Method: `POST` +* Endpoint: `/poll/{token}/draft/{pollId]}` +* Data: + +| field | type | Description | +|--------------|--------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `question` | string | The question of the poll | +| `options` | string[] | Array of strings with the voting options | +| `resultMode` | int | The result and voting mode of the poll, `0` means participants can immediatelly see the result and who voted for which option. `1` means the result is hidden until the poll is closed and then only the summary is published. | +| `maxVotes` | int | Maximum amount of options a participant can vote for | + +* Response: + - Status code: + + `201 Created` + + `400 Bad Request` When the room is a one-to-one conversation + + `400 Bad Request` When the question or the options were too long or invalid (not strings) + + `403 Forbidden` When the conversation is read-only + + `403 Forbidden` When the actor does not have chat permissions + + `404 Not Found` When the conversation could not be found for the participant + + `412 Precondition Failed` When the lobby is active and the user is not a moderator + + - Data: + + See [Poll data](#poll-data) + ## Get state or result of a poll * Federation capability: `federation-v1` diff --git a/lib/Controller/PollController.php b/lib/Controller/PollController.php index e8517b8122c..f5cec3b78a0 100644 --- a/lib/Controller/PollController.php +++ b/lib/Controller/PollController.php @@ -139,12 +139,14 @@ public function createPoll(string $question, array $options, int $resultMode, in * * Required capability: `edit-draft-poll` * - * @param int $pollId + * @param int $pollId The poll id * @param string $question Question of the poll * @param string[] $options Options of the poll - * @param int $resultMode + * @psalm-param list $options + * @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 - * @return DataResponse|DataResponse|DataResponse + * @return DataResponse|DataResponse|DataResponse * * 200: Draft modified successfully * 400: Modifying poll is not possible @@ -158,7 +160,6 @@ public function createPoll(string $question, array $options, int $resultMode, in #[RequireReadWriteConversation] public function updateDraftPoll(int $pollId, string $question, array $options, int $resultMode, int $maxVotes): DataResponse { if ($this->room->isFederatedConversation()) { - // TODO: add editing a draft to federation /** @var \OCA\Talk\Federation\Proxy\TalkV1\Controller\PollController $proxy */ $proxy = \OCP\Server::get(\OCA\Talk\Federation\Proxy\TalkV1\Controller\PollController::class); return $proxy->updateDraftPoll($pollId, $this->room, $this->participant, $question, $options, $resultMode, $maxVotes, $draft); @@ -169,10 +170,6 @@ public function updateDraftPoll(int $pollId, string $question, array $options, i return new DataResponse(['error' => PollPropertyException::REASON_ROOM], Http::STATUS_BAD_REQUEST); } - if (!$this->participant->hasModeratorPermissions()) { - return new DataResponse(['error' => PollPropertyException::REASON_DRAFT], Http::STATUS_BAD_REQUEST); - } - try { $poll = $this->pollService->getPoll($this->room->getId(), $pollId); } catch (DoesNotExistException $e) { @@ -183,8 +180,21 @@ public function updateDraftPoll(int $pollId, string $question, array $options, i return new DataResponse(['error' => PollPropertyException::REASON_POLL], Http::STATUS_BAD_REQUEST); } + if (!$this->participant->hasModeratorPermissions() + && ($poll->getActorType() !== $this->participant->getAttendee()->getActorType() + || $poll->getActorId() !== $this->participant->getAttendee()->getActorId())) { + return new DataResponse(['error' => PollPropertyException::REASON_DRAFT], Http::STATUS_BAD_REQUEST); + } + + try { + $question = $this->pollService->validatePollQuestion($question); + $encodedOptions = $this->pollService->validatePollOptions($options); + } catch (PollPropertyException $e) { + $this->logger->error('Error modifying poll', ['exception' => $e]); + return new DataResponse(['error' => $e->getReason()], Http::STATUS_BAD_REQUEST); + } + $poll->setQuestion($question); - $encodedOptions = json_encode($options, JSON_THROW_ON_ERROR, 1); $poll->setOptions($encodedOptions); $poll->setResultMode($resultMode); $poll->setMaxVotes($maxVotes); @@ -373,10 +383,8 @@ public function closePoll(int $pollId): DataResponse { return new DataResponse([], Http::STATUS_BAD_REQUEST); } - $poll->setStatus(Poll::STATUS_CLOSED); - try { - $this->pollService->updatePoll($this->participant, $poll); + $this->pollService->closePoll($this->participant, $poll); } catch (WrongPermissionsException $e) { return new DataResponse([], Http::STATUS_FORBIDDEN); } catch (Exception $e) { diff --git a/lib/Service/PollService.php b/lib/Service/PollService.php index 1e9984459b9..73f497a1ed3 100644 --- a/lib/Service/PollService.php +++ b/lib/Service/PollService.php @@ -34,43 +34,8 @@ 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 PollPropertyException(PollPropertyException::REASON_QUESTION); - } - - try { - json_encode($options, JSON_THROW_ON_ERROR, 1); - } catch (\Exception) { - throw new PollPropertyException(PollPropertyException::REASON_OPTIONS); - } - - $validOptions = []; - foreach ($options as $option) { - if (!is_string($option)) { - throw new PollPropertyException(PollPropertyException::REASON_OPTIONS); - } - - $option = trim($option); - if ($option !== '') { - $validOptions[] = $option; - } - } - - if (count($validOptions) < 2) { - throw new PollPropertyException(PollPropertyException::REASON_OPTIONS); - } - - try { - $jsonOptions = json_encode($validOptions, JSON_THROW_ON_ERROR, 1); - } catch (\Exception) { - throw new PollPropertyException(PollPropertyException::REASON_OPTIONS); - } - - if (strlen($jsonOptions) > 60_000) { - throw new PollPropertyException(PollPropertyException::REASON_OPTIONS); - } + $question = $this->validatePollQuestion($question); + $jsonOptions = $this->validatePollOptions($options); $poll = new Poll(); $poll->setRoomId($roomId); @@ -112,17 +77,76 @@ public function getPoll(int $roomId, int $pollId): Poll { /** * @param Participant $participant * @param Poll $poll - * @throws WrongPermissionsException * @throws Exception + * @throws WrongPermissionsException */ public function updatePoll(Participant $participant, Poll $poll): void { if (!$participant->hasModeratorPermissions() - && ($poll->getActorType() !== $participant->getAttendee()->getActorType() - || $poll->getActorId() !== $participant->getAttendee()->getActorId())) { + && ($poll->getActorType() !== $participant->getAttendee()->getActorType() + || $poll->getActorId() !== $participant->getAttendee()->getActorId())) { // Only moderators and the author of the poll can update it throw new WrongPermissionsException(); } + $this->pollMapper->update($poll); + } + /** + * @param array $options + * @return string + * + * @since 21.0.0 + */ + public function validatePollOptions(array $options): string { + try { + json_encode($options, JSON_THROW_ON_ERROR, 1); + } catch (\Exception) { + throw new PollPropertyException(PollPropertyException::REASON_OPTIONS); + } + + $validOptions = []; + foreach ($options as $option) { + if (!is_string($option)) { + throw new PollPropertyException(PollPropertyException::REASON_OPTIONS); + } + + $option = trim($option); + if ($option !== '') { + $validOptions[] = $option; + } + } + + if (count($validOptions) < 2) { + throw new PollPropertyException(PollPropertyException::REASON_OPTIONS); + } + + try { + $jsonOptions = json_encode($validOptions, JSON_THROW_ON_ERROR, 1); + } catch (\Exception) { + throw new PollPropertyException(PollPropertyException::REASON_OPTIONS); + } + + if (strlen($jsonOptions) > 60_000) { + throw new PollPropertyException(PollPropertyException::REASON_OPTIONS); + } + + return $jsonOptions; + } + + /** + * @param Participant $participant + * @param Poll $poll + * @return void + * @throws WrongPermissionsException + */ + public function closePoll(Participant $participant, Poll $poll): void { + if (!$participant->hasModeratorPermissions() + && ($poll->getActorType() !== $participant->getAttendee()->getActorType() + || $poll->getActorId() !== $participant->getAttendee()->getActorId())) { + // Only moderators and the author of the poll can update it + throw new WrongPermissionsException(); + } + + $poll->setStatus(Poll::STATUS_CLOSED); $this->pollMapper->update($poll); } @@ -347,4 +371,16 @@ public function neutralizeDeletedUser(string $actorType, string $actorId): void ->andWhere($update->expr()->eq('actor_id', $update->createNamedParameter($actorId))); $update->executeStatement(); } + + /** + * @param string $question + * @return string + */ + public function validatePollQuestion(string $question): string { + $question = trim($question); + if ($question === '' || strlen($question) > 32_000) { + throw new PollPropertyException(PollPropertyException::REASON_QUESTION); + } + return $question; + } } diff --git a/openapi-full.json b/openapi-full.json index be8eeabb0ac..7c0d737bb06 100644 --- a/openapi-full.json +++ b/openapi-full.json @@ -9245,6 +9245,7 @@ "required": [ "question", "options", + "resultMode", "maxVotes" ], "properties": { @@ -9259,6 +9260,15 @@ "type": "string" } }, + "resultMode": { + "type": "integer", + "format": "int64", + "enum": [ + 0, + 1 + ], + "description": "Mode how the results will be shown" + }, "maxVotes": { "type": "integer", "format": "int64", @@ -9294,10 +9304,11 @@ { "name": "pollId", "in": "path", + "description": "The poll id", "required": true, "schema": { - "type": "string", - "pattern": "^\\d+$" + "type": "integer", + "format": "int64" } }, { diff --git a/openapi.json b/openapi.json index dd95a5280f9..3d7a91a403f 100644 --- a/openapi.json +++ b/openapi.json @@ -9132,6 +9132,7 @@ "required": [ "question", "options", + "resultMode", "maxVotes" ], "properties": { @@ -9146,6 +9147,15 @@ "type": "string" } }, + "resultMode": { + "type": "integer", + "format": "int64", + "enum": [ + 0, + 1 + ], + "description": "Mode how the results will be shown" + }, "maxVotes": { "type": "integer", "format": "int64", @@ -9181,10 +9191,11 @@ { "name": "pollId", "in": "path", + "description": "The poll id", "required": true, "schema": { - "type": "string", - "pattern": "^\\d+$" + "type": "integer", + "format": "int64" } }, { diff --git a/src/types/openapi/openapi-full.ts b/src/types/openapi/openapi-full.ts index b3a280c3d37..be557574acc 100644 --- a/src/types/openapi/openapi-full.ts +++ b/src/types/openapi/openapi-full.ts @@ -623,6 +623,26 @@ export type paths = { patch?: never; trace?: never; }; + "/ocs/v2.php/apps/spreed/api/{apiVersion}/poll/{token}/draft/{pollId}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Modify a draft poll + * @description Required capability: `edit-draft-poll` + */ + post: operations["poll-update-draft-poll"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/ocs/v2.php/apps/spreed/api/{apiVersion}/poll/{token}/drafts": { parameters: { query?: never; @@ -5355,6 +5375,92 @@ export interface operations { }; }; }; + "poll-update-draft-poll": { + parameters: { + query?: never; + header: { + /** @description Required to be true for the API request to pass */ + "OCS-APIRequest": boolean; + }; + path: { + apiVersion: "v1"; + token: string; + /** @description The poll id */ + pollId: number; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": { + /** @description Question of the poll */ + question: string; + /** @description Options of the poll */ + options: string[]; + /** + * Format: int64 + * @description Mode how the results will be shown + * @enum {integer} + */ + resultMode: 0 | 1; + /** + * Format: int64 + * @description Number of maximum votes per voter + */ + maxVotes: number; + }; + }; + }; + responses: { + /** @description Draft modified successfully */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + ocs: { + meta: components["schemas"]["OCSMeta"]; + data: components["schemas"]["PollDraft"]; + }; + }; + }; + }; + /** @description Modifying poll is not possible */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + ocs: { + meta: components["schemas"]["OCSMeta"]; + data: { + /** @enum {string} */ + error: "draft" | "options" | "question" | "room"; + }; + }; + }; + }; + }; + /** @description No draft poll exists */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + ocs: { + meta: components["schemas"]["OCSMeta"]; + data: { + error: string; + }; + }; + }; + }; + }; + }; + }; "poll-get-all-draft-polls": { parameters: { query?: never; diff --git a/src/types/openapi/openapi.ts b/src/types/openapi/openapi.ts index d43bc1046ff..d582c0f0c9e 100644 --- a/src/types/openapi/openapi.ts +++ b/src/types/openapi/openapi.ts @@ -623,6 +623,26 @@ export type paths = { patch?: never; trace?: never; }; + "/ocs/v2.php/apps/spreed/api/{apiVersion}/poll/{token}/draft/{pollId}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Modify a draft poll + * @description Required capability: `edit-draft-poll` + */ + post: operations["poll-update-draft-poll"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/ocs/v2.php/apps/spreed/api/{apiVersion}/poll/{token}/drafts": { parameters: { query?: never; @@ -4836,6 +4856,92 @@ export interface operations { }; }; }; + "poll-update-draft-poll": { + parameters: { + query?: never; + header: { + /** @description Required to be true for the API request to pass */ + "OCS-APIRequest": boolean; + }; + path: { + apiVersion: "v1"; + token: string; + /** @description The poll id */ + pollId: number; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": { + /** @description Question of the poll */ + question: string; + /** @description Options of the poll */ + options: string[]; + /** + * Format: int64 + * @description Mode how the results will be shown + * @enum {integer} + */ + resultMode: 0 | 1; + /** + * Format: int64 + * @description Number of maximum votes per voter + */ + maxVotes: number; + }; + }; + }; + responses: { + /** @description Draft modified successfully */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + ocs: { + meta: components["schemas"]["OCSMeta"]; + data: components["schemas"]["PollDraft"]; + }; + }; + }; + }; + /** @description Modifying poll is not possible */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + ocs: { + meta: components["schemas"]["OCSMeta"]; + data: { + /** @enum {string} */ + error: "draft" | "options" | "question" | "room"; + }; + }; + }; + }; + }; + /** @description No draft poll exists */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + ocs: { + meta: components["schemas"]["OCSMeta"]; + data: { + error: string; + }; + }; + }; + }; + }; + }; + }; "poll-get-all-draft-polls": { parameters: { query?: never;