* * @author Joas Schilling * * @license GNU AGPL version 3 or any later version * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as * published by the Free Software Foundation, either version 3 of the * License, or (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . * */ namespace OCA\Talk\Service; use OCA\Talk\Exceptions\WrongPermissionsException; use OCA\Talk\Model\Poll; use OCA\Talk\Model\PollMapper; use OCA\Talk\Model\Vote; use OCA\Talk\Model\VoteMapper; use OCA\Talk\Participant; use OCP\AppFramework\Db\DoesNotExistException; use OCP\DB\Exception; use OCP\DB\QueryBuilder\IQueryBuilder; use OCP\IDBConnection; class PollService { protected IDBConnection $connection; protected PollMapper $pollMapper; protected VoteMapper $voteMapper; public function __construct( IDBConnection $connection, PollMapper $pollMapper, VoteMapper $voteMapper, ) { $this->connection = $connection; $this->pollMapper = $pollMapper; $this->voteMapper = $voteMapper; } public function createPoll(int $roomId, string $actorType, string $actorId, string $displayName, string $question, array $options, int $resultMode, int $maxVotes): Poll { $question = trim($question); if ($question === '' || strlen($question) > 32_000) { throw new \UnexpectedValueException(); } try { json_encode($options, JSON_THROW_ON_ERROR, 1); } catch (\Exception $e) { throw new \RuntimeException(); } $validOptions = []; foreach ($options as $option) { if (!is_string($option)) { throw new \RuntimeException(); } $option = trim($option); if ($option !== '') { $validOptions[] = $option; } } if (count($validOptions) < 2) { throw new \RuntimeException(); } try { $jsonOptions = json_encode($validOptions, JSON_THROW_ON_ERROR, 1); } catch (\Exception $e) { throw new \RuntimeException(); } if (strlen($jsonOptions) > 60_000) { throw new \UnexpectedValueException(); } $poll = new Poll(); $poll->setRoomId($roomId); $poll->setActorType($actorType); $poll->setActorId($actorId); $poll->setDisplayName($displayName); $poll->setQuestion($question); $poll->setOptions($jsonOptions); $poll->setVotes(json_encode([])); $poll->setResultMode($resultMode); $poll->setMaxVotes($maxVotes); $this->pollMapper->insert($poll); return $poll; } /** * @param int $roomId * @param int $pollId * @return Poll * @throws DoesNotExistException */ public function getPoll(int $roomId, int $pollId): Poll { $poll = $this->pollMapper->getByPollId($pollId); if ($poll->getRoomId() !== $roomId) { throw new DoesNotExistException('Room id mismatch'); } return $poll; } /** * @param Participant $participant * @param Poll $poll * @throws WrongPermissionsException * @throws Exception */ public function updatePoll(Participant $participant, Poll $poll): void { if (!$participant->hasModeratorPermissions() && ($poll->getActorType() !== $participant->getAttendee()->getActorType() || $poll->getActorId() !== $participant->getAttendee()->getActorId())) { // Only moderators and the author of the poll can update it throw new WrongPermissionsException(); } $this->pollMapper->update($poll); } /** * @param Participant $participant * @param Poll $poll * @return Vote[] */ public function getVotesForActor(Participant $participant, Poll $poll): array { return $this->voteMapper->findByPollIdForActor( $poll->getId(), $participant->getAttendee()->getActorType(), $participant->getAttendee()->getActorId() ); } /** * @param Poll $poll * @return Vote[] */ public function getVotes(Poll $poll): array { return $this->voteMapper->findByPollId($poll->getId()); } /** * @param Participant $participant * @param Poll $poll * @param int[] $optionIds Options the user voted for * @return Vote[] * @throws \RuntimeException */ public function votePoll(Participant $participant, Poll $poll, array $optionIds): array { $numVotes = count($optionIds); if ($numVotes !== count(array_unique($optionIds))) { throw new \UnexpectedValueException(); } if ($poll->getMaxVotes() !== Poll::MAX_VOTES_UNLIMITED && $poll->getMaxVotes() < $numVotes) { throw new \OverflowException(); } if (!empty($optionIds)) { foreach ($optionIds as $optionId) { if (!is_numeric($optionId)) { throw new \RangeException(); } } $maxOptionId = max(array_keys(json_decode($poll->getOptions(), true, 512, JSON_THROW_ON_ERROR))); $maxVotedId = max($optionIds); $minVotedId = min($optionIds); if ($minVotedId < 0 || $maxVotedId > $maxOptionId) { throw new \RangeException(); } } $votes = []; $result = json_decode($poll->getVotes(), true); $previousVotes = $this->voteMapper->findByPollIdForActor( $poll->getId(), $participant->getAttendee()->getActorType(), $participant->getAttendee()->getActorId() ); $numVoters = $poll->getNumVoters(); if ($previousVotes && $numVoters > 0) { $numVoters--; } foreach ($previousVotes as $vote) { $result[$vote->getOptionId()] ??= 1; $result[$vote->getOptionId()] -= 1; } $this->connection->beginTransaction(); try { $this->voteMapper->deleteVotesByActor( $poll->getId(), $participant->getAttendee()->getActorType(), $participant->getAttendee()->getActorId() ); if (!empty($optionIds)) { $numVoters++; } foreach ($optionIds as $optionId) { $vote = new Vote(); $vote->setPollId($poll->getId()); $vote->setRoomId($poll->getRoomId()); $vote->setActorType($participant->getAttendee()->getActorType()); $vote->setActorId($participant->getAttendee()->getActorId()); $vote->setDisplayName($participant->getAttendee()->getDisplayName()); $vote->setOptionId($optionId); $this->voteMapper->insert($vote); $result[$optionId] ??= 0; $result[$optionId] += 1; $votes[] = $vote; } } catch (\Exception $e) { $this->connection->rollBack(); throw $e; } $this->connection->commit(); $this->updateResultCache($poll->getId()); $result = array_filter($result); $poll->setVotes(json_encode($result)); $poll->setNumVoters($numVoters); return $votes; } public function updateResultCache(int $pollId): void { $resultQuery = $this->connection->getQueryBuilder(); $resultQuery->selectAlias( $resultQuery->func()->concat( $resultQuery->expr()->literal('"'), 'option_id', $resultQuery->expr()->literal('":'), $resultQuery->func()->count('id') ), 'colonseparatedvalue' ) ->from('talk_poll_votes') ->where($resultQuery->expr()->eq('poll_id', $resultQuery->createNamedParameter($pollId))) ->groupBy('option_id') ->orderBy('option_id', 'ASC'); $jsonQuery = $this->connection->getQueryBuilder(); $jsonQuery ->selectAlias( $jsonQuery->func()->concat( $jsonQuery->expr()->literal('{'), $jsonQuery->func()->groupConcat('colonseparatedvalue'), $jsonQuery->expr()->literal('}') ), 'json' ) ->from($jsonQuery->createFunction('(' . $resultQuery->getSQL() . ')'), 'json'); $subQuery = $this->connection->getQueryBuilder(); $subQuery->select('actor_type', 'actor_id') ->from('talk_poll_votes') ->where($subQuery->expr()->eq('poll_id', $subQuery->createNamedParameter($pollId))) ->groupBy('actor_type', 'actor_id'); $votersQuery = $this->connection->getQueryBuilder(); $votersQuery->select($votersQuery->func()->count('*')) ->from($votersQuery->createFunction('(' . $subQuery->getSQL() . ')'), 'voters'); $update = $this->connection->getQueryBuilder(); $update->update('talk_polls') ->set('votes', $jsonQuery->createFunction('(' . $jsonQuery->getSQL() . ')')) ->set('num_voters', $jsonQuery->createFunction('(' . $votersQuery->getSQL() . ')')) ->where($update->expr()->eq('id', $update->createNamedParameter($pollId, IQueryBuilder::PARAM_INT))); $this->connection->beginTransaction(); try { $update->executeStatement(); // Fix `null` being stored if the only voter revokes their vote $updateFixNull = $this->connection->getQueryBuilder(); $updateFixNull->update('talk_polls') ->set('votes', $updateFixNull->createNamedParameter('{}')) ->where($updateFixNull->expr()->eq('id', $updateFixNull->createNamedParameter($pollId, IQueryBuilder::PARAM_INT))) ->andWhere($updateFixNull->expr()->isNull('votes')); $updateFixNull->executeStatement(); } catch (\Exception $e) { $this->connection->rollBack(); throw $e; } $this->connection->commit(); } public function deleteByRoomId(int $roomId): void { $this->voteMapper->deleteByRoomId($roomId); $this->pollMapper->deleteByRoomId($roomId); } public function deleteByPollId(int $pollId): void { $this->voteMapper->deleteByPollId($pollId); $this->pollMapper->deleteByPollId($pollId); } public function updateDisplayNameForActor(string $actorType, string $actorId, string $displayName): void { $update = $this->connection->getQueryBuilder(); $update->update('talk_polls') ->set('display_name', $update->createNamedParameter($displayName)) ->where($update->expr()->eq('actor_type', $update->createNamedParameter($actorType))) ->andWhere($update->expr()->eq('actor_id', $update->createNamedParameter($actorId))); $update->executeStatement(); $update = $this->connection->getQueryBuilder(); $update->update('talk_poll_votes') ->set('display_name', $update->createNamedParameter($displayName)) ->where($update->expr()->eq('actor_type', $update->createNamedParameter($actorType))) ->andWhere($update->expr()->eq('actor_id', $update->createNamedParameter($actorId))); $update->executeStatement(); } public function neutralizeDeletedUser(string $actorType, string $actorId): void { $update = $this->connection->getQueryBuilder(); $update->update('talk_polls') ->set('display_name', $update->createNamedParameter('')) ->set('actor_type', $update->createNamedParameter('deleted_users')) ->set('actor_id', $update->createNamedParameter('deleted_users')) ->where($update->expr()->eq('actor_type', $update->createNamedParameter($actorType))) ->andWhere($update->expr()->eq('actor_id', $update->createNamedParameter($actorId))); $update->executeStatement(); $update = $this->connection->getQueryBuilder(); $update->update('talk_poll_votes') ->set('display_name', $update->createNamedParameter('')) ->set('actor_type', $update->createNamedParameter('deleted_users')) ->set('actor_id', $update->createNamedParameter('deleted_users')) ->where($update->expr()->eq('actor_type', $update->createNamedParameter($actorType))) ->andWhere($update->expr()->eq('actor_id', $update->createNamedParameter($actorId))); $update->executeStatement(); } }