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..0b4722d47c6 100644 --- a/lib/Controller/PollController.php +++ b/lib/Controller/PollController.php @@ -11,9 +11,11 @@ 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; +use OCA\Talk\Middleware\Attribute\RequireModeratorParticipant; use OCA\Talk\Middleware\Attribute\RequireParticipant; use OCA\Talk\Middleware\Attribute\RequirePermission; use OCA\Talk\Middleware\Attribute\RequireReadWriteConversation; @@ -34,6 +36,7 @@ /** * @psalm-import-type TalkPoll from ResponseDefinitions + * @psalm-import-type TalkPollDraft from ResponseDefinitions */ class PollController extends AEnvironmentAwareController { @@ -58,8 +61,10 @@ 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 - * @return DataResponse|DataResponse, array{}> + * @param bool $draft Whether the poll should be saved as a draft (only allowed for moderators and with `talk-polls-drafts` capability) + * @return DataResponse|DataResponse|DataResponse * + * 200: Draft created successfully * 201: Poll created successfully * 400: Creating poll is not possible */ @@ -69,16 +74,20 @@ 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 && $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(['error' => PollPropertyException::REASON_DRAFT], Http::STATUS_BAD_REQUEST); } $attendee = $this->participant->getAttendee(); @@ -91,11 +100,16 @@ public function createPoll(string $question, array $options, int $resultMode, in $question, $options, $resultMode, - $maxVotes + $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) { + return new DataResponse($poll->renderAsDraft()); } $message = json_encode([ @@ -117,7 +131,37 @@ public function createPoll(string $question, array $options, int $resultMode, in $this->logger->error($e->getMessage(), ['exception' => $e]); } - return new DataResponse($this->renderPoll($poll, []), Http::STATUS_CREATED); + return new DataResponse($this->renderPoll($poll), Http::STATUS_CREATED); + } + + /** + * Get all drafted polls + * + * Required capability: `talk-polls-drafts` + * + * @return DataResponse, array{}>|DataResponse, array{}> + * + * 200: Poll returned + * 403: User is not a moderator + * 404: Poll not found + */ + #[FederationSupported] + #[PublicPage] + #[RequireModeratorParticipant] + public function getAllDraftPolls(): DataResponse { + if ($this->room->isFederatedConversation()) { + /** @var \OCA\Talk\Federation\Proxy\TalkV1\Controller\PollController $proxy */ + $proxy = \OCP\Server::get(\OCA\Talk\Federation\Proxy\TalkV1\Controller\PollController::class); + return $proxy->getDraftsForRoom($this->room, $this->participant); + } + + $polls = $this->pollService->getDraftsForRoom($this->room->getId()); + $data = []; + foreach ($polls as $poll) { + $data[] = $poll->renderAsDraft(); + } + + return new DataResponse($data); } /** @@ -143,7 +187,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 +229,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 +274,10 @@ public function votePoll(int $pollId, array $optionIds = []): DataResponse { * * @param int $pollId ID of the poll * @psalm-param non-negative-int $pollId - * @return DataResponse|DataResponse, array{}> + * @return DataResponse|DataResponse, array{}> * * 200: Poll closed successfully + * 202: Poll draft was deleted successfully * 400: Poll already closed * 403: Missing permissions to close poll * 404: Poll not found @@ -242,10 +295,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); } @@ -293,7 +351,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/Exceptions/PollPropertyException.php b/lib/Exceptions/PollPropertyException.php new file mode 100644 index 00000000000..56200dfe2e1 --- /dev/null +++ b/lib/Exceptions/PollPropertyException.php @@ -0,0 +1,32 @@ +reason; + } +} diff --git a/lib/Federation/Proxy/TalkV1/Controller/PollController.php b/lib/Federation/Proxy/TalkV1/Controller/PollController.php index 5eeec4189d4..658c87f548c 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( @@ -28,6 +29,38 @@ public function __construct( ) { } + /** + * @return DataResponse, array{}>|DataResponse, array{}> + * @throws CannotReachRemoteException + * + * 200: Polls returned + * 404: Polls not found + * + * @see \OCA\Talk\Controller\PollController::showPoll() + */ + public function getDraftsForRoom(Room $room, Participant $participant): DataResponse { + $proxy = $this->proxy->get( + $participant->getAttendee()->getInvitedCloudId(), + $participant->getAttendee()->getAccessToken(), + $room->getRemoteServer() . '/ocs/v2.php/apps/spreed/api/v1/poll/' . $room->getRemoteToken() . '/drafts', + ); + + $status = $proxy->getStatusCode(); + if ($status === Http::STATUS_NOT_FOUND || $status === Http::STATUS_FORBIDDEN) { + return new DataResponse([], $status); + } + + /** @var list $list */ + $list = $this->proxy->getOCSData($proxy); + + $data = []; + foreach ($list as $poll) { + $data[] = $this->userConverter->convertPoll($room, $poll); + } + + return new DataResponse($data); + } + /** * @return DataResponse|DataResponse, array{}> * @throws CannotReachRemoteException @@ -93,15 +126,16 @@ public function votePoll(Room $room, Participant $participant, int $pollId, arra /** - * @return DataResponse|DataResponse, array{}> + * @return DataResponse|DataResponse|DataResponse * @throws CannotReachRemoteException * + * 200: Draft created successfully * 201: Poll created successfully * 400: Creating poll is not possible * * @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,17 +145,23 @@ public function createPoll(Room $room, Participant $participant, string $questio 'options' => $options, 'resultMode' => $resultMode, 'maxVotes' => $maxVotes, + 'draft' => $draft, ], ); - if ($proxy->getStatusCode() === Http::STATUS_BAD_REQUEST) { - return new DataResponse([], Http::STATUS_BAD_REQUEST); + $status = $proxy->getStatusCode(); + if ($status === Http::STATUS_BAD_REQUEST) { + $data = $this->proxy->getOCSData($proxy, [Http::STATUS_BAD_REQUEST]); + return new DataResponse($data, 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/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 9682dea6c72..d53c0b05d45 100644 --- a/lib/Model/Poll.php +++ b/lib/Model/Poll.php @@ -13,34 +13,46 @@ 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 */ 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; @@ -74,25 +86,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 91f1b0cdacf..ee59e9937a3 100644 --- a/lib/Model/PollMapper.php +++ b/lib/Model/PollMapper.php @@ -27,6 +27,21 @@ 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))) + ->orderBy('id', 'ASC'); + + return $this->findEntities($query); + } + /** * @param int $pollId * @return Poll diff --git a/lib/ResponseDefinitions.php b/lib/ResponseDefinitions.php index 7f55a770b35..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, @@ -197,18 +199,21 @@ * optionId: int, * } * - * @psalm-type TalkPoll = array{ + * @psalm-type TalkPollDraft = array{ * actorDisplayName: string, - * actorId: string, - * actorType: string, + * actorId: non-empty-string, + * actorType: TalkActorTypes, + * id: int<1, max>, + * maxVotes: int<0, max>, + * options: list, + * question: non-empty-string, + * resultMode: 0|1, + * status: 0|1|2, + * } + * + * @psalm-type TalkPoll = TalkPollDraft&array{ * details?: TalkPollVote[], - * id: int, - * maxVotes: int, - * numVoters?: int, - * options: string[], - * question: string, - * resultMode: int, - * status: int, + * numVoters?: int<0, max>, * votedSelf?: int[], * votes?: array, * } diff --git a/lib/Service/PollService.php b/lib/Service/PollService.php index 217c11a57c8..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( ) { } - public function createPoll(int $roomId, string $actorType, string $actorId, string $displayName, string $question, array $options, int $resultMode, int $maxVotes): Poll { + /** + * @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(); @@ -78,12 +82,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..3a17270c787 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": [ @@ -842,6 +856,43 @@ } }, "Poll": { + "allOf": [ + { + "$ref": "#/components/schemas/PollDraft" + }, + { + "type": "object", + "properties": { + "details": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PollVote" + } + }, + "numVoters": { + "type": "integer", + "format": "int64", + "minimum": 0 + }, + "votedSelf": { + "type": "array", + "items": { + "type": "integer", + "format": "int64" + } + }, + "votes": { + "type": "object", + "additionalProperties": { + "type": "integer", + "format": "int64" + } + } + } + } + ] + }, + "PollDraft": { "type": "object", "required": [ "actorDisplayName", @@ -859,28 +910,21 @@ "type": "string" }, "actorId": { - "type": "string" + "type": "string", + "minLength": 1 }, "actorType": { - "type": "string" - }, - "details": { - "type": "array", - "items": { - "$ref": "#/components/schemas/PollVote" - } + "$ref": "#/components/schemas/ActorTypes" }, "id": { "type": "integer", - "format": "int64" + "format": "int64", + "minimum": 1 }, "maxVotes": { "type": "integer", - "format": "int64" - }, - "numVoters": { - "type": "integer", - "format": "int64" + "format": "int64", + "minimum": 0 }, "options": { "type": "array", @@ -889,29 +933,25 @@ } }, "question": { - "type": "string" + "type": "string", + "minLength": 1 }, "resultMode": { "type": "integer", - "format": "int64" + "format": "int64", + "enum": [ + 0, + 1 + ] }, "status": { "type": "integer", - "format": "int64" - }, - "votedSelf": { - "type": "array", - "items": { - "type": "integer", - "format": "int64" - } - }, - "votes": { - "type": "object", - "additionalProperties": { - "type": "integer", - "format": "int64" - } + "format": "int64", + "enum": [ + 0, + 1, + 2 + ] } } }, @@ -8724,6 +8764,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)" } } } @@ -8764,6 +8809,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": { @@ -8796,6 +8871,165 @@ }, "400": { "description": "Creating poll is not possible", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "object", + "required": [ + "error" + ], + "properties": { + "error": { + "type": "string", + "enum": [ + "draft", + "options", + "question", + "room" + ] + } + } + } + } + } + } + } + } + } + } + } + } + }, + "/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/PollDraft" + } + } + } + } + } + } + } + } + }, + "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": { @@ -9205,6 +9439,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..11b00cbcb3e 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": [ @@ -729,6 +743,43 @@ } }, "Poll": { + "allOf": [ + { + "$ref": "#/components/schemas/PollDraft" + }, + { + "type": "object", + "properties": { + "details": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PollVote" + } + }, + "numVoters": { + "type": "integer", + "format": "int64", + "minimum": 0 + }, + "votedSelf": { + "type": "array", + "items": { + "type": "integer", + "format": "int64" + } + }, + "votes": { + "type": "object", + "additionalProperties": { + "type": "integer", + "format": "int64" + } + } + } + } + ] + }, + "PollDraft": { "type": "object", "required": [ "actorDisplayName", @@ -746,28 +797,21 @@ "type": "string" }, "actorId": { - "type": "string" + "type": "string", + "minLength": 1 }, "actorType": { - "type": "string" - }, - "details": { - "type": "array", - "items": { - "$ref": "#/components/schemas/PollVote" - } + "$ref": "#/components/schemas/ActorTypes" }, "id": { "type": "integer", - "format": "int64" + "format": "int64", + "minimum": 1 }, "maxVotes": { "type": "integer", - "format": "int64" - }, - "numVoters": { - "type": "integer", - "format": "int64" + "format": "int64", + "minimum": 0 }, "options": { "type": "array", @@ -776,29 +820,25 @@ } }, "question": { - "type": "string" + "type": "string", + "minLength": 1 }, "resultMode": { "type": "integer", - "format": "int64" + "format": "int64", + "enum": [ + 0, + 1 + ] }, "status": { "type": "integer", - "format": "int64" - }, - "votedSelf": { - "type": "array", - "items": { - "type": "integer", - "format": "int64" - } - }, - "votes": { - "type": "object", - "additionalProperties": { - "type": "integer", - "format": "int64" - } + "format": "int64", + "enum": [ + 0, + 1, + 2 + ] } } }, @@ -8611,6 +8651,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)" } } } @@ -8651,6 +8696,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": { @@ -8683,6 +8758,165 @@ }, "400": { "description": "Creating poll is not possible", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "object", + "required": [ + "error" + ], + "properties": { + "error": { + "type": "string", + "enum": [ + "draft", + "options", + "question", + "room" + ] + } + } + } + } + } + } + } + } + } + } + } + } + }, + "/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/PollDraft" + } + } + } + } + } + } + } + } + }, + "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": { @@ -9092,6 +9326,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..b9a3b9df1af 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; @@ -1805,6 +1825,8 @@ export type paths = { export type webhooks = Record; export type components = { schemas: { + /** @enum {string} */ + ActorTypes: "users" | "groups" | "guests" | "emails" | "circles" | "bridged" | "bots" | "federated_users" | "phones"; Ban: { /** Format: int64 */ id: number; @@ -2033,27 +2055,35 @@ 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"][]; + actorType: components["schemas"]["ActorTypes"]; /** 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; - }; + /** + * Format: int64 + * @enum {integer} + */ + resultMode: 0 | 1; + /** + * Format: int64 + * @enum {integer} + */ + status: 0 | 1 | 2; }; PollVote: { actorDisplayName: string; @@ -5112,10 +5142,29 @@ 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; }; }; }; 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: { @@ -5132,6 +5181,68 @@ export interface operations { }; /** @description Creating poll is not possible */ 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + ocs: { + meta: components["schemas"]["OCSMeta"]; + data: { + /** @enum {string} */ + error: "draft" | "options" | "question" | "room"; + }; + }; + }; + }; + }; + }; + }; + "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"]["PollDraft"][]; + }; + }; + }; + }; + /** @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; }; @@ -5295,6 +5406,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..a369c986d59 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; @@ -1316,6 +1336,8 @@ export type paths = { export type webhooks = Record; export type components = { schemas: { + /** @enum {string} */ + ActorTypes: "users" | "groups" | "guests" | "emails" | "circles" | "bridged" | "bots" | "federated_users" | "phones"; Ban: { /** Format: int64 */ id: number; @@ -1514,27 +1536,35 @@ 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"][]; + actorType: components["schemas"]["ActorTypes"]; /** 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; - }; + /** + * Format: int64 + * @enum {integer} + */ + resultMode: 0 | 1; + /** + * Format: int64 + * @enum {integer} + */ + status: 0 | 1 | 2; }; PollVote: { actorDisplayName: string; @@ -4593,10 +4623,29 @@ 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; }; }; }; 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: { @@ -4613,6 +4662,68 @@ export interface operations { }; /** @description Creating poll is not possible */ 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + ocs: { + meta: components["schemas"]["OCSMeta"]; + data: { + /** @enum {string} */ + error: "draft" | "options" | "question" | "room"; + }; + }; + }; + }; + }; + }; + }; + "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"]["PollDraft"][]; + }; + }; + }; + }; + /** @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; }; @@ -4776,6 +4887,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..9c3b52093c3 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( @@ -2415,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; } @@ -2425,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)\))?$/ * @@ -2528,6 +2572,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..ab274d0193d 100644 --- a/tests/integration/features/chat-3/poll.feature +++ b/tests/integration/features/chat-3/poll.feature @@ -805,3 +805,61 @@ 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: 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 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 200 + | 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 | ["You","me"] | + | 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"}} |