Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(polls): allow editing of draft polls #13883

Open
wants to merge 5 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions appinfo/routes/routesPollController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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::updateDraftPoll() */
['name' => 'Poll#updateDraftPoll', 'url' => '/api/{apiVersion}/poll/{token}/draft/{pollId}', 'verb' => 'POST', 'requirements' => $requirementsWithPollId],
/** @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() */
Expand Down
1 change: 1 addition & 0 deletions docs/capabilities.md
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,7 @@
* `config => call => start-without-media` (local) - Boolean, whether media should be disabled when starting or joining a conversation
* `config => call => max-duration` - Integer, maximum call duration in seconds. Please note that this should only be used with system cron and with a reasonable high value, due to the expended duration until the background job ran.
* `config => call => blur-virtual-background` (local) - Boolean, whether blur background is set by default when joining a conversation
+ `edit-draft-poll` - Whether moderators can edit draft polls
miaulalala marked this conversation as resolved.
Show resolved Hide resolved

## 21
* `config => conversations => force-passwords` - Whether passwords are enforced for public rooms
Expand Down
25 changes: 25 additions & 0 deletions docs/poll.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,31 @@ Base endpoint is: `/ocs/v2.php/apps/spreed/api/v1`

See [Poll data](#poll-data)

# Edit a draft poll in a conversation

* Required capability: `edit-draft-poll`
* Method: `POST`
* Endpoint: `/poll/{token}/draft/{pollId}`
* Data:

| field | type | Description |
|--------------|--------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| `question` | string | The question of the poll |
| `options` | string[] | Array of strings with the voting options |
| `resultMode` | int | The result and voting mode of the poll, `0` means participants can immediatelly see the result and who voted for which option. `1` means the result is hidden until the poll is closed and then only the summary is published. |
| `maxVotes` | int | Maximum amount of options a participant can vote for |

* Response:
- Status code:
+ `200 OK`
+ `400 Bad Request` Modifying poll is not possible
+ `403 Forbidden` No permission to modify this poll
+ `404 Not Found` When the draft poll could not be found

- Data:

See [Poll data](#poll-data)

## Get state or result of a poll

* Federation capability: `federation-v1`
Expand Down
1 change: 1 addition & 0 deletions lib/Capabilities.php
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,7 @@ class Capabilities implements IPublicCapability {
'email-csv-import',
'conversation-creation-password',
'call-notification-state-api',
'edit-draft-poll',
];

public const CONDITIONAL_FEATURES = [
Expand Down
89 changes: 80 additions & 9 deletions lib/Controller/PollController.php
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,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{error: 'draft'|'options'|'question'|'room'}, array{}>
* @return DataResponse<Http::STATUS_OK, TalkPollDraft, array{}>|DataResponse<Http::STATUS_CREATED, TalkPoll, array{}>|DataResponse<Http::STATUS_BAD_REQUEST, array{error: 'draft'|'options'|'poll'|'question'|'room'}, array{}>
*
* 200: Draft created successfully
* 201: Poll created successfully
Expand Down Expand Up @@ -133,6 +133,79 @@ public function createPoll(string $question, array $options, int $resultMode, in
return new DataResponse($this->renderPoll($poll), Http::STATUS_CREATED);
}

/**
* Modify a draft poll
*
* Required capability: `edit-draft-poll`
*
* @param int $pollId The poll id
* @param string $question Question of the poll
* @param string[] $options Options of the poll
* @psalm-param list<string> $options
* @param 0|1 $resultMode Mode how the results will be shown
* @psalm-param Poll::MODE_* $resultMode Mode how the results will be shown
* @param int $maxVotes Number of maximum votes per voter
* @return DataResponse<Http::STATUS_OK, TalkPollDraft, array{}>|DataResponse<Http::STATUS_BAD_REQUEST|Http::STATUS_FORBIDDEN|Http::STATUS_NOT_FOUND, array{error: 'draft'|'options'|'poll'|'question'|'room'}, array{}>
*
* 200: Draft modified successfully
* 400: Modifying poll is not possible
* 403: No permission to modify this poll
* 404: No draft poll exists
*/
#[FederationSupported]
#[PublicPage]
#[RequireModeratorOrNoLobby]
#[RequireParticipant]
#[RequirePermission(permission: RequirePermission::CHAT)]
#[RequireReadWriteConversation]
public function updateDraftPoll(int $pollId, string $question, array $options, int $resultMode, int $maxVotes): 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->updateDraftPoll($pollId, $this->room, $this->participant, $question, $options, $resultMode, $maxVotes);
}

if ($this->room->getType() !== Room::TYPE_GROUP
&& $this->room->getType() !== Room::TYPE_PUBLIC) {
return new DataResponse(['error' => PollPropertyException::REASON_ROOM], Http::STATUS_BAD_REQUEST);
}

try {
$poll = $this->pollService->getPoll($this->room->getId(), $pollId);
} catch (DoesNotExistException $e) {
return new DataResponse(['error' => PollPropertyException::REASON_POLL], Http::STATUS_NOT_FOUND);
}

if (!$poll->isDraft()) {
return new DataResponse(['error' => PollPropertyException::REASON_POLL], Http::STATUS_BAD_REQUEST);
}

if (!$this->participant->hasModeratorPermissions()
&& ($poll->getActorType() !== $this->participant->getAttendee()->getActorType()
|| $poll->getActorId() !== $this->participant->getAttendee()->getActorId())) {
return new DataResponse(['error' => PollPropertyException::REASON_DRAFT], Http::STATUS_BAD_REQUEST);
}

try {
$poll->setQuestion($question);
$poll->setOptions($options);
$poll->setResultMode($resultMode);
$poll->setMaxVotes($maxVotes);
} catch (PollPropertyException $e) {
$this->logger->error('Error modifying poll', ['exception' => $e]);
return new DataResponse(['error' => $e->getReason()], Http::STATUS_BAD_REQUEST);
}

try {
$this->pollService->updatePoll($this->participant, $poll);
} catch (WrongPermissionsException $e) {
$this->logger->error('Error modifying poll', ['exception' => $e]);
return new DataResponse(['error' => PollPropertyException::REASON_POLL], Http::STATUS_FORBIDDEN);
}

return new DataResponse($poll->renderAsDraft());
}

/**
* Get all drafted polls
*
Expand Down Expand Up @@ -273,7 +346,7 @@ 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_ACCEPTED, null, array{}>|DataResponse<Http::STATUS_BAD_REQUEST|Http::STATUS_FORBIDDEN|Http::STATUS_NOT_FOUND, array{error: string}, array{}>
* @return DataResponse<Http::STATUS_OK, TalkPoll, array{}>|DataResponse<Http::STATUS_ACCEPTED, null, array{}>|DataResponse<Http::STATUS_BAD_REQUEST|Http::STATUS_FORBIDDEN|Http::STATUS_NOT_FOUND, array{error: 'poll'}, array{}>
*
* 200: Poll closed successfully
* 202: Poll draft was deleted successfully
Expand All @@ -295,7 +368,7 @@ public function closePoll(int $pollId): DataResponse {
try {
$poll = $this->pollService->getPoll($this->room->getId(), $pollId);
} catch (DoesNotExistException) {
return new DataResponse(['error' => 'poll'], Http::STATUS_NOT_FOUND);
return new DataResponse(['error' => PollPropertyException::REASON_POLL], Http::STATUS_NOT_FOUND);
}

if ($poll->getStatus() === Poll::STATUS_DRAFT) {
Expand All @@ -304,15 +377,13 @@ public function closePoll(int $pollId): DataResponse {
}

if ($poll->getStatus() === Poll::STATUS_CLOSED) {
return new DataResponse(['error' => 'poll'], Http::STATUS_BAD_REQUEST);
return new DataResponse(['error' => PollPropertyException::REASON_POLL], Http::STATUS_BAD_REQUEST);
}

$poll->setStatus(Poll::STATUS_CLOSED);

try {
$this->pollService->updatePoll($this->participant, $poll);
} catch (WrongPermissionsException) {
return new DataResponse(['error' => 'poll'], Http::STATUS_FORBIDDEN);
$this->pollService->closePoll($this->participant, $poll);
} catch (WrongPermissionsException $e) {
return new DataResponse(['error' => PollPropertyException::REASON_POLL], Http::STATUS_FORBIDDEN);
}

$attendee = $this->participant->getAttendee();
Expand Down
1 change: 1 addition & 0 deletions lib/Exceptions/PollPropertyException.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@

class PollPropertyException extends \InvalidArgumentException {
public const REASON_DRAFT = 'draft';
public const REASON_POLL = 'poll';
public const REASON_QUESTION = 'question';
public const REASON_OPTIONS = 'options';
public const REASON_ROOM = 'room';
Expand Down
53 changes: 50 additions & 3 deletions lib/Federation/Proxy/TalkV1/Controller/PollController.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,15 @@
namespace OCA\Talk\Federation\Proxy\TalkV1\Controller;

use OCA\Talk\Exceptions\CannotReachRemoteException;
use OCA\Talk\Exceptions\PollPropertyException;
use OCA\Talk\Federation\Proxy\TalkV1\ProxyRequest;
use OCA\Talk\Federation\Proxy\TalkV1\UserConverter;
use OCA\Talk\Participant;
use OCA\Talk\ResponseDefinitions;
use OCA\Talk\Room;
use OCP\AppFramework\Http;
use OCP\AppFramework\Http\DataResponse;
use Psr\Log\LoggerInterface;

/**
* @psalm-import-type TalkPoll from ResponseDefinitions
Expand All @@ -26,6 +28,7 @@ class PollController {
public function __construct(
protected ProxyRequest $proxy,
protected UserConverter $userConverter,
protected LoggerInterface $logger,
) {
}

Expand Down Expand Up @@ -131,7 +134,7 @@ 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{error: 'draft'|'options'|'question'|'room'}, array{}>
* @return DataResponse<Http::STATUS_OK, TalkPollDraft, array{}>|DataResponse<Http::STATUS_CREATED, TalkPoll, array{}>|DataResponse<Http::STATUS_BAD_REQUEST, array{error: 'draft'|'options'|'poll'|'question'|'room'}, array{}>
* @throws CannotReachRemoteException
*
* 200: Draft created successfully
Expand Down Expand Up @@ -171,7 +174,46 @@ public function createPoll(Room $room, Participant $participant, string $questio
}

/**
* @return DataResponse<Http::STATUS_OK, TalkPoll, array{}>|DataResponse<Http::STATUS_ACCEPTED, null, array{}>|DataResponse<Http::STATUS_BAD_REQUEST|Http::STATUS_FORBIDDEN|Http::STATUS_NOT_FOUND, array{error: string}, array{}>
* @return DataResponse<Http::STATUS_OK, TalkPollDraft, array{}>|DataResponse<Http::STATUS_BAD_REQUEST|Http::STATUS_FORBIDDEN|Http::STATUS_NOT_FOUND, array{error: 'draft'|'options'|'poll'|'question'|'room'}, array{}>
* @throws CannotReachRemoteException
*
* 200: Draft created successfully
* 201: Poll created successfully
* 400: Creating poll is not possible
*
* @see \OCA\Talk\Controller\PollController::createPoll()
*/
public function updateDraftPoll(int $pollId, Room $room, Participant $participant, string $question, array $options, int $resultMode, int $maxVotes): DataResponse {
$proxy = $this->proxy->post(
$participant->getAttendee()->getInvitedCloudId(),
$participant->getAttendee()->getAccessToken(),
$room->getRemoteServer() . '/ocs/v2.php/apps/spreed/api/v1/poll/' . $room->getRemoteToken() . '/draft/' . $pollId,
[
'question' => $question,
'options' => $options,
'resultMode' => $resultMode,
'maxVotes' => $maxVotes
],
);

$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 TalkPollDraft $data */
$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);
}

