diff --git a/lib/Controller/PollController.php b/lib/Controller/PollController.php index 29f9d7c9b3a0..6efdc821d6fe 100644 --- a/lib/Controller/PollController.php +++ b/lib/Controller/PollController.php @@ -35,6 +35,7 @@ /** * @psalm-import-type TalkPoll from ResponseDefinitions + * @psalm-import-type TalkPollDraft from ResponseDefinitions */ class PollController extends AEnvironmentAwareController { @@ -134,7 +135,7 @@ public function createPoll(string $question, array $options, int $resultMode, in * * Required capability: `talk-polls-drafts` * - * @return DataResponse, array{}>|DataResponse, array{}> + * @return DataResponse, array{}>|DataResponse, array{}> * * 200: Poll returned * 403: User is not a moderator @@ -153,7 +154,7 @@ public function getAllDraftPolls(): DataResponse { $polls = $this->pollService->getDraftsForRoom($this->room->getId()); $data = []; foreach ($polls as $poll) { - $data[] = $this->renderPoll($poll); + $data[] = $poll->renderAsDraft(); } return new DataResponse($data); @@ -346,7 +347,7 @@ public function closePoll(int $pollId): DataResponse { * @throws JsonException */ protected function renderPoll(Poll $poll, array $votedSelf = [], array $detailedVotes = []): array { - $data = $poll->asArray(); + $data = $poll->renderAsPoll(); $canSeeSummary = !empty($votedSelf) && $poll->getResultMode() === Poll::MODE_PUBLIC; diff --git a/lib/Federation/Proxy/TalkV1/Controller/PollController.php b/lib/Federation/Proxy/TalkV1/Controller/PollController.php index 9ab604cb3d60..32eceb66953e 100644 --- a/lib/Federation/Proxy/TalkV1/Controller/PollController.php +++ b/lib/Federation/Proxy/TalkV1/Controller/PollController.php @@ -20,6 +20,7 @@ /** * @psalm-import-type TalkPoll from ResponseDefinitions + * @psalm-import-type TalkPollDraft from ResponseDefinitions */ class PollController { public function __construct( @@ -29,7 +30,7 @@ public function __construct( } /** - * @return DataResponse, array{}>|DataResponse, array{}> + * @return DataResponse, array{}>|DataResponse, array{}> * @throws CannotReachRemoteException * * 200: Polls returned @@ -49,7 +50,7 @@ public function getDraftsForRoom(Room $room, Participant $participant): DataResp return new DataResponse([], $status); } - /** @var list $list */ + /** @var list $list */ $list = $this->proxy->getOCSData($proxy); $data = []; diff --git a/lib/Federation/Proxy/TalkV1/UserConverter.php b/lib/Federation/Proxy/TalkV1/UserConverter.php index 5d5c6c9f480e..6e435d385917 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 63b123e9a25d..b8531aaa5370 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 deee8d32c110..ee59e9937a3c 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 7f55a770b355..d8f2332ec802 100644 --- a/lib/ResponseDefinitions.php +++ b/lib/ResponseDefinitions.php @@ -197,18 +197,21 @@ * optionId: int, * } * - * @psalm-type TalkPoll = array{ + * @psalm-type TalkPollDraft = array{ * actorDisplayName: string, * actorId: string, * actorType: string, - * details?: TalkPollVote[], * id: int, * maxVotes: int, - * numVoters?: int, * options: string[], * question: string, * resultMode: int, * status: int, + * } + * + * @psalm-type TalkPoll = TalkPollDraft&array{ + * details?: TalkPollVote[], + * numVoters?: int, * votedSelf?: int[], * votes?: array, * } diff --git a/openapi-full.json b/openapi-full.json index 8e97228ca486..58b7241c95e3 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 0eccc2366e66..32f7c187d43d 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 ce5391bcf7f0..a0f6d50b38d2 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 849a61470cfa..f264f69fb87b 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 1d339adb2dd5..cea73a4a49ad 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 ce9f0270629b..7406aefeab8c 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 |