From 7adfeb221874820c3614175d2820e090776b55a7 Mon Sep 17 00:00:00 2001
From: Joas Schilling <coding@schilljs.com>
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 <coding@schilljs.com>
---
 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<Http::STATUS_CREATED, TalkPoll, array{}>|DataResponse<Http::STATUS_BAD_REQUEST, array<empty>, 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<Http::STATUS_OK, list<TalkPoll>, array{}>|DataResponse<Http::STATUS_FORBIDDEN|Http::STATUS_NOT_FOUND, list<empty>, 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<Http::STATUS_OK, TalkPoll, array{}>|DataResponse<Http::STATUS_BAD_REQUEST|Http::STATUS_FORBIDDEN|Http::STATUS_NOT_FOUND|Http::STATUS_INTERNAL_SERVER_ERROR, array<empty>, array{}>
+	 * @return DataResponse<Http::STATUS_OK, TalkPoll, array{}>|DataResponse<Http::STATUS_ACCEPTED|Http::STATUS_BAD_REQUEST|Http::STATUS_FORBIDDEN|Http::STATUS_NOT_FOUND|Http::STATUS_INTERNAL_SERVER_ERROR, array<empty>, 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<Http::STATUS_OK, list<TalkPoll>, array{}>|DataResponse<Http::STATUS_FORBIDDEN|Http::STATUS_NOT_FOUND, list<empty>, 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<TalkPoll> $list */
+		$list = $this->proxy->getOCSData($proxy);
+
+		$data = [];
+		foreach ($list as $poll) {
+			$data[] = $this->userConverter->convertPoll($room, $poll);
+		}
+
+		return new DataResponse($data);
+	}
+
 	/**
 	 * @return DataResponse<Http::STATUS_OK, TalkPoll, array{}>|DataResponse<Http::STATUS_NOT_FOUND, array<empty>, 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 <coding@schilljs.com>
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 <coding@schilljs.com>
---
 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<Http::STATUS_OK, list<TalkPoll>, array{}>|DataResponse<Http::STATUS_FORBIDDEN|Http::STATUS_NOT_FOUND, list<empty>, array{}>
+	 * @return DataResponse<Http::STATUS_OK, list<TalkPollDraft>, array{}>|DataResponse<Http::STATUS_FORBIDDEN|Http::STATUS_NOT_FOUND, list<empty>, 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<Http::STATUS_OK, list<TalkPoll>, array{}>|DataResponse<Http::STATUS_FORBIDDEN|Http::STATUS_NOT_FOUND, list<empty>, array{}>
+	 * @return DataResponse<Http::STATUS_OK, list<TalkPollDraft>, array{}>|DataResponse<Http::STATUS_FORBIDDEN|Http::STATUS_NOT_FOUND, list<empty>, array{}>
 	 * @throws CannotReachRemoteException
 	 *
 	 * 200: Polls returned
@@ -49,7 +50,7 @@ public function getDraftsForRoom(Room $room, Participant $participant): DataResp
 			return new DataResponse([], $status);
 		}
 
-		/** @var list<TalkPoll> $list */
+		/** @var list<TalkPollDraft> $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<string, int>,
  * }
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 <coding@schilljs.com>
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 <coding@schilljs.com>
---
 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<Http::STATUS_CREATED, TalkPoll, array{}>|DataResponse<Http::STATUS_BAD_REQUEST, array<empty>, array{}>
+	 * @return DataResponse<Http::STATUS_OK, TalkPollDraft, array{}>|DataResponse<Http::STATUS_CREATED, TalkPoll, array{}>|DataResponse<Http::STATUS_BAD_REQUEST, array<empty>, 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<Http::STATUS_CREATED, TalkPoll, array{}>|DataResponse<Http::STATUS_BAD_REQUEST, array<empty>, array{}>
+	 * @return DataResponse<Http::STATUS_OK, TalkPollDraft, array{}>|DataResponse<Http::STATUS_CREATED, TalkPoll, array{}>|DataResponse<Http::STATUS_BAD_REQUEST, array<empty>, 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 <coding@schilljs.com>
Date: Fri, 11 Oct 2024 13:45:52 +0200
Subject: [PATCH 4/5] feat(polls): Stronger API typing

Signed-off-by: Joas Schilling <coding@schilljs.com>
---
 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<string>,
+ *     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<string, int>,
  * }
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<string, never>;
 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<string, never>;
 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 <coding@schilljs.com>
Date: Fri, 11 Oct 2024 14:02:30 +0200
Subject: [PATCH 5/5] feat(polls): Define returned errors

Signed-off-by: Joas Schilling <coding@schilljs.com>
---
 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<Http::STATUS_OK, TalkPollDraft, array{}>|DataResponse<Http::STATUS_CREATED, TalkPoll, array{}>|DataResponse<Http::STATUS_BAD_REQUEST, array<empty>, array{}>
+	 * @return DataResponse<Http::STATUS_OK, TalkPollDraft, array{}>|DataResponse<Http::STATUS_CREATED, TalkPoll, array{}>|DataResponse<Http::STATUS_BAD_REQUEST, array{error: 'draft'|'options'|'question'|'room'}, array{}>
 	 *
 	 * 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 @@
+<?php
+
+declare(strict_types=1);
+/**
+ * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+namespace OCA\Talk\Exceptions;
+
+class PollPropertyException extends \InvalidArgumentException {
+	public const REASON_DRAFT = 'draft';
+	public const REASON_QUESTION = 'question';
+	public const REASON_OPTIONS = 'options';
+	public const REASON_ROOM = 'room';
+
+	/**
+	 * @param self::REASON_* $reason
+	 */
+	public function __construct(
+		protected string $reason,
+	) {
+		parent::__construct($reason);
+	}
+
+	/**
+	 * @return self::REASON_*
+	 */
+	public function getReason(): string {
+		return $this->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<Http::STATUS_OK, TalkPollDraft, array{}>|DataResponse<Http::STATUS_CREATED, TalkPoll, array{}>|DataResponse<Http::STATUS_BAD_REQUEST, array<empty>, array{}>
+	 * @return DataResponse<Http::STATUS_OK, TalkPollDraft, array{}>|DataResponse<Http::STATUS_CREATED, TalkPoll, array{}>|DataResponse<Http::STATUS_BAD_REQUEST, array{error: 'draft'|'options'|'question'|'room'}, array{}>
 	 * @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";
+                            };
                         };
                     };
                 };