/**
* @return DataResponse<Http::STATUS_OK, TalkPoll, array{}>|DataResponse<Http::STATUS_ACCEPTED, null, array{}>|DataResponse<Http::STATUS_BAD_REQUEST|Http::STATUS_FORBIDDEN|Http::STATUS_NOT_FOUND, array{error: 'poll'}, array{}>
* @throws CannotReachRemoteException
*
* 200: Poll closed successfully
Expand Down Expand Up @@ -199,7 +241,12 @@ public function closePoll(Room $room, Participant $participant, int $pollId): Da
}
/** @var array{error?: string} $data */
$data = $this->proxy->getOCSData($proxy);
return new DataResponse(['error' => $data['error'] ?? 'poll'], $statusCode);

if ($data['error'] !== PollPropertyException::REASON_POLL) {
$this->logger->error('Unhandled error in ' . __METHOD__ . ': ' . $data['error']);
}

return new DataResponse(['error' => PollPropertyException::REASON_POLL], $statusCode);
}

/** @var TalkPoll $data */
Expand Down
56 changes: 54 additions & 2 deletions lib/Model/Poll.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@

namespace OCA\Talk\Model;

use OCA\Talk\Exceptions\PollPropertyException;
use OCA\Talk\ResponseDefinitions;
use OCP\AppFramework\Db\Entity;
use OCP\DB\Types;
Expand All @@ -18,10 +19,8 @@
* @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()
Expand Down Expand Up @@ -121,4 +120,57 @@ public function renderAsDraft(): array {
'maxVotes' => $this->getMaxVotes(),
];
}

