diff --git a/lib/Activity/PollChanges.php b/lib/Activity/PollChanges.php index 6ee52561eb..dec460ea33 100644 --- a/lib/Activity/PollChanges.php +++ b/lib/Activity/PollChanges.php @@ -5,6 +5,7 @@ * SPDX-FileCopyrightText: 2021 Nextcloud contributors * SPDX-License-Identifier: AGPL-3.0-or-later */ + namespace OCA\Polls\Activity; use OCA\Polls\AppConstants; diff --git a/lib/Controller/BaseApiV2Controller.php b/lib/Controller/BaseApiV2Controller.php index c81d30fae8..648cf115d4 100644 --- a/lib/Controller/BaseApiV2Controller.php +++ b/lib/Controller/BaseApiV2Controller.php @@ -5,6 +5,7 @@ * SPDX-FileCopyrightText: 2024 Nextcloud contributors * SPDX-License-Identifier: AGPL-3.0-or-later */ + namespace OCA\Polls\Controller; use Closure; @@ -42,10 +43,8 @@ public function __construct( protected function response(Closure $callback, int $successStatus = Http::STATUS_OK): DataResponse { try { return new DataResponse($callback(), $successStatus); - } catch (DoesNotExistException $e) { throw new OCSNotFoundException($e->getMessage()); - } catch (Exception $e) { if ($e->getStatus() === Http::STATUS_NOT_MODIFIED) { diff --git a/lib/Controller/OptionApiController.php b/lib/Controller/OptionApiController.php index bd55c0d45c..5cf6bed03e 100644 --- a/lib/Controller/OptionApiController.php +++ b/lib/Controller/OptionApiController.php @@ -74,7 +74,6 @@ public function add( ), Http::STATUS_CREATED); } - /** * Add mulitple new options * @param int $pollId poll id diff --git a/lib/Controller/PollController.php b/lib/Controller/PollController.php index b61ff27b3d..fefca4ce32 100644 --- a/lib/Controller/PollController.php +++ b/lib/Controller/PollController.php @@ -9,8 +9,6 @@ namespace OCA\Polls\Controller; use OCA\Polls\Db\Poll; -use OCA\Polls\Helper\Container; -use OCA\Polls\Model\Settings\AppSettings; use OCA\Polls\Service\CommentService; use OCA\Polls\Service\MailService; use OCA\Polls\Service\OptionService; @@ -46,31 +44,57 @@ public function __construct( } /** - * Get list of polls + * Get one page of the current user's polls + * @param string $category Poll list category, see Poll::CATEGORIES; ignored if $pollGroup is set + * @param int|null $pollGroup Only list polls of this poll group + * @param string|null $type Only list polls of this type (datePoll or textPoll) + * @param string $sortBy Sort column, see PollService::SORT_COLUMNS + * @param string $sortDirection 'asc' or 'desc' + * @param int $offset Number of polls to skip + * @param int $limit Page size, max PollService::MAX_PAGE_SIZE + * + * psalm-return JSONResponse, total: int}> + */ + #[NoAdminRequired] + #[OpenAPI(OpenAPI::SCOPE_IGNORE)] + #[FrontpageRoute(verb: 'GET', url: '/polls')] + public function listPolls( + string $category = Poll::CATEGORY_RELEVANT, + ?int $pollGroup = null, + ?string $type = null, + string $sortBy = PollService::SORT_CREATED, + string $sortDirection = 'desc', + int $offset = 0, + int $limit = 20, + ): JSONResponse { + return $this->response(fn () => $this->pollService->listPollsPaged( + $category, + $pollGroup, + $type, + $sortBy, + $sortDirection, + $offset, + $limit, + )); + } + + /** + * Get poll counts per category and poll group and the poll groups for the navigation + * * psalm-return JSONResponse, - * permissions: array{ - * pollCreationAllowed: bool, - * comboAllowed: bool - * }, + * counts: array, + * pollGroupCounts: array, * pollGroups: array * }> */ #[NoAdminRequired] #[OpenAPI(OpenAPI::SCOPE_IGNORE)] - #[FrontpageRoute(verb: 'GET', url: '/polls')] - public function listPolls(): JSONResponse { - return $this->response(function () { - $appSettings = Container::queryClass(AppSettings::class); - return [ - 'polls' => $this->pollService->listPolls(), - 'permissions' => [ - 'pollCreationAllowed' => $appSettings->getPollCreationAllowed(), - 'comboAllowed' => $appSettings->getComboAllowed(), - ], - 'pollGroups' => $this->pollGroupService->listPollGroups(), - ]; - }); + #[FrontpageRoute(verb: 'GET', url: '/polls/meta')] + public function listPollsMeta(): JSONResponse { + return $this->response(fn () => array_merge( + $this->pollService->getPollListCounts(), + ['pollGroups' => $this->pollGroupService->listPollGroups()], + )); } /** diff --git a/lib/Controller/PublicController.php b/lib/Controller/PublicController.php index 8fcfebf02a..0175a17ef6 100644 --- a/lib/Controller/PublicController.php +++ b/lib/Controller/PublicController.php @@ -113,7 +113,6 @@ public function getSession(): JSONResponse { ]); } - /** * Watch poll for updates * @param string $mode the mode of watching, e.g. 'longPolling' @@ -419,7 +418,6 @@ public function setDisplayName(string $token, string $displayName): JSONResponse ]); } - /** * Set EmailAddress * @param string $token Share token diff --git a/lib/Controller/ShareApiController.php b/lib/Controller/ShareApiController.php index beac905187..ff97ce3fd8 100644 --- a/lib/Controller/ShareApiController.php +++ b/lib/Controller/ShareApiController.php @@ -58,7 +58,6 @@ public function get(string $token): DataResponse { return $this->response(fn () => ['share' => $this->shareService->request($token)]); } - #[CORS] #[PublicPage] #[ShareTokenRequired] diff --git a/lib/Cron/JanitorCron.php b/lib/Cron/JanitorCron.php index 59bbe16d11..7ebb02f968 100644 --- a/lib/Cron/JanitorCron.php +++ b/lib/Cron/JanitorCron.php @@ -96,7 +96,6 @@ protected function run($argument) { $this->logger->info('JanitorCron: ' . $message); } - // archive polls after defined days after closing date $autoArchiveOffset = $this->appSettings->getAutoArchiveOffsetDays(); diff --git a/lib/Cron/UserDeletedJob.php b/lib/Cron/UserDeletedJob.php index 3777d0dbef..dedc0ad22f 100644 --- a/lib/Cron/UserDeletedJob.php +++ b/lib/Cron/UserDeletedJob.php @@ -13,7 +13,6 @@ use OCA\Polls\Db\LogMapper; use OCA\Polls\Db\OptionMapper; use OCA\Polls\Db\PollMapper; - use OCA\Polls\Db\PreferencesMapper; use OCA\Polls\Db\Share; use OCA\Polls\Db\ShareMapper; diff --git a/lib/Db/CommentMapper.php b/lib/Db/CommentMapper.php index 138ff2b98d..d3c66c6bc4 100644 --- a/lib/Db/CommentMapper.php +++ b/lib/Db/CommentMapper.php @@ -93,7 +93,6 @@ public function purgeDeletedComments(int $offset): int { ); return $query->executeStatement(); - } /** diff --git a/lib/Db/EntityWithUser.php b/lib/Db/EntityWithUser.php index b35f19f31a..ea460294ce 100644 --- a/lib/Db/EntityWithUser.php +++ b/lib/Db/EntityWithUser.php @@ -90,7 +90,6 @@ private function getEntityAnonymization(): bool { return false; } - /** * @return UserBase Gets owner of the entity */ diff --git a/lib/Db/Log.php b/lib/Db/Log.php index 1bf6ba3987..4a0a945b5f 100644 --- a/lib/Db/Log.php +++ b/lib/Db/Log.php @@ -9,7 +9,6 @@ namespace OCA\Polls\Db; use JsonSerializable; - use OCP\AppFramework\Db\Entity; /** diff --git a/lib/Db/OptionMapper.php b/lib/Db/OptionMapper.php index 9524f4bf49..8dd5d72f67 100644 --- a/lib/Db/OptionMapper.php +++ b/lib/Db/OptionMapper.php @@ -134,7 +134,6 @@ public function getMinDate(int $pollId): int|false { return $qb->executeQuery()->fetchOne(); } - /** * Get the maximum date of all options in a poll * @@ -207,7 +206,6 @@ protected function buildQuery(bool $hideResults = false): IQueryBuilder { ->groupBy(self::TABLE . '.id') ->orderBy('order', 'ASC'); - $this->joinVotesCount($qb, self::TABLE, hideResults: $hideResults); $this->joinPollForLimits($qb, self::TABLE); $this->joinCurrentUserVote($qb, self::TABLE, $currentUserId); @@ -215,7 +213,6 @@ protected function buildQuery(bool $hideResults = false): IQueryBuilder { $this->joinAnon($qb, self::TABLE); $this->joinShareRole($qb, self::TABLE, $currentUserId); - return $qb; } diff --git a/lib/Db/Poll.php b/lib/Db/Poll.php index da7df0ae2a..419cec200b 100644 --- a/lib/Db/Poll.php +++ b/lib/Db/Poll.php @@ -100,6 +100,29 @@ class Poll extends EntityWithUser implements JsonSerializable { public const TWO_DAYS = 172800; public const ONE_AND_HALF_DAY = 129600; + public const CATEGORY_RELEVANT = 'relevant'; + public const CATEGORY_MY = 'my'; + public const CATEGORY_PRIVATE = 'private'; + public const CATEGORY_PARTICIPATED = 'participated'; + public const CATEGORY_OPEN = 'open'; + public const CATEGORY_ALL = 'all'; + public const CATEGORY_CLOSED = 'closed'; + public const CATEGORY_ARCHIVED = 'archived'; + public const CATEGORY_ADMIN = 'admin'; + public const CATEGORIES = [ + self::CATEGORY_RELEVANT, + self::CATEGORY_MY, + self::CATEGORY_PRIVATE, + self::CATEGORY_PARTICIPATED, + self::CATEGORY_OPEN, + self::CATEGORY_ALL, + self::CATEGORY_CLOSED, + self::CATEGORY_ARCHIVED, + self::CATEGORY_ADMIN, + ]; + // polls are relevant, if the relevant threshold is not older than this + public const RELEVANT_PERIOD = 100 * 24 * 60 * 60; + public const ROLE_USER = Share::TYPE_USER; public const ROLE_ADMIN = Share::TYPE_ADMIN; public const ROLE_EMAIL = Share::TYPE_EMAIL; @@ -348,6 +371,14 @@ public function getPollOwnerId() { return $this->getOwner(); } + /** + * Uses the injected user session instead of a container lookup per call, + * because this is evaluated multiple times per poll in permission checks + */ + public function getCurrentUserIsEntityUser(): bool { + return $this->userSession->getCurrentUserId() === $this->getOwner(); + } + public function getUserRole(): string { if ($this->getCurrentUserIsEntityUser()) { return self::ROLE_OWNER; @@ -480,7 +511,7 @@ public function getPollGroupUserShares(): array { return explode(PollGroup::CONCAT_SEPARATOR, $this->pollGroupUserShares); } - private function getAccess(): string { + public function getAccess(): string { if ($this->access === self::ACCESS_PUBLIC) { return self::ACCESS_OPEN; } @@ -517,7 +548,6 @@ private function getMaxDate(): int { return $this->maxDate; } - private function setMiscSettingsArray(array $value): void { $this->setMiscSettings(json_encode($value)); } @@ -530,7 +560,7 @@ private function getMiscSettingsArray(): array { return []; } - private function getRelevantThreshold(): int { + public function getRelevantThreshold(): int { return max( $this->getCreated(), $this->getLastInteraction(), @@ -609,6 +639,52 @@ public function getIsAllowed(string $permission): bool { }; } + /** + * Get the poll list categories, the poll belongs to for the current user + * Permission checks are evaluated once per poll + * + * @return string[] subset of self::CATEGORIES + */ + public function getCategories(): array { + $isAdmin = $this->userSession->getCurrentUser()->getIsAdmin(); + $categories = ($isAdmin && !$this->getCurrentUserIsEntityUser()) ? [self::CATEGORY_ADMIN] : []; + $canView = $this->getAllowAccessPoll(); + + if ($this->getDeleted()) { + if ($canView) { + $categories[] = self::CATEGORY_ARCHIVED; + } + return $categories; + } + + $access = $this->getAccess(); + + if ($this->getRelevantThreshold() > time() - self::RELEVANT_PERIOD + && ($this->getIsInvolved() || ($canView && $access !== self::ACCESS_OPEN)) + ) { + $categories[] = self::CATEGORY_RELEVANT; + } + if ($this->getIsPollOwner()) { + $categories[] = self::CATEGORY_MY; + } + if ($this->getIsParticipant()) { + $categories[] = self::CATEGORY_PARTICIPATED; + } + if ($access === self::ACCESS_OPEN) { + $categories[] = self::CATEGORY_OPEN; + } + if ($canView) { + $categories[] = self::CATEGORY_ALL; + if ($access === self::ACCESS_PRIVATE) { + $categories[] = self::CATEGORY_PRIVATE; + } + if ($this->getExpired()) { + $categories[] = self::CATEGORY_CLOSED; + } + } + return $categories; + } + /** * getIsInvolved - Is current user involved in current poll? * @return bool Returns true, if the current user is involved in the poll via share, as a participant or as the poll owner. @@ -832,7 +908,6 @@ public function getIsPollOwner(): bool { return ($this->getUserRole() === Poll::ROLE_OWNER); } - /** * Permission checks */ @@ -949,5 +1024,4 @@ private function getAllowShowResults(): bool { return $this->getShowResults() === Poll::SHOW_RESULTS_ALWAYS; } - } diff --git a/lib/Db/PollGroupMapper.php b/lib/Db/PollGroupMapper.php index b086fa7fd4..2150adba43 100644 --- a/lib/Db/PollGroupMapper.php +++ b/lib/Db/PollGroupMapper.php @@ -98,7 +98,6 @@ public function add(PollGroup $pollGroup): PollGroup { $pollGroup->setCreated(time()); $pollGroup->setOwner($this->userSession->getCurrentUserId()); return $this->insert($pollGroup); - } /** diff --git a/lib/Db/PollMapper.php b/lib/Db/PollMapper.php index 8286ed0a7c..55b163d2be 100644 --- a/lib/Db/PollMapper.php +++ b/lib/Db/PollMapper.php @@ -11,6 +11,7 @@ use OCA\Polls\Helper\SqlHelper; use OCA\Polls\UserSession; use OCP\AppFramework\Db\QBMapper; +use OCP\DB\QueryBuilder\ICompositeExpression; use OCP\DB\QueryBuilder\IParameter; use OCP\DB\QueryBuilder\IQueryBuilder; use OCP\IDBConnection; @@ -60,16 +61,291 @@ public function findAutoReminderPolls(): array { } /** + * Find the polls of the current user's poll list + * + * The optional filters only narrow down the candidates in SQL. They never + * drop a poll, which could match. Poll::getCategories() stays the authority + * and must still be evaluated by the caller. + * + * @param string|null $category one of Poll::CATEGORIES, ignored if $pollGroupId is set + * @param int|null $pollGroupId only polls of this poll group + * @param string|null $type only polls of this type * @throws \OCP\AppFramework\Db\DoesNotExistException if not found * @return Poll[] */ - public function findForMe(string $userId): array { + public function findForMe( + string $userId, + ?string $category = null, + ?int $pollGroupId = null, + ?string $type = null, + ): array { $qb = $this->buildQuery(detailed: false); - $qb->where($qb->expr()->eq(self::TABLE . '.deleted', $qb->expr()->literal(0, IQueryBuilder::PARAM_INT))) - ->orWhere($qb->expr()->eq(self::TABLE . '.owner', $qb->createNamedParameter($userId, IQueryBuilder::PARAM_STR))); + $this->applyPollListFilter($qb, $userId, $category, $pollGroupId, $type); return $this->findEntities($qb); } + /** + * One sorted page of findForMe() + * + * Unlike findForMe() this skips the entity side category check, so it may + * only be used for the categories, whose SQL condition is exact. See + * PollService::hasExactCategoryCondition(). + * + * @param string $sortColumn a numeric column of the polls table + * @throws \OCP\AppFramework\Db\DoesNotExistException if not found + * @return Poll[] + */ + public function findPageForMe( + string $userId, + string $category, + ?string $type, + string $sortColumn, + bool $descending, + int $offset, + int $limit, + ): array { + $qb = $this->buildQuery(detailed: false); + $this->applyPollListFilter($qb, $userId, $category, null, $type); + + $direction = $descending ? 'DESC' : 'ASC'; + $qb->orderBy(self::TABLE . '.' . $sortColumn, $direction) + // tie breaker, so paging over equal sort values does not skip or repeat polls + ->addOrderBy(self::TABLE . '.id', $direction) + ->setFirstResult($offset) + ->setMaxResults($limit); + + return $this->findEntities($qb); + } + + /** + * Number of polls matching findForMe() without loading them + * + * Counts the candidates of the SQL conditions, so this is only exact for the + * categories of PollService::hasExactCategoryCondition(). + */ + public function countForMe( + string $userId, + ?string $category = null, + ?int $pollGroupId = null, + ?string $type = null, + ): int { + // no joins needed, all conditions are either on the polls table or EXISTS subqueries + $qb = $this->db->getQueryBuilder(); + $qb->select($qb->func()->count(self::TABLE . '.id')) + ->from($this->getTableName(), self::TABLE); + $this->applyPollListFilter($qb, $userId, $category, $pollGroupId, $type); + + $result = $qb->executeQuery(); + $count = (int)$result->fetchOne(); + $result->closeCursor(); + + return $count; + } + + /** + * Add the poll list conditions of findForMe() to a query on the polls table + */ + private function applyPollListFilter( + IQueryBuilder $qb, + string $userId, + ?string $category, + ?int $pollGroupId, + ?string $type, + ): void { + $expr = $qb->expr(); + $userParam = $qb->createNamedParameter($userId, IQueryBuilder::PARAM_STR); + + $qb->where($expr->orX( + $expr->eq(self::TABLE . '.deleted', $expr->literal(0, IQueryBuilder::PARAM_INT)), + $expr->eq(self::TABLE . '.owner', $userParam), + )); + + if ($type !== null) { + $qb->andWhere($expr->eq(self::TABLE . '.type', $qb->createNamedParameter($type, IQueryBuilder::PARAM_STR))); + } + + if ($pollGroupId !== null) { + // EXISTS instead of filtering the poll groups join, which would truncate the concatenated poll groups + $subQuery = $this->db->getQueryBuilder(); + $subQuery->select($subQuery->expr()->literal(1)) + ->from(PollGroup::RELATION_TABLE, 'group_filter') + ->where($subQuery->expr()->eq('group_filter.poll_id', self::TABLE . '.id')) + ->andWhere($subQuery->expr()->eq('group_filter.group_id', $qb->createNamedParameter($pollGroupId, IQueryBuilder::PARAM_INT))); + $qb->andWhere((string)$qb->createFunction('EXISTS (' . $subQuery->getSQL() . ')')); + return; + } + + $condition = $category !== null + ? $this->getCategoryCondition($qb, $category, $userParam) + : $this->anyCategoryCondition($qb, $userParam); + + if ($condition !== null) { + $qb->andWhere($condition); + } + } + + /** + * SQL condition, which is a superset of the union of all categories of + * Poll::getCategories(), i.e. of every poll, which shows up in any poll list + * + * Without it an unfiltered poll list would scan every poll of the instance. + * + * @return ICompositeExpression|null null, if no condition applies + */ + private function anyCategoryCondition(IQueryBuilder $qb, IParameter $userParam): ?ICompositeExpression { + // site admins have every poll they do not own in their admin category + if ($this->userSession->getCurrentUser()->getIsAdmin()) { + return null; + } + + // every remaining category requires access to the poll, archived polls are + // already limited to the current user's own polls by the base condition + return $this->mayViewCondition($qb, $userParam); + } + + /** + * SQL condition, which is a superset of Poll::getCategories() for the category + * Exact permission checks (group memberships, locked shares) are left to the entity + * + * @return ICompositeExpression|string|null null, if no condition applies + */ + private function getCategoryCondition(IQueryBuilder $qb, string $category, IParameter $userParam): ICompositeExpression|string|null { + $expr = $qb->expr(); + $notDeleted = $expr->eq(self::TABLE . '.deleted', $expr->literal(0, IQueryBuilder::PARAM_INT)); + + return match ($category) { + Poll::CATEGORY_ALL => $expr->andX( + $notDeleted, + $this->mayViewCondition($qb, $userParam), + ), + Poll::CATEGORY_MY => $expr->andX( + $notDeleted, + $expr->eq(self::TABLE . '.owner', $userParam), + ), + // legacy access values are mapped by Poll::getAccess() + Poll::CATEGORY_PRIVATE => $expr->andX( + $notDeleted, + $expr->in(self::TABLE . '.access', $qb->createNamedParameter(['private', 'hidden'], IQueryBuilder::PARAM_STR_ARRAY)), + $this->mayViewCondition($qb, $userParam), + ), + Poll::CATEGORY_OPEN => $expr->andX( + $notDeleted, + $expr->in(self::TABLE . '.access', $qb->createNamedParameter(['open', 'public'], IQueryBuilder::PARAM_STR_ARRAY)), + ), + Poll::CATEGORY_CLOSED => $expr->andX( + $notDeleted, + $expr->gt(self::TABLE . '.expire', $expr->literal(0, IQueryBuilder::PARAM_INT)), + $expr->lt(self::TABLE . '.expire', $qb->createNamedParameter(time(), IQueryBuilder::PARAM_INT)), + $this->mayViewCondition($qb, $userParam), + ), + // combined with the base condition, these are the current user's archived polls + Poll::CATEGORY_ARCHIVED => $expr->gt(self::TABLE . '.deleted', $expr->literal(0, IQueryBuilder::PARAM_INT)), + Poll::CATEGORY_PARTICIPATED => $expr->andX( + $notDeleted, + $this->existsSubQuery($qb, Vote::TABLE, 'vote_filter', $userParam), + ), + // relevant = involved || (view && not open). Both are covered by the + // view superset without its open access branch + Poll::CATEGORY_RELEVANT => $expr->andX( + $notDeleted, + $this->relevantCondition($qb, time() - Poll::RELEVANT_PERIOD), + $this->mayViewCondition($qb, $userParam, includeOpenAccess: false), + ), + // only site admins have the admin category + Poll::CATEGORY_ADMIN => $this->userSession->getCurrentUser()->getIsAdmin() + ? $expr->neq(self::TABLE . '.owner', $userParam) + : $expr->eq($expr->literal(1, IQueryBuilder::PARAM_INT), $expr->literal(0, IQueryBuilder::PARAM_INT)), + default => null, + }; + } + + /** + * Superset of Poll::getAllowAccessPoll() for logged in users + * + * Uses EXISTS subqueries instead of the joined share columns, because filtering + * on joined rows would truncate the concatenated columns (poll groups, group shares) + * + * @param bool $includeOpenAccess false limits the superset to involved users and session shares + */ + private function mayViewCondition(IQueryBuilder $qb, IParameter $userParam, bool $includeOpenAccess = true): ICompositeExpression { + $expr = $qb->expr(); + $conditions = [ + $expr->eq(self::TABLE . '.owner', $userParam), + // participant + $this->existsSubQuery($qb, Vote::TABLE, 'vote_filter', $userParam), + // personal share of any type (user, admin, email, contact, external) + $this->existsSubQuery($qb, Share::TABLE, 'share_filter', $userParam), + ]; + if ($includeOpenAccess) { + $conditions[] = $expr->in(self::TABLE . '.access', $qb->createNamedParameter(['open', 'public'], IQueryBuilder::PARAM_STR_ARRAY)); + } + + // any group share, the group membership is checked by the entity + $groupShares = $this->db->getQueryBuilder(); + $groupShares->select($groupShares->expr()->literal(1)) + ->from(Share::TABLE, 'group_share_filter') + ->where($groupShares->expr()->eq('group_share_filter.poll_id', self::TABLE . '.id')) + ->andWhere($groupShares->expr()->eq('group_share_filter.type', $qb->createNamedParameter(Share::TYPE_GROUP, IQueryBuilder::PARAM_STR))) + ->andWhere($groupShares->expr()->eq('group_share_filter.deleted', $groupShares->expr()->literal(0, IQueryBuilder::PARAM_INT))); + $conditions[] = (string)$qb->createFunction('EXISTS (' . $groupShares->getSQL() . ')'); + + // share of a poll group, the poll belongs to + $pollGroupShares = $this->db->getQueryBuilder(); + $pollGroupShares->select($pollGroupShares->expr()->literal(1)) + ->from(PollGroup::RELATION_TABLE, 'poll_group_filter') + ->innerJoin('poll_group_filter', Share::TABLE, 'poll_group_share_filter', $pollGroupShares->expr()->eq('poll_group_share_filter.group_id', 'poll_group_filter.group_id')) + ->where($pollGroupShares->expr()->eq('poll_group_filter.poll_id', self::TABLE . '.id')) + ->andWhere($pollGroupShares->expr()->eq('poll_group_share_filter.user_id', $userParam)) + ->andWhere($pollGroupShares->expr()->eq('poll_group_share_filter.deleted', $pollGroupShares->expr()->literal(0, IQueryBuilder::PARAM_INT))); + $conditions[] = (string)$qb->createFunction('EXISTS (' . $pollGroupShares->getSQL() . ')'); + + // poll of the share the session was opened with + $sessionShare = $this->userSession->getShare(); + if ($sessionShare->getId()) { + $conditions[] = $expr->eq(self::TABLE . '.id', $qb->createNamedParameter($sessionShare->getPollId(), IQueryBuilder::PARAM_INT)); + } + + return $expr->orX(...$conditions); + } + + /** + * EXISTS condition for a row of the current user in a table with poll_id and user_id + * Deleted shares are excluded, votes have no deleted state + */ + private function existsSubQuery(IQueryBuilder $qb, string $table, string $alias, IParameter $userParam): string { + $subQuery = $this->db->getQueryBuilder(); + $subQuery->select($subQuery->expr()->literal(1)) + ->from($table, $alias) + ->where($subQuery->expr()->eq($alias . '.poll_id', self::TABLE . '.id')) + ->andWhere($subQuery->expr()->eq($alias . '.user_id', $userParam)); + if ($table === Share::TABLE) { + $subQuery->andWhere($subQuery->expr()->eq($alias . '.deleted', $subQuery->expr()->literal(0, IQueryBuilder::PARAM_INT))); + } + return (string)$qb->createFunction('EXISTS (' . $subQuery->getSQL() . ')'); + } + + /** + * Equivalent to Poll::getRelevantThreshold() > $threshold + */ + private function relevantCondition(IQueryBuilder $qb, int $threshold): ICompositeExpression { + $expr = $qb->expr(); + $thresholdParam = $qb->createNamedParameter($threshold, IQueryBuilder::PARAM_INT); + + $subQuery = $this->db->getQueryBuilder(); + $subQuery->select($subQuery->expr()->literal(1)) + ->from(Option::TABLE, 'option_filter') + ->where($subQuery->expr()->eq('option_filter.poll_id', self::TABLE . '.id')) + ->andWhere($subQuery->expr()->eq('option_filter.deleted', $subQuery->expr()->literal(0, IQueryBuilder::PARAM_INT))) + ->andWhere($subQuery->expr()->gt('option_filter.timestamp', $thresholdParam)); + + return $expr->orX( + $expr->gt(self::TABLE . '.created', $thresholdParam), + $expr->gt(self::TABLE . '.last_interaction', $thresholdParam), + $expr->gt(self::TABLE . '.expire', $thresholdParam), + (string)$qb->createFunction('EXISTS (' . $subQuery->getSQL() . ')'), + ); + } + /** * @throws \OCP\AppFramework\Db\DoesNotExistException if not found * @return Poll[] diff --git a/lib/Db/QBMapperWithUser.php b/lib/Db/QBMapperWithUser.php index 9788639494..8ee1757ee3 100644 --- a/lib/Db/QBMapperWithUser.php +++ b/lib/Db/QBMapperWithUser.php @@ -57,7 +57,6 @@ protected function joinAnon( $qb->expr()->eq($joinAlias . '.id', $fromAlias . '.poll_id'), ); - } /** * Joins share type for evaluating current user's role in a poll diff --git a/lib/Db/Share.php b/lib/Db/Share.php index c586371318..09adad6ec3 100644 --- a/lib/Db/Share.php +++ b/lib/Db/Share.php @@ -70,7 +70,6 @@ class Share extends EntityWithUser implements JsonSerializable { public const TYPE_CIRCLE = 'circle'; public const TYPE_CONTACTGROUP = 'contactGroup'; - public const CONVERATABLE_PUBLIC_SHARES = [ self::TYPE_EMAIL, self::TYPE_CONTACT, diff --git a/lib/Db/Subscription.php b/lib/Db/Subscription.php index b2f6f389ea..43e3f34d1e 100644 --- a/lib/Db/Subscription.php +++ b/lib/Db/Subscription.php @@ -31,7 +31,6 @@ class Subscription extends Entity implements JsonSerializable { /** @var Log[] $logEntries */ protected array $logEntries = []; - public function __construct() { $this->addType('id', 'integer'); $this->addType('pollId', 'integer'); diff --git a/lib/Db/V3/TableManager.php b/lib/Db/V3/TableManager.php index abd47eb623..707f9e94a8 100644 --- a/lib/Db/V3/TableManager.php +++ b/lib/Db/V3/TableManager.php @@ -305,7 +305,6 @@ public function removeOrphaned(): array { ); $orphaned[PollGroup::RELATION_TABLE] = $query->executeStatement(); - // finally delete all polls with id === null $query = $this->connection->getQueryBuilder(); $query->delete(Poll::TABLE) @@ -537,7 +536,6 @@ public function setLastInteraction(?int $timestamp = null): string { $this->logger->info('No polls needed to get updated with last interaction info'); return 'Last interaction all set'; - } public function migrateOptionsToHash(): array { diff --git a/lib/Db/VoteMapper.php b/lib/Db/VoteMapper.php index 5de1ec3f7f..424c9ac457 100644 --- a/lib/Db/VoteMapper.php +++ b/lib/Db/VoteMapper.php @@ -60,7 +60,6 @@ public function getAll(bool $includeNull = false): array { return $this->findEntities($qb); } - /** * @throws \OCP\AppFramework\Db\DoesNotExistException if not found * @return Vote[] @@ -234,7 +233,6 @@ protected function buildQuery(bool $findOrphaned = false): IQueryBuilder { $this->joinAnon($qb, self::TABLE); $this->joinShareRole($qb, self::TABLE, $currentUserId); - return $qb; } diff --git a/lib/Db/Watch.php b/lib/Db/Watch.php index d8b07945ed..cd2a2a9309 100644 --- a/lib/Db/Watch.php +++ b/lib/Db/Watch.php @@ -9,7 +9,6 @@ namespace OCA\Polls\Db; use JsonSerializable; - use OCP\AppFramework\Db\Entity; /** diff --git a/lib/Event/BaseEvent.php b/lib/Event/BaseEvent.php index f941b5d84f..36eeaf6423 100644 --- a/lib/Event/BaseEvent.php +++ b/lib/Event/BaseEvent.php @@ -26,7 +26,6 @@ abstract class BaseEvent extends Event { protected UserMapper $userMapper; protected UserSession $userSession; - public function __construct( protected Poll|Comment|Share|Option|Vote $eventObject, ) { diff --git a/lib/Event/PollDeletedEvent.php b/lib/Event/PollDeletedEvent.php index 3555bbca49..b2ae43c423 100644 --- a/lib/Event/PollDeletedEvent.php +++ b/lib/Event/PollDeletedEvent.php @@ -8,7 +8,6 @@ namespace OCA\Polls\Event; use OCA\Polls\Db\Poll; - use OCA\Polls\Notification\Notifier; class PollDeletedEvent extends PollEvent { @@ -19,7 +18,6 @@ public function __construct( $this->eventId = self::DELETE; } - public function getNotification(): array { if ($this->getActor() === $this->getPollOwner()) { return []; diff --git a/lib/Exceptions/InvalidPollListParameterException.php b/lib/Exceptions/InvalidPollListParameterException.php new file mode 100644 index 0000000000..32897a4e4d --- /dev/null +++ b/lib/Exceptions/InvalidPollListParameterException.php @@ -0,0 +1,19 @@ +userSession->setClientTimeZone($clientTimeZone); } - if ($this->hasAttribute($reflectionMethod, ShareTokenRequired::class)) { $this->userSession->setShareToken($this->getShareTokenFromURI()); } diff --git a/lib/Migration/FixVotes.php b/lib/Migration/FixVotes.php index 6c81ba501f..0cae43a0f3 100644 --- a/lib/Migration/FixVotes.php +++ b/lib/Migration/FixVotes.php @@ -6,7 +6,6 @@ * SPDX-License-Identifier: AGPL-3.0-or-later */ - namespace OCA\Polls\Migration; use Doctrine\DBAL\Schema\Schema; diff --git a/lib/Migration/RepairSteps/CleanTables.php b/lib/Migration/RepairSteps/CleanTables.php index 8bf5c4a5fa..4e88891538 100644 --- a/lib/Migration/RepairSteps/CleanTables.php +++ b/lib/Migration/RepairSteps/CleanTables.php @@ -6,7 +6,6 @@ * SPDX-License-Identifier: AGPL-3.0-or-later */ - namespace OCA\Polls\Migration\RepairSteps; use Doctrine\DBAL\Schema\Schema; diff --git a/lib/Migration/RepairSteps/CreateIndices.php b/lib/Migration/RepairSteps/CreateIndices.php index ac1f88744e..52c09c63df 100644 --- a/lib/Migration/RepairSteps/CreateIndices.php +++ b/lib/Migration/RepairSteps/CreateIndices.php @@ -6,7 +6,6 @@ * SPDX-License-Identifier: AGPL-3.0-or-later */ - namespace OCA\Polls\Migration\RepairSteps; use Doctrine\DBAL\Schema\Schema; diff --git a/lib/Migration/RepairSteps/CreateTables.php b/lib/Migration/RepairSteps/CreateTables.php index 22c789ebce..0406936dd8 100644 --- a/lib/Migration/RepairSteps/CreateTables.php +++ b/lib/Migration/RepairSteps/CreateTables.php @@ -6,7 +6,6 @@ * SPDX-License-Identifier: AGPL-3.0-or-later */ - namespace OCA\Polls\Migration\RepairSteps; use Doctrine\DBAL\Schema\Schema; diff --git a/lib/Migration/RepairSteps/DropOrphanedColumns.php b/lib/Migration/RepairSteps/DropOrphanedColumns.php index c2d65e8999..eecb1d284b 100644 --- a/lib/Migration/RepairSteps/DropOrphanedColumns.php +++ b/lib/Migration/RepairSteps/DropOrphanedColumns.php @@ -6,7 +6,6 @@ * SPDX-License-Identifier: AGPL-3.0-or-later */ - namespace OCA\Polls\Migration\RepairSteps; use Doctrine\DBAL\Schema\Schema; diff --git a/lib/Migration/RepairSteps/DropOrphanedTables.php b/lib/Migration/RepairSteps/DropOrphanedTables.php index e44fd26d9f..f82430cbf1 100644 --- a/lib/Migration/RepairSteps/DropOrphanedTables.php +++ b/lib/Migration/RepairSteps/DropOrphanedTables.php @@ -6,7 +6,6 @@ * SPDX-License-Identifier: AGPL-3.0-or-later */ - namespace OCA\Polls\Migration\RepairSteps; use Doctrine\DBAL\Schema\Schema; diff --git a/lib/Migration/RepairSteps/FixNullish.php b/lib/Migration/RepairSteps/FixNullish.php index 2622811603..238e4c41d2 100644 --- a/lib/Migration/RepairSteps/FixNullish.php +++ b/lib/Migration/RepairSteps/FixNullish.php @@ -6,7 +6,6 @@ * SPDX-License-Identifier: AGPL-3.0-or-later */ - namespace OCA\Polls\Migration\RepairSteps; use OCA\Polls\Db\V3\TableManager; diff --git a/lib/Migration/RepairSteps/Install.php b/lib/Migration/RepairSteps/Install.php index d10acc6598..438bf04175 100644 --- a/lib/Migration/RepairSteps/Install.php +++ b/lib/Migration/RepairSteps/Install.php @@ -6,7 +6,6 @@ * SPDX-License-Identifier: AGPL-3.0-or-later */ - namespace OCA\Polls\Migration\RepairSteps; use Doctrine\DBAL\Schema\Schema; diff --git a/lib/Migration/RepairSteps/SetLastInteraction.php b/lib/Migration/RepairSteps/SetLastInteraction.php index db6a3b0e0e..dbb531bab8 100644 --- a/lib/Migration/RepairSteps/SetLastInteraction.php +++ b/lib/Migration/RepairSteps/SetLastInteraction.php @@ -6,7 +6,6 @@ * SPDX-License-Identifier: AGPL-3.0-or-later */ - namespace OCA\Polls\Migration\RepairSteps; use OCA\Polls\Db\V3\TableManager; diff --git a/lib/Migration/RepairSteps/UpdateHashes.php b/lib/Migration/RepairSteps/UpdateHashes.php index 82e6170073..ad98e70dfb 100644 --- a/lib/Migration/RepairSteps/UpdateHashes.php +++ b/lib/Migration/RepairSteps/UpdateHashes.php @@ -6,7 +6,6 @@ * SPDX-License-Identifier: AGPL-3.0-or-later */ - namespace OCA\Polls\Migration\RepairSteps; use OCA\Polls\Db\V3\TableManager; diff --git a/lib/Migration/RepairSteps/UpdateInteraction.php b/lib/Migration/RepairSteps/UpdateInteraction.php index 52d70de646..5f6ee55ad1 100644 --- a/lib/Migration/RepairSteps/UpdateInteraction.php +++ b/lib/Migration/RepairSteps/UpdateInteraction.php @@ -6,7 +6,6 @@ * SPDX-License-Identifier: AGPL-3.0-or-later */ - namespace OCA\Polls\Migration\RepairSteps; use OCA\Polls\Db\V3\TableManager; diff --git a/lib/Model/Mail/ConfirmationMail.php b/lib/Model/Mail/ConfirmationMail.php index ad5d326ff7..7c4a2234fc 100644 --- a/lib/Model/Mail/ConfirmationMail.php +++ b/lib/Model/Mail/ConfirmationMail.php @@ -6,7 +6,6 @@ * SPDX-License-Identifier: AGPL-3.0-or-later */ - namespace OCA\Polls\Model\Mail; use OCA\Polls\AppConstants; diff --git a/lib/Model/Mail/InvitationMail.php b/lib/Model/Mail/InvitationMail.php index 674f60ec00..4a67fa1156 100644 --- a/lib/Model/Mail/InvitationMail.php +++ b/lib/Model/Mail/InvitationMail.php @@ -6,7 +6,6 @@ * SPDX-License-Identifier: AGPL-3.0-or-later */ - namespace OCA\Polls\Model\Mail; use OCA\Polls\AppConstants; diff --git a/lib/Model/Mail/MailBase.php b/lib/Model/Mail/MailBase.php index bddd2f828f..83edaab0d8 100644 --- a/lib/Model/Mail/MailBase.php +++ b/lib/Model/Mail/MailBase.php @@ -6,7 +6,6 @@ * SPDX-License-Identifier: AGPL-3.0-or-later */ - namespace OCA\Polls\Model\Mail; use League\CommonMark\Environment\Environment; diff --git a/lib/Model/Mail/NotificationMail.php b/lib/Model/Mail/NotificationMail.php index ccb7de5c9e..683a0ed924 100644 --- a/lib/Model/Mail/NotificationMail.php +++ b/lib/Model/Mail/NotificationMail.php @@ -6,7 +6,6 @@ * SPDX-License-Identifier: AGPL-3.0-or-later */ - namespace OCA\Polls\Model\Mail; use OCA\Polls\AppConstants; diff --git a/lib/Model/Mail/ReminderMail.php b/lib/Model/Mail/ReminderMail.php index 217a185069..dc56f0fab4 100644 --- a/lib/Model/Mail/ReminderMail.php +++ b/lib/Model/Mail/ReminderMail.php @@ -6,7 +6,6 @@ * SPDX-License-Identifier: AGPL-3.0-or-later */ - namespace OCA\Polls\Model\Mail; use DateTime; diff --git a/lib/Model/User/Contact.php b/lib/Model/User/Contact.php index 492cd489c1..7c8379c18c 100644 --- a/lib/Model/User/Contact.php +++ b/lib/Model/User/Contact.php @@ -113,8 +113,6 @@ public function getDescription(): string { return count($description) ? implode(', ', $description) : $this->l10n->t('Contact'); } - - public static function isEnabled(): bool { return Container::isAppEnabled('contacts'); } diff --git a/lib/Model/User/Email.php b/lib/Model/User/Email.php index ff74445a61..fb972b444b 100644 --- a/lib/Model/User/Email.php +++ b/lib/Model/User/Email.php @@ -6,7 +6,6 @@ * SPDX-License-Identifier: AGPL-3.0-or-later */ - namespace OCA\Polls\Model\User; use OCA\Polls\Model\UserBase; diff --git a/lib/Model/User/GenericUser.php b/lib/Model/User/GenericUser.php index 93462b94c4..dd41acf8e0 100644 --- a/lib/Model/User/GenericUser.php +++ b/lib/Model/User/GenericUser.php @@ -6,7 +6,6 @@ * SPDX-License-Identifier: AGPL-3.0-or-later */ - namespace OCA\Polls\Model\User; use OCA\Polls\Model\UserBase; diff --git a/lib/Model/UserBase.php b/lib/Model/UserBase.php index 00a1f8ef52..bfa5a82efc 100644 --- a/lib/Model/UserBase.php +++ b/lib/Model/UserBase.php @@ -485,5 +485,4 @@ public function getSafeType(): string { return $this->getType(); } - } diff --git a/lib/Notification/Notifier.php b/lib/Notification/Notifier.php index c7c8e78c29..45efb66ac5 100644 --- a/lib/Notification/Notifier.php +++ b/lib/Notification/Notifier.php @@ -113,7 +113,6 @@ public function prepare(INotification $notification, string $languageCode): INot $l = $this->l10nFactory->get(AppConstants::APP_ID, $languageCode); - $notification->setIcon( $this->urlGenerator->getAbsoluteURL( $this->urlGenerator->imagePath(AppConstants::APP_ID, 'polls-dark.svg') diff --git a/lib/Provider/ActivityProvider.php b/lib/Provider/ActivityProvider.php index 0e0b0184b4..dcf4500f94 100644 --- a/lib/Provider/ActivityProvider.php +++ b/lib/Provider/ActivityProvider.php @@ -84,7 +84,6 @@ protected function setSubjects(IEvent $event, string $subject): void { ]; } - $placeholders = $replacements = []; foreach ($parameters as $placeholder => $parameter) { $placeholders[] = '{' . $placeholder . '}'; diff --git a/lib/Provider/ReferenceProvider.php b/lib/Provider/ReferenceProvider.php index dfe27afe21..defeb9ba89 100644 --- a/lib/Provider/ReferenceProvider.php +++ b/lib/Provider/ReferenceProvider.php @@ -54,7 +54,6 @@ public function extractPollId(string $referenceText): int { return 0; } - /** * @inheritDoc */ @@ -65,7 +64,6 @@ public function resolveReference(string $referenceText): ?IReference { $expiry = 0; $participated = false; - if ($pollId) { try { $poll = $this->pollService->get($pollId); diff --git a/lib/Provider/SearchProvider.php b/lib/Provider/SearchProvider.php index eafb65814a..8a68163688 100644 --- a/lib/Provider/SearchProvider.php +++ b/lib/Provider/SearchProvider.php @@ -8,7 +8,6 @@ declare(strict_types=1); - namespace OCA\Polls\Provider; use OCA\Polls\AppConstants; diff --git a/lib/Service/ActivityService.php b/lib/Service/ActivityService.php index acbdf8bb44..9780a18bee 100644 --- a/lib/Service/ActivityService.php +++ b/lib/Service/ActivityService.php @@ -266,28 +266,24 @@ private function getMatchedMessages(): array { self::THIRD_PERSON_FULL => $this->l10n->t('{actor} has added a public share to poll {pollTitle}'), self::FIRST_PERSON_FILTERED => $this->l10n->t('You have added a public share'), self::THIRD_PERSON_FILTERED => $this->l10n->t('{actor} has added a public share'), - ], Share::TYPE_GROUP => [ self::FIRST_PERSON_FULL => $this->l10n->t('You have shared poll {pollTitle} with group {sharee}'), self::THIRD_PERSON_FULL => $this->l10n->t('{actor} has shared poll {pollTitle} with group {sharee}'), self::FIRST_PERSON_FILTERED => $this->l10n->t('You have shared this poll with group {sharee}'), self::THIRD_PERSON_FILTERED => $this->l10n->t('{actor} has shared this poll with group {sharee}'), - ], Share::TYPE_CIRCLE => [ self::FIRST_PERSON_FULL => $this->l10n->t('You have shared poll {pollTitle} with circle {sharee}'), self::THIRD_PERSON_FULL => $this->l10n->t('{actor} has shared poll {pollTitle} with circle {sharee}'), self::FIRST_PERSON_FILTERED => $this->l10n->t('You have shared this poll with circle {sharee}'), self::THIRD_PERSON_FILTERED => $this->l10n->t('{actor} has shared this poll with circle {sharee}'), - ], Share::TYPE_CONTACTGROUP => [ self::FIRST_PERSON_FULL => $this->l10n->t('You have shared poll {pollTitle} with contact group {sharee}'), self::THIRD_PERSON_FULL => $this->l10n->t('{actor} has shared poll {pollTitle} with contact group {sharee}'), self::FIRST_PERSON_FILTERED => $this->l10n->t('You have shared this poll with contact group {sharee}'), self::THIRD_PERSON_FILTERED => $this->l10n->t('{actor} has shared this poll with contact group {sharee}'), - ], default => [ self::FIRST_PERSON_FULL => $this->l10n->t('You have shared poll {pollTitle} with {sharee}'), diff --git a/lib/Service/CalendarService.php b/lib/Service/CalendarService.php index f3fcdb942d..848b16f718 100644 --- a/lib/Service/CalendarService.php +++ b/lib/Service/CalendarService.php @@ -6,7 +6,6 @@ * SPDX-License-Identifier: AGPL-3.0-or-later */ - namespace OCA\Polls\Service; use DateInterval; @@ -58,7 +57,6 @@ private function getCalendarsForPrincipal(): void { } } - /** * getTimerange - set timeranges to search within based on the option's time information * diff --git a/lib/Service/MailService.php b/lib/Service/MailService.php index 5dc49b944e..f4b11caa16 100644 --- a/lib/Service/MailService.php +++ b/lib/Service/MailService.php @@ -60,7 +60,6 @@ public function __construct( private static function isValidEmail(string $eMailAddress): bool { # Rely on PHP's filter return (bool)filter_var($eMailAddress, FILTER_VALIDATE_EMAIL); - // Alternative // return (bool) preg_match(self::REGEX_VALID_MAIL, $eMailAddress); } @@ -113,7 +112,6 @@ public static function extractEmailAddressAndName($eMailString): array { } throw new NoEmailAddress($eMailString); - } public static function parseEmailStrings(array $emailArray): array { diff --git a/lib/Service/NotificationService.php b/lib/Service/NotificationService.php index 4e69b8d49b..13e71d5d69 100644 --- a/lib/Service/NotificationService.php +++ b/lib/Service/NotificationService.php @@ -27,7 +27,6 @@ public function removeNotification(INotification $notification): void { $this->notificationManager->markProcessed($notification); } - /** * Remove all notifications for a specific poll and the current user. * diff --git a/lib/Service/OptionService.php b/lib/Service/OptionService.php index 330056d0ae..316330bec0 100644 --- a/lib/Service/OptionService.php +++ b/lib/Service/OptionService.php @@ -84,7 +84,6 @@ public function addWithSequenceAndAutoVote( $newOption = $this->add($pollId, $option, $voteYes); - if ($sequence) { $repetitions = $this->sequence($newOption, $sequence, $voteYes); } else { @@ -143,7 +142,6 @@ public function add(int $pollId, SimpleOption $simpleOption, bool $voteYes = fal } } - if ($voteYes) { // Set the vote for the new option on request $this->voteService->set($newOption, Vote::VOTE_YES); diff --git a/lib/Service/PollService.php b/lib/Service/PollService.php index d3975b2880..9675b9e45d 100644 --- a/lib/Service/PollService.php +++ b/lib/Service/PollService.php @@ -24,6 +24,7 @@ use OCA\Polls\Exceptions\EmptyTitleException; use OCA\Polls\Exceptions\ForbiddenException; use OCA\Polls\Exceptions\InvalidAccessException; +use OCA\Polls\Exceptions\InvalidPollListParameterException; use OCA\Polls\Exceptions\InvalidPollTypeException; use OCA\Polls\Exceptions\InvalidShowResultsException; use OCA\Polls\Exceptions\InvalidUsernameException; @@ -34,14 +35,46 @@ use OCA\Polls\UserSession; use OCP\AppFramework\Db\DoesNotExistException; use OCP\EventDispatcher\IEventDispatcher; +use OCP\IUserManager; use OCP\Search\ISearchQuery; class PollService { + public const SORT_CREATED = 'created'; + public const SORT_TITLE = 'title'; + public const SORT_ACCESS = 'access'; + public const SORT_OWNER = 'owner'; + public const SORT_EXPIRE = 'expire'; + public const SORT_INTERACTION = 'interaction'; + public const SORT_COLUMNS = [ + self::SORT_CREATED, + self::SORT_TITLE, + self::SORT_ACCESS, + self::SORT_OWNER, + self::SORT_EXPIRE, + self::SORT_INTERACTION, + ]; + public const SORT_ASCENDING = 'asc'; + public const SORT_DESCENDING = 'desc'; + public const SORT_DIRECTIONS = [ + self::SORT_ASCENDING, + self::SORT_DESCENDING, + ]; + public const MAX_PAGE_SIZE = 100; + /** + * Sort columns, which the database can sort equally to sortPolls() + * Title, access and owner are sorted by their evaluated value in PHP + */ + private const SORT_SQL_COLUMNS = [ + self::SORT_CREATED => 'created', + self::SORT_EXPIRE => 'expire', + self::SORT_INTERACTION => 'last_interaction', + ]; /** @psalm-suppress PossiblyUnusedMethod */ public function __construct( private AppSettings $appSettings, private IEventDispatcher $eventDispatcher, + private IUserManager $userManager, private Poll $poll, private PollMapper $pollMapper, private UserMapper $userMapper, @@ -51,10 +84,16 @@ public function __construct( } /** - * Get list of polls including Threshold for "relevant polls" + * Get the polls, the current user has access to + * The optional filters narrow down the polls already in the database query + * + * @param string|null $category see PollMapper::findForMe() + * @param int|null $pollGroupId see PollMapper::findForMe() + * @param string|null $type see PollMapper::findForMe() + * @return Poll[] */ - public function listPolls(): array { - $pollList = $this->pollMapper->findForMe($this->userSession->getCurrentUserId()); + public function listPolls(?string $category = null, ?int $pollGroupId = null, ?string $type = null): array { + $pollList = $this->pollMapper->findForMe($this->userSession->getCurrentUserId(), $category, $pollGroupId, $type); if ($this->userSession->getCurrentUser()->getIsAdmin()) { return $pollList; } @@ -64,6 +103,163 @@ public function listPolls(): array { })); } + /** + * Get one page of the current user's polls, filtered by category or poll group + * + * @param string $category one of Poll::CATEGORIES, ignored if $pollGroupId is set + * @param int|null $pollGroupId limit the list to the polls of this poll group + * @param string|null $type limit the list to Poll::TYPE_DATE or Poll::TYPE_TEXT + * @param string $sortBy one of self::SORT_COLUMNS + * @param string $sortDirection one of self::SORT_DIRECTIONS + * @return array{polls: Poll[], total: int} + * @throws InvalidPollListParameterException + */ + public function listPollsPaged( + string $category, + ?int $pollGroupId, + ?string $type, + string $sortBy, + string $sortDirection, + int $offset, + int $limit, + ): array { + if ($pollGroupId === null && !in_array($category, Poll::CATEGORIES, true)) { + throw new InvalidPollListParameterException('Invalid category ' . $category); + } + if ($type !== null && !in_array($type, [Poll::TYPE_DATE, Poll::TYPE_TEXT], true)) { + throw new InvalidPollListParameterException('Invalid poll type ' . $type); + } + if (!in_array($sortBy, self::SORT_COLUMNS, true)) { + throw new InvalidPollListParameterException('Invalid sort column ' . $sortBy); + } + if (!in_array($sortDirection, self::SORT_DIRECTIONS, true)) { + throw new InvalidPollListParameterException('Invalid sort direction ' . $sortDirection); + } + if ($offset < 0 || $limit < 1 || $limit > self::MAX_PAGE_SIZE) { + throw new InvalidPollListParameterException('Invalid offset or limit'); + } + + $descending = $sortDirection === self::SORT_DESCENDING; + + // the database can do the paging, if it filters the category exactly and + // sorts like sortPolls() would + if ($pollGroupId === null + && $this->hasExactCategoryCondition($category) + && isset(self::SORT_SQL_COLUMNS[$sortBy]) + ) { + $userId = $this->userSession->getCurrentUserId(); + return [ + 'polls' => $this->pollMapper->findPageForMe( + $userId, + $category, + $type, + self::SORT_SQL_COLUMNS[$sortBy], + $descending, + $offset, + $limit, + ), + 'total' => $this->pollMapper->countForMe($userId, $category, null, $type), + ]; + } + + // type and poll group are filtered exactly by the query, + // the category query is only a superset of the permission based categories + $polls = $this->listPolls($category, $pollGroupId, $type); + if ($pollGroupId === null) { + $polls = array_filter( + $polls, + fn (Poll $poll): bool => in_array($category, $poll->getCategories(), true), + ); + } + $polls = $this->sortPolls($polls, $sortBy, $descending); + + return [ + 'polls' => array_slice($polls, $offset, $limit), + 'total' => count($polls), + ]; + } + + /** + * Whether PollMapper::getCategoryCondition() matches Poll::getCategories() + * exactly for this category, so the entity check can be skipped + * + * All other categories depend on permissions, which are only evaluated by + * the entity, like group memberships or locked shares. + */ + private function hasExactCategoryCondition(string $category): bool { + return match ($category) { + // the owner always has access to their own polls, archived polls are + // limited to the current user's polls by the query anyway + Poll::CATEGORY_MY, Poll::CATEGORY_ARCHIVED => true, + // open polls are only accessible for logged in users + Poll::CATEGORY_OPEN => $this->userSession->getIsLoggedIn(), + default => false, + }; + } + + /** + * Count the polls per category and per poll group without serializing any poll + * + * The polls still have to be loaded, because the categories depend on + * permissions, which are evaluated by the entity. + * + * @return array{counts: array, pollGroupCounts: array} + */ + public function getPollListCounts(): array { + $counts = array_fill_keys(Poll::CATEGORIES, 0); + $pollGroupCounts = []; + + foreach ($this->listPolls() as $poll) { + foreach ($poll->getCategories() as $category) { + $counts[$category]++; + } + foreach ($poll->getPollGroups() as $pollGroupId) { + $pollGroupCounts[$pollGroupId] = ($pollGroupCounts[$pollGroupId] ?? 0) + 1; + } + } + + return [ + 'counts' => $counts, + 'pollGroupCounts' => $pollGroupCounts, + ]; + } + + /** + * Sort the polls deterministically, ties are broken by the poll id + * + * @param Poll[] $polls + * @return Poll[] + */ + private function sortPolls(array $polls, string $sortBy, bool $descending): array { + $displayNames = []; + $sortValue = match ($sortBy) { + self::SORT_TITLE => fn (Poll $poll): string => $poll->getTitle(), + self::SORT_ACCESS => fn (Poll $poll): string => $poll->getAccess(), + self::SORT_OWNER => function (Poll $poll) use (&$displayNames): string { + $owner = (string)$poll->getOwner(); + return $displayNames[$owner] ??= $this->userManager->getDisplayName($owner) ?? $owner; + }, + self::SORT_EXPIRE => fn (Poll $poll): int => $poll->getExpire(), + self::SORT_INTERACTION => fn (Poll $poll): int => $poll->getLastInteraction(), + default => fn (Poll $poll): int => $poll->getCreated(), + }; + + $keyed = array_map(fn (Poll $poll): array => [$sortValue($poll), $poll], array_values($polls)); + $direction = $descending ? -1 : 1; + usort($keyed, function (array $a, array $b) use ($direction): int { + $compare = is_string($a[0]) + ? strnatcasecmp($a[0], $b[0]) + : $a[0] <=> $b[0]; + + // the database order of ties is undefined, so break them by id like + // PollMapper::findPageForMe() does, otherwise paging over equal sort + // values could skip or repeat polls + return $direction * ($compare !== 0 ? $compare : $a[1]->getId() <=> $b[1]->getId()); + }); + + return array_column($keyed, 1); + } + /** * Get list of polls */ @@ -326,7 +522,6 @@ public function setLastInteraction(int $pollId): void { } } - /** * Move to archive or restore * @return Poll diff --git a/lib/Service/PreferencesService.php b/lib/Service/PreferencesService.php index c5fb0509f4..6ba97d5491 100644 --- a/lib/Service/PreferencesService.php +++ b/lib/Service/PreferencesService.php @@ -58,11 +58,9 @@ public function write(array $preferences): Preferences { return $this->preferencesMapper->update($this->preferences); } else { return $this->preferencesMapper->insert($this->preferences); - } } - /** * Tidy preferences * @param array $preferences diff --git a/lib/Service/ShareService.php b/lib/Service/ShareService.php index 0e54fb0eea..07e02ac916 100644 --- a/lib/Service/ShareService.php +++ b/lib/Service/ShareService.php @@ -164,7 +164,6 @@ public function request(string $token): Share { $this->share->setDisplayName(''); } - if ($poll->getIsInvolved()) { // user is already involved in the poll if ($poll->getShareToken()) { @@ -175,7 +174,6 @@ public function request(string $token): Share { return $this->share; } - if ($this->share->getType() === Share::TYPE_PUBLIC) { // Exception: logged in user, accesses the poll via public share link if ($this->userSession->getIsLoggedIn()) { @@ -184,7 +182,6 @@ public function request(string $token): Share { return $this->share; } - // Exception for convertable (email and contact) shares if (in_array($this->share->getType(), Share::CONVERATABLE_PUBLIC_SHARES, true)) { return $this->convertPersonalPublicShareToExternalShare(); @@ -355,7 +352,6 @@ private function convertPersonalPublicShareToExternalShare( } $this->share->setEmailAddress($emailAddress ?? $this->share->getEmailAddress()); - // convert to type external $this->share->setType(Share::TYPE_EXTERNAL); @@ -528,7 +524,6 @@ public function sendAllInvitations(int $pollId): ?SentResult { public function resolveGroupByToken(string $token): array { $share = $this->get($token); return $this->resolveGroup($share); - } /** diff --git a/lib/Types.php b/lib/Types.php index ed51861fbf..513fb69fe8 100644 --- a/lib/Types.php +++ b/lib/Types.php @@ -5,6 +5,7 @@ * SPDX-FileCopyrightText: 2025 Nextcloud contributors * SPDX-License-Identifier: AGPL-3.0-or-later */ + namespace OCA\Polls; /** diff --git a/package-lock.json b/package-lock.json index b94fd02c31..fe2ea39576 100644 --- a/package-lock.json +++ b/package-lock.json @@ -32,6 +32,7 @@ "marked-gfm-heading-id": "^4.1.0", "pinia": "^3.0.3", "qrcode": "^1.5.3", + "sortablejs": "^1.15.7", "vue": "^3.5.17", "vue-material-design-icons": "^5.3.0", "vue-router": "^4.5.1", @@ -11790,12 +11791,10 @@ } }, "node_modules/sortablejs": { - "version": "1.15.6", - "resolved": "https://registry.npmjs.org/sortablejs/-/sortablejs-1.15.6.tgz", - "integrity": "sha512-aNfiuwMEpfBM/CN6LY0ibyhxPfPbyFeBTYJKCvzkJ2GkUpazIt3H+QIPAMHwqQ7tMKaHz1Qj+rJJCqljnf4p3A==", - "license": "MIT", - "optional": true, - "peer": true + "version": "1.15.7", + "resolved": "https://registry.npmjs.org/sortablejs/-/sortablejs-1.15.7.tgz", + "integrity": "sha512-Kk8wLQPlS+yi1ZEf48a4+fzHa4yxjC30M/Sr2AnQu+f/MPwvvX9XjZ6OWejiz8crBsLwSq8GHqaxaET7u6ux0A==", + "license": "MIT" }, "node_modules/source-map": { "version": "0.6.1", diff --git a/package.json b/package.json index bd4279d2d1..52802084ae 100644 --- a/package.json +++ b/package.json @@ -54,6 +54,7 @@ "marked-gfm-heading-id": "^4.1.0", "pinia": "^3.0.3", "qrcode": "^1.5.3", + "sortablejs": "^1.15.7", "vue": "^3.5.17", "vue-material-design-icons": "^5.3.0", "vue-router": "^4.5.1", diff --git a/src/Api/modules/polls.ts b/src/Api/modules/polls.ts index 6bb8e09275..6ba8baa38e 100644 --- a/src/Api/modules/polls.ts +++ b/src/Api/modules/polls.ts @@ -8,6 +8,7 @@ import type { AxiosResponse } from '@nextcloud/axios' import type { ApiEmailAdressList, FullPollResponse } from './api.types' import type { PollGroup } from '../../stores/pollGroups.types' import type { Poll, PollConfiguration, PollType } from '../../stores/poll.types' +import type { PollListMeta, PollListQuery } from '../../stores/polls.types' export type Confirmations = { sentMails: { emailAddress: string; displayName: string }[] @@ -17,20 +18,13 @@ export type Confirmations = { } const polls = { - getPolls(): Promise< - AxiosResponse<{ - polls: Poll[] - permissions: { - pollCreationAllowed: boolean - comboAllowed: true - } - pollGroups: PollGroup[] - }> - > { + getPolls( + query: PollListQuery, + ): Promise> { return httpInstance.request({ method: 'GET', url: 'polls', - params: { time: +new Date() }, + params: { ...query, time: +new Date() }, cancelToken: cancelTokenHandlerObject[ this.getPolls.name @@ -38,6 +32,55 @@ const polls = { }) }, + // no cancel token, several navigation entries may load at the same time + getNavigationPolls( + query: PollListQuery, + ): Promise> { + return httpInstance.request({ + method: 'GET', + url: 'polls', + params: { ...query, time: +new Date() }, + }) + }, + + // separate method for an own cancel token, independent of the main list + getDatePolls( + offset: number, + limit: number, + ): Promise> { + return httpInstance.request({ + method: 'GET', + url: 'polls', + params: { + category: 'all', + type: 'datePoll', + sortBy: 'created', + sortDirection: 'desc', + offset, + limit, + time: +new Date(), + }, + cancelToken: + cancelTokenHandlerObject[ + this.getDatePolls.name + ].handleRequestCancellation().token, + }) + }, + + getPollsMeta(): Promise< + AxiosResponse + > { + return httpInstance.request({ + method: 'GET', + url: 'polls/meta', + params: { time: +new Date() }, + cancelToken: + cancelTokenHandlerObject[ + this.getPollsMeta.name + ].handleRequestCancellation().token, + }) + }, + getPoll(pollId: number): Promise> { return httpInstance.request({ method: 'GET', diff --git a/src/components/PollList/PollItemActions.vue b/src/components/PollList/PollItemActions.vue index 807743d494..3abb412400 100644 --- a/src/components/PollList/PollItemActions.vue +++ b/src/components/PollList/PollItemActions.vue @@ -106,10 +106,14 @@ async function removePollFromGroup(pollId: number, pollGroupId: number) { async function addPollToPollGroup(pollId: number, pollGroupId: number) { subMenu.value = null - pollGroupsStore.addPollToPollGroup({ - pollId, - pollGroupId, - }) + try { + await pollGroupsStore.addPollToPollGroup({ + pollId, + pollGroupId, + }) + } catch { + showError(t('polls', 'Error adding poll to group.')) + } } async function addPollToNewPollGroup(pollId: number) { diff --git a/src/components/SideBar/SideBarTabDatePolls.vue b/src/components/SideBar/SideBarTabDatePolls.vue index f733d869d7..3bb7e19669 100644 --- a/src/components/SideBar/SideBarTabDatePolls.vue +++ b/src/components/SideBar/SideBarTabDatePolls.vue @@ -4,6 +4,13 @@ --> diff --git a/src/stores/combo.ts b/src/stores/combo.ts index 20c07969a1..9d07a2536f 100644 --- a/src/stores/combo.ts +++ b/src/stores/combo.ts @@ -10,7 +10,6 @@ import { VotesAPI, OptionsAPI, PollsAPI } from '../Api' import { Logger, uniqueOptions, uniqueParticipants } from '../helpers' import { usePreferencesStore } from './preferences' -import { usePollsStore } from './polls' import type { AxiosError } from '@nextcloud/axios' import type { Participant } from '../Types' @@ -114,20 +113,6 @@ export const useComboStore = defineStore('combo', { }) }, - async cleanUp() { - const pollsStore = usePollsStore() - this.polls.forEach((comboPoll: Poll) => { - if ( - pollsStore.polls.findIndex( - (poll) => - poll.id === comboPoll.id && !poll.status.isArchived, - ) < 0 - ) { - this.removePoll({ pollId: comboPoll.id }) - } - }) - }, - async togglePollItem(pollId: number) { if (this.pollIsListed(pollId)) { this.remove(pollId) diff --git a/src/stores/pollGroups.ts b/src/stores/pollGroups.ts index 1be3c1ee36..d1135802b7 100644 --- a/src/stores/pollGroups.ts +++ b/src/stores/pollGroups.ts @@ -40,44 +40,20 @@ export const usePollGroupsStore = defineStore('pollGroups', () => { /** * Sort poll groups by title in ascending order + * Groups without accessible polls are hidden * @return {PollGroup[]} Sorted poll groups, sorted by title in ascending order */ - const pollGroupsSorted = computed((): PollGroup[] => - orderBy( + const pollGroupsSorted = computed((): PollGroup[] => { + const pollsStore = usePollsStore() + return orderBy( pollGroups.value.filter( - (group) => countPollsInPollGroups.value[group.id] > 0, + (group) => (pollsStore.listMeta.pollGroupCounts[group.id] ?? 0) > 0, ), ['title'], ['asc'], - ), - ) - - const pollsInCurrendPollGroup = computed((): Poll[] => { - const pollsStore = usePollsStore() - if (!currentPollGroup.value) { - return [] - } - return pollsStore.polls.filter((poll) => - currentPollGroup.value?.pollIds.includes(poll.id), ) }) - /** - * Count of polls in each poll group and return pollgroupid and count as list - * with the pollgroupid as key and the count as value - * @return {Record} An object where the keys are poll group IDs and the values are the counts of polls in those groups - */ - const countPollsInPollGroups = computed((): Record => { - const counts: Record = {} - const pollsStore = usePollsStore() - pollGroups.value.forEach((group) => { - counts[group.id] = pollsStore.polls.filter((poll) => - group.pollIds.includes(poll.id), - ).length - }) - return counts - }) - /** * Returns a list of poll groups the poll can be added to. * @@ -156,6 +132,7 @@ export const usePollGroupsStore = defineStore('pollGroups', () => { groupTitle?: string }) { const pollsStore = usePollsStore() + let poll: Poll try { const response = await PollGroupsAPI.addPollToGroup( @@ -164,7 +141,7 @@ export const usePollGroupsStore = defineStore('pollGroups', () => { payload.groupTitle, ) addOrUpdatePollGroupInList({ pollGroup: response.data.pollGroup }) - pollsStore.addOrUpdatePollGroupInList({ poll: response.data.poll }) + poll = response.data.poll } catch (error) { if ((error as AxiosError)?.code === 'ERR_CANCELED') { return @@ -173,16 +150,22 @@ export const usePollGroupsStore = defineStore('pollGroups', () => { error, payload, }) - pollsStore.load() + // resync the lists, the refresh must not mask the error of the write + await pollsStore.load().catch(() => undefined) throw error } + + await refreshPollLists( + poll, + 'Error refreshing the polls after adding a poll to a group', + ) } async function removePollFromGroup(payload: { pollGroupId: number pollId: number }): Promise { - const pollsStore = usePollsStore() + let poll: Poll try { const response = await PollGroupsAPI.removePollFromGroup( @@ -190,28 +173,49 @@ export const usePollGroupsStore = defineStore('pollGroups', () => { payload.pollId, ) - // update poll in the polls store - pollsStore.addOrUpdatePollGroupInList({ poll: response.data.poll }) - if (response.data.pollGroup === null) { // If the poll group was removed (=== null), remove it from the store pollGroups.value = pollGroups.value.filter( (group) => group.id !== payload.pollGroupId, ) - return + } else { + // Otherwise, update the poll group in the store + addOrUpdatePollGroupInList({ pollGroup: response.data.pollGroup }) } - // Otherwise, update the poll group in the store - addOrUpdatePollGroupInList({ pollGroup: response.data.pollGroup }) + poll = response.data.poll } catch (error) { - if ((error as AxiosError)?.code !== 'ERR_CANCELED') { - Logger.error('Error removing poll from group', { - error, - payload, - }) - throw error + if ((error as AxiosError)?.code === 'ERR_CANCELED') { + return } - } finally { - // pollsStore.load() + Logger.error('Error removing poll from group', { + error, + payload, + }) + throw error + } + + await refreshPollLists( + poll, + 'Error refreshing the polls after removing a poll from a group', + ) + } + + /** + * Update the poll in the polls store and refresh the counts and lists + * + * Only called after the write succeeded, so a failing refresh is logged + * instead of being reported to the caller as a failed write + * + * @param poll the poll as returned by the write + * @param message log message of a failing refresh + */ + async function refreshPollLists(poll: Poll, message: string): Promise { + const pollsStore = usePollsStore() + + try { + await pollsStore.addOrUpdatePollGroupInList({ poll }) + } catch (error) { + Logger.error(message, { error }) } } @@ -227,9 +231,7 @@ export const usePollGroupsStore = defineStore('pollGroups', () => { pollGroups, updating, pollGroupsSorted, - countPollsInPollGroups, currentPollGroup, - pollsInCurrendPollGroup, addablePollGroups, setCurrentPollGroup, setPollGroupElement: addOrUpdatePollGroupInList, diff --git a/src/stores/polls.ts b/src/stores/polls.ts index be7b1ebf28..42976361e4 100644 --- a/src/stores/polls.ts +++ b/src/stores/polls.ts @@ -4,8 +4,7 @@ */ import { defineStore } from 'pinia' -import orderBy from 'lodash/orderBy' -import { DateTime } from 'luxon' +import { toRaw } from 'vue' import { t } from '@nextcloud/l10n' import { Logger } from '../helpers' @@ -17,21 +16,21 @@ import { usePollGroupsStore } from './pollGroups' import type { AxiosError } from '@nextcloud/axios' import type { Poll } from './poll.types' import type { + FilterType, + PaginatedPolls, PollCategory, PollCategoryList, + PollListQuery, PollsStore, - FilterType, SortType, } from './polls.types' -export const sortColumnsMapping: { [key in SortType]: string } = { - created: 'status.created', - title: 'configuration.title', - access: 'configuration.access', - owner: 'owner.displayName', - expire: 'configuration.expire', - interaction: 'status.lastInteraction', -} +// Must match PollService::MAX_PAGE_SIZE +const MAX_PAGE_SIZE = 100 +const DASHBOARD_POLLS = 7 + +// running meta request, shared by callers that do not force a reload +let metaRequest: Promise | null = null export const sortTitlesMapping: { [key in SortType]: string } = { created: t('polls', 'Created'), @@ -53,12 +52,6 @@ const pollCategories: PollCategoryList = { ), pinned: false, showInNavigation: () => true, - filterCondition: (poll: Poll) => - !poll.status.isArchived - && DateTime.fromSeconds(poll.status.relevantThreshold).diffNow('days') - .days > -100 - && (poll.currentUserStatus.isInvolved - || (poll.permissions.view && poll.configuration.access !== 'open')), }, my: { id: 'my', @@ -70,8 +63,6 @@ const pollCategories: PollCategoryList = { const sessionStore = useSessionStore() return sessionStore.appPermissions.pollCreation }, - filterCondition: (poll: Poll) => - !poll.status.isArchived && poll.currentUserStatus.isOwner, }, private: { id: 'private', @@ -83,10 +74,6 @@ const pollCategories: PollCategoryList = { const sessionStore = useSessionStore() return sessionStore.appPermissions.pollCreation }, - filterCondition: (poll: Poll) => - !poll.status.isArchived - && poll.permissions.view - && poll.configuration.access === 'private', }, participated: { id: 'participated', @@ -95,8 +82,6 @@ const pollCategories: PollCategoryList = { description: t('polls', 'All polls in which you participated.'), pinned: false, showInNavigation: () => true, - filterCondition: (poll: Poll) => - !poll.status.isArchived && poll.currentUserStatus.countVotes > 0, }, open: { id: 'open', @@ -111,8 +96,6 @@ const pollCategories: PollCategoryList = { const sessionStore = useSessionStore() return sessionStore.appPermissions.pollCreation }, - filterCondition: (poll: Poll) => - !poll.status.isArchived && poll.configuration.access === 'open', }, all: { id: 'all', @@ -121,8 +104,6 @@ const pollCategories: PollCategoryList = { description: t('polls', 'All polls, where you have access to.'), pinned: false, showInNavigation: () => true, - filterCondition: (poll: Poll) => - !poll.status.isArchived && poll.permissions.view, }, closed: { id: 'closed', @@ -131,10 +112,6 @@ const pollCategories: PollCategoryList = { description: t('polls', 'All closed polls, where voting is disabled.'), pinned: false, showInNavigation: () => true, - filterCondition: (poll: Poll) => - !poll.status.isArchived - && poll.status.isExpired - && poll.permissions.view, }, archived: { id: 'archived', @@ -146,8 +123,6 @@ const pollCategories: PollCategoryList = { const sessionStore = useSessionStore() return sessionStore.appPermissions.pollCreation }, - filterCondition: (poll: Poll) => - poll.status.isArchived && poll.permissions.view, }, admin: { id: 'admin', @@ -162,31 +137,109 @@ const pollCategories: PollCategoryList = { const sessionStore = useSessionStore() return !!sessionStore.currentUser?.isAdmin }, - filterCondition: (poll: Poll) => { - const sessionStore = useSessionStore() - return sessionStore.currentUser.id !== poll.owner.id - }, }, } +// newest load per paginated list, superseded loads must not write their result +const loadGenerations = new WeakMap() + +/** + * Fetch polls into a paginated list + * + * A limit bigger than MAX_PAGE_SIZE is requested in consecutive pages, so + * reloading a list, which was scrolled beyond the maximum page size, keeps + * all polls, which were loaded before. + * + * A newer load into the same list supersedes a running one, which then stops + * requesting pages and discards its result. The API methods share one cancel + * token per endpoint, so without this the next page of an outdated load would + * cancel the request of the newer one. + * + * @param target list to fill + * @param fetchPage request for one page, must not read mutable store state + * @param limit number of polls to load + * @param append append to the loaded polls instead of replacing them + */ +async function fetchInto( + target: PaginatedPolls, + fetchPage: ( + offset: number, + limit: number, + ) => Promise<{ data: { polls: Poll[]; total: number } }>, + limit: number, + append: boolean, +): Promise { + const rawTarget = toRaw(target) + const generation = (loadGenerations.get(rawTarget) ?? 0) + 1 + loadGenerations.set(rawTarget, generation) + + const offset = append ? target.polls.length : 0 + target.status = 'loading' + try { + const polls: Poll[] = [] + let total = 0 + + do { + const response = await fetchPage( + offset + polls.length, + Math.min(limit - polls.length, MAX_PAGE_SIZE), + ) + if (loadGenerations.get(rawTarget) !== generation) { + // a newer load took over this list + return + } + total = response.data.total + if (response.data.polls.length === 0) { + // the list shrunk since the first page was requested + break + } + polls.push(...response.data.polls) + } while (polls.length < limit && offset + polls.length < total) + + target.polls = append ? target.polls.concat(polls) : polls + target.total = total + target.status = 'loaded' + } catch (error) { + if ( + (error as AxiosError)?.code === 'ERR_CANCELED' + || loadGenerations.get(rawTarget) !== generation + ) { + return + } + target.status = 'error' + Logger.error('Error loading polls', { error }) + throw error + } +} + export const usePollsStore = defineStore('polls', { state: (): PollsStore => ({ - polls: [], - meta: { - chunks: { - size: 20, - loaded: 1, + list: { polls: [], total: 0, status: '' }, + datePolls: { polls: [], total: 0, status: '' }, + listMeta: { + counts: { + relevant: 0, + my: 0, + private: 0, + participated: 0, + open: 0, + all: 0, + closed: 0, + archived: 0, + admin: 0, }, - maxPollsInNavigation: 6, + pollGroupCounts: {}, status: '', }, + navigationPolls: {}, + meta: { + pageSize: 20, + maxPollsInNavigation: 6, + }, sort: { by: 'created', reverse: true, }, - status: { - loadingGroups: false, - }, categories: pollCategories, }), @@ -198,18 +251,12 @@ export const usePollsStore = defineStore('polls', { }, /* - * Sliced filtered and sorted polls for navigation + * Newest polls of a category or poll group, loaded on expanding the navigation entry */ navigationList: (state: PollsStore) => - (filterId: FilterType): Poll[] => - orderBy( - state.polls.filter((poll: Poll) => - state.categories[filterId].filterCondition(poll), - ) ?? [], - ['created'], - ['desc'], - ).slice(0, state.meta.maxPollsInNavigation), + (key: FilterType | number): Poll[] => + state.navigationPolls[key] ?? [], currentCategory(state: PollsStore): PollCategory { const sessionStore = useSessionStore() @@ -223,143 +270,260 @@ export const usePollsStore = defineStore('polls', { return state.categories.relevant }, + pollsCount(state: PollsStore): Record { + return state.listMeta.counts + }, + /* - * polls list, filtered by current category and sorted + * Server side filter for the current route (category or poll group) */ - pollsFilteredSorted(state: PollsStore): Poll[] { + listFilter(): Pick { const sessionStore = useSessionStore() const pollGroupsStore = usePollGroupsStore() - // if we are in a group route, return the polls of the current group if (sessionStore.route.name === 'group') { - return pollGroupsStore.pollsInCurrendPollGroup + // -1 matches no poll group, if the slug is unknown + return { pollGroup: pollGroupsStore.currentPollGroup?.id ?? -1 } } - - return orderBy( - state.polls.filter((poll: Poll) => - this.currentCategory?.filterCondition(poll), - ) ?? [], - [sortColumnsMapping[state.sort.by]], - [state.sort.reverse ? 'desc' : 'asc'], - ) + return { category: this.currentCategory.id } }, /* - * Chunked filtered and sorted polls for main view + * Server side filter and sorting of the current route */ - chunkedList(): Poll[] { - return this.pollsFilteredSorted.slice(0, this.loaded) - }, - - pollsCount(state: PollsStore): { [key: string]: number } { - const count: Record = {} as Record< - FilterType, - number - > - - for (const [key, category] of Object.entries(state.categories)) { - count[key as FilterType] = state.polls.filter((poll: Poll) => - category.filterCondition(poll), - ).length + listQuery(): Omit { + return { + ...this.listFilter, + sortBy: this.sort.by, + sortDirection: this.sort.reverse ? 'desc' : 'asc', } - - return count }, - /* - * Sliced filtered and sorted polls for dashboard - */ dashboardList(state: PollsStore): Poll[] { - return orderBy( - state.polls.filter((poll: Poll) => - state.categories.relevant.filterCondition(poll), - ), - ['created'], - ['desc'], - ).slice(0, 7) + return state.list.polls }, - loaded(state: PollsStore): number { - return state.meta.chunks.loaded * state.meta.chunks.size + pollsLoading(state: PollsStore): boolean { + return state.list.status === 'loading' }, - datePolls(state: PollsStore): Poll[] { - return state.polls.filter( - (poll: Poll) => poll.type === 'datePoll' && !poll.status.isArchived, - ) + hasMore(state: PollsStore): boolean { + return state.list.polls.length < state.list.total }, - pollsLoading(state): boolean { - return state.meta.status === 'loading' + hasMoreDatePolls(state: PollsStore): boolean { + return state.datePolls.polls.length < state.datePolls.total }, - - countByCategory: (state: PollsStore) => (filterId: FilterType) => - state.polls.filter((poll: Poll) => - state.categories[filterId].filterCondition(poll), - ).length, }, actions: { /** - * Load all polls and poll groups from the API. - * This will set the `polls` and `pollGroups` state properties. + * Load the poll counts and the poll groups for the navigation. + * Does not load the polls lists. * - * This will also set the `meta.status` to `Loading` while the request is in progress, - * and to `Loaded` or `Error` when the request is finished. + * @param {boolean} forced - If false, reuse loaded data or a running request + */ + async loadMeta(forced: boolean = true): Promise { + if (!forced && (metaRequest || this.listMeta.status === 'loaded')) { + return metaRequest ?? undefined + } + + const pollGroupsStore = usePollGroupsStore() + this.listMeta.status = 'loading' + + const request = (async () => { + try { + const response = await PollsAPI.getPollsMeta() + this.listMeta = { + counts: response.data.counts, + // php returns empty maps as arrays + pollGroupCounts: { ...response.data.pollGroupCounts }, + status: 'loaded', + } + pollGroupsStore.pollGroups = response.data.pollGroups + } catch (error) { + if ((error as AxiosError)?.code === 'ERR_CANCELED') { + return + } + this.listMeta.status = 'error' + Logger.error('Error loading poll list meta data', { error }) + throw error + } + })() + + metaRequest = request + try { + await request + } finally { + if (metaRequest === request) { + metaRequest = null + } + } + }, + + /** + * Load the first page of the current list (category or poll group) + * Previously loaded polls are replaced. * - * @param {boolean} forced - If false, loading polls will only be done, when the status is not `Loaded`. - * @throws {Error} If the request fails and is not canceled. - * @return {Promise} + * @param {number} limit - Number of polls to load */ - async load(forced: boolean = true): Promise { + async loadList(limit?: number): Promise { + const sessionStore = useSessionStore() const pollGroupsStore = usePollGroupsStore() + // poll groups are needed to resolve the slug of the group route if ( - this.meta.status === 'loading' - || (!forced && this.meta.status === 'loaded') + sessionStore.route.name === 'group' + && !pollGroupsStore.currentPollGroup ) { - Logger.debug('Polls already loaded or loading, skipping load', { - status: this.meta.status, - forced, - }) + const metaWasLoaded = this.listMeta.status === 'loaded' + await this.loadMeta(false) + + // the slug is not part of the cached poll groups, e.g. because the + // group was created in another session, so refresh them once + if (metaWasLoaded && !pollGroupsStore.currentPollGroup) { + await this.loadMeta(true) + } + } + + // pinned, so every page of this load uses the same filter and sorting, + // even if the route or the sorting changes in between + const query = this.listQuery + await fetchInto( + this.list, + (offset, limit) => PollsAPI.getPolls({ ...query, offset, limit }), + limit ?? this.meta.pageSize, + false, + ) + }, + + /** + * Append the next page to the current list + */ + async loadMore(): Promise { + if (this.list.status === 'loading' || !this.hasMore) { return } + const query = this.listQuery + await fetchInto( + this.list, + (offset, limit) => PollsAPI.getPolls({ ...query, offset, limit }), + this.meta.pageSize, + true, + ) + }, + + /** + * Load the newest relevant polls for the dashboard widget + */ + async loadDashboard(): Promise { + await fetchInto( + this.list, + (offset, limit) => + PollsAPI.getPolls({ + category: 'relevant', + sortBy: 'created', + sortDirection: 'desc', + offset, + limit, + }), + DASHBOARD_POLLS, + false, + ) + }, + + /** + * Load the first page of the non archived date polls + * + * @param {number} limit - Number of polls to load + */ + async loadDatePolls(limit?: number): Promise { + await fetchInto( + this.datePolls, + (offset, limit) => PollsAPI.getDatePolls(offset, limit), + limit ?? this.meta.pageSize, + false, + ) + }, - this.meta.status = 'loading' + async loadMoreDatePolls(): Promise { + if (this.datePolls.status === 'loading' || !this.hasMoreDatePolls) { + return + } + await fetchInto( + this.datePolls, + (offset, limit) => PollsAPI.getDatePolls(offset, limit), + this.meta.pageSize, + true, + ) + }, + /** + * Load the newest polls of a category or poll group for its navigation entry + * + * @param {FilterType | number} key - Category id or poll group id + */ + async loadNavigationList(key: FilterType | number): Promise { try { - const response = await PollsAPI.getPolls() - this.polls = response.data.polls - pollGroupsStore.pollGroups = response.data.pollGroups - this.meta.status = 'loaded' + const response = await PollsAPI.getNavigationPolls({ + ...(typeof key === 'number' + ? { pollGroup: key } + : { category: key }), + sortBy: 'created', + sortDirection: 'desc', + offset: 0, + limit: this.meta.maxPollsInNavigation, + }) + this.navigationPolls[key] = response.data.polls } catch (error) { - if ((error as AxiosError)?.code === 'ERR_CANCELED') { - return - } - this.meta.status = 'error' - Logger.error('Error loading polls', { error }) + Logger.error('Error loading navigation polls', { error, key }) throw error } }, /** - * Sliced filtered and sorted polls for navigation - * @param filterList - List of poll IDs to filter by + * Refresh everything that was loaded before, after polls changed. + * Lists keep the number of already loaded polls. */ - groupList(filterList: number[]): Poll[] { - const pollsStore = usePollsStore() - return orderBy( - pollsStore.polls.filter((poll: Poll) => filterList.includes(poll.id)) - ?? [], - ['created'], - ['desc'], - ).slice(0, pollsStore.meta.maxPollsInNavigation) + async load(): Promise { + const requests: Promise[] = [ + this.loadMeta(), + // refresh already expanded navigation entries, object keys are strings + ...Object.keys(this.navigationPolls).map((key) => + this.loadNavigationList( + /^\d+$/.test(key) ? Number(key) : (key as FilterType), + ), + ), + ] + if (this.list.status !== '') { + requests.push( + this.loadList( + Math.max(this.list.polls.length, this.meta.pageSize), + ), + ) + } + if (this.datePolls.status !== '') { + requests.push( + this.loadDatePolls( + Math.max(this.datePolls.polls.length, this.meta.pageSize), + ), + ) + } + await Promise.all(requests) }, - addOrUpdatePollGroupInList(payload: { poll: Poll }) { - this.polls = this.polls - .filter((p) => p.id !== payload.poll?.id) - .concat(payload.poll) + /** + * Update a poll after its poll groups changed + * and refresh the counts and the current list + * + * @param payload + * @param payload.poll + */ + async addOrUpdatePollGroupInList(payload: { poll: Poll }): Promise { + this.list.polls = this.list.polls.map((poll) => + poll.id === payload.poll.id ? payload.poll : poll, + ) + await this.load() }, async changeOwner(payload: { pollId: number; userId: string }) { @@ -379,14 +543,6 @@ export const usePollsStore = defineStore('polls', { } }, - addChunk(): void { - this.meta.chunks.loaded = this.meta.chunks.loaded + 1 - }, - - resetChunks(): void { - this.meta.chunks.loaded = 1 - }, - async clone(payload: { pollId: number }): Promise { try { await PollsAPI.clonePoll(payload.pollId) diff --git a/src/stores/polls.types.ts b/src/stores/polls.types.ts index ce7725f188..ea8cda25c8 100644 --- a/src/stores/polls.types.ts +++ b/src/stores/polls.types.ts @@ -3,8 +3,8 @@ * SPDX-License-Identifier: AGPL-3.0-or-later */ -import { Poll } from './poll.types' -import { Chunking, StatusResults } from '../Types' +import { Poll, PollType } from './poll.types' +import { StatusResults } from '../Types' export type SortType = | 'created' @@ -34,27 +34,53 @@ export type PollCategory = { description: string pinned: boolean showInNavigation(): boolean - filterCondition(poll: Poll): boolean } export type PollCategoryList = Record -export type Meta = { - chunks: Chunking - maxPollsInNavigation: number +/** + * Query for one page of polls, filtering is done by the server. + * `pollGroup` takes precedence over `category`. + */ +export type PollListQuery = { + category?: FilterType + pollGroup?: number + type?: PollType + sortBy: SortType + sortDirection: SortDirection + offset: number + limit: number +} + +/** + * Poll counts for the navigation, polls are loaded separately + */ +export type PollListMeta = { + counts: Record + pollGroupCounts: Record +} + +export type PaginatedPolls = { + polls: Poll[] + total: number status: StatusResults } export type PollsStore = { - polls: Poll[] - // pollGroups: PollGroup[] - meta: Meta + // current list view (category or poll group), pages are appended + list: PaginatedPolls + // non archived date polls for the combo sidebar + datePolls: PaginatedPolls + listMeta: PollListMeta & { status: StatusResults } + // newest polls of expanded navigation entries, keyed by category id or poll group id + navigationPolls: Partial> + meta: { + pageSize: number + maxPollsInNavigation: number + } sort: { by: SortType reverse: boolean } - status: { - loadingGroups: boolean - } categories: PollCategoryList } diff --git a/src/views/Dashboard.vue b/src/views/Dashboard.vue index 4a702c0dba..6d1fe4efc3 100644 --- a/src/views/Dashboard.vue +++ b/src/views/Dashboard.vue @@ -28,11 +28,11 @@ const pollsStore = usePollsStore() /** * Load the polls */ -function loadPolls(): void { +async function loadPolls(): Promise { Logger.debug('Loading polls in dashboard widget') try { - pollsStore.load() - } catch (error) { + await pollsStore.loadDashboard() + } catch { showError(t('polls', 'Error setting dashboard list')) } } diff --git a/src/views/Navigation.vue b/src/views/Navigation.vue index fcbe17bc57..0680ffc558 100644 --- a/src/views/Navigation.vue +++ b/src/views/Navigation.vue @@ -160,8 +160,30 @@ async function pollAdded(payLoad: { id: number; title: string }) { }) } -onMounted(() => { - pollsStore.load(false) +/** + * Load the newest polls of a navigation entry, when it gets expanded + * + * @param open - New open state of the entry + * @param key - Category id or poll group id + */ +async function loadNavigationList(open: boolean, key: FilterType | number) { + if (!open || !sessionStore.appSettings.navigationPollsInList) { + return + } + try { + await pollsStore.loadNavigationList(key) + } catch { + showError(t('polls', 'Error loading polls')) + } +} + +onMounted(async () => { + try { + await pollsStore.loadMeta(false) + } catch { + // without the meta data the counters stay at 0 and no poll group is listed + showError(t('polls', 'Error loading poll list')) + } }) @@ -198,30 +220,31 @@ onMounted(() => { name: 'group', params: { slug: pollGroup.slug }, }" - :open="false"> + :open="false" + @update:open="loadNavigationList($event, pollGroup.id)">
    { name: 'list', params: { type: pollCategory.id }, }" - :open="false"> + :open="false" + @update:open="loadNavigationList($event, pollCategory.id)">