public function isDraft(): bool {
return $this->getStatus() === self::STATUS_DRAFT;
}

/**
* @param array $options
* @return void
* @throws PollPropertyException
*/
public function setOptions(array $options): void {
try {
$jsonOptions = json_encode($options, JSON_THROW_ON_ERROR, 1);
} catch (\Exception) {
throw new PollPropertyException(PollPropertyException::REASON_OPTIONS);
}

$validOptions = [];
foreach ($options as $option) {
if (!is_string($option)) {
throw new PollPropertyException(PollPropertyException::REASON_OPTIONS);
}

$option = trim($option);
if ($option !== '') {
$validOptions[] = $option;
}
}

if (count($validOptions) < 2) {
throw new PollPropertyException(PollPropertyException::REASON_OPTIONS);
}

if (strlen($jsonOptions) > 60_000) {
throw new PollPropertyException(PollPropertyException::REASON_OPTIONS);
}

$this->setter('options', [$jsonOptions]);
}

/**
* @param string $question
* @return void
* @throws PollPropertyException
*/
public function setQuestion(string $question): void {
$question = trim($question);
if ($question === '' || strlen($question) > 32_000) {
throw new PollPropertyException(PollPropertyException::REASON_QUESTION);
}

$this->setter('question', [$question]);
}
}
5 changes: 3 additions & 2 deletions lib/Model/PollMapper.php
Original file line number Diff line number Diff line change
Expand Up @@ -44,12 +44,13 @@ public function getDraftsByRoomId(int $roomId): array {
* @throws DoesNotExistException
* @throws MultipleObjectsReturnedException
*/
public function getByPollId(int $pollId): Poll {
public function getPollByRoomIdAndPollId(int $roomId, int $pollId): Poll {
$query = $this->db->getQueryBuilder();

$query->select('*')
->from($this->getTableName())
->where($query->expr()->eq('id', $query->createNamedParameter($pollId, IQueryBuilder::PARAM_INT)));
->where($query->expr()->eq('id', $query->createNamedParameter($pollId, IQueryBuilder::PARAM_INT)))
->andWhere($query->expr()->eq('room_id', $query->createNamedParameter($roomId, IQueryBuilder::PARAM_INT)));

return $this->findEntity($query);
}
Expand Down
Loading
Loading