From 5001e92bcb5b47ea86e85ea17636d87f4cf80f6b Mon Sep 17 00:00:00 2001 From: dartcafe Date: Fri, 25 Jul 2025 19:14:59 +0200 Subject: [PATCH 1/2] add janitor job: remove orphaned votes Signed-off-by: dartcafe --- lib/Command/Db/removeOrphanedVotes.php | 64 ++++++++++++++++++++++++++ lib/Cron/JanitorCron.php | 7 ++- lib/Db/VoteMapper.php | 33 +++++++++++++ 3 files changed, 103 insertions(+), 1 deletion(-) create mode 100644 lib/Command/Db/removeOrphanedVotes.php diff --git a/lib/Command/Db/removeOrphanedVotes.php b/lib/Command/Db/removeOrphanedVotes.php new file mode 100644 index 0000000000..be787c1693 --- /dev/null +++ b/lib/Command/Db/removeOrphanedVotes.php @@ -0,0 +1,64 @@ +schema = $this->connection->createSchema(); + $this->indexManager->setSchema($this->schema); + $this->addForeignKeyConstraints(); + $this->addIndices(); + $this->connection->migrateToSchema($this->schema); + + return 0; + } + + /** + * add an on delete fk contraint to all tables referencing the main polls table + */ + private function addForeignKeyConstraints(): void { + $this->printComment('Add foreign key constraints'); + $messages = $this->indexManager->createForeignKeyConstraints(); + $this->printInfo($messages, ' - '); + } + + /** + * Create index for $table + */ + private function addIndices(): void { + $this->printComment('Add indices'); + $messages = $this->indexManager->createIndices(); + $this->printInfo($messages, ' - '); + } +} diff --git a/lib/Cron/JanitorCron.php b/lib/Cron/JanitorCron.php index 14a9da4a46..471ed45a82 100644 --- a/lib/Cron/JanitorCron.php +++ b/lib/Cron/JanitorCron.php @@ -16,6 +16,7 @@ use OCA\Polls\Db\PollMapper; use OCA\Polls\Db\ShareMapper; use OCA\Polls\Db\TableManager; +use OCA\Polls\Db\VoteMapper; use OCA\Polls\Db\WatchMapper; use OCA\Polls\Helper\Container; use OCA\Polls\Model\Settings\AppSettings; @@ -39,6 +40,7 @@ public function __construct( private OptionMapper $optionMapper, private PollMapper $pollMapper, private ShareMapper $shareMapper, + private VoteMapper $voteMapper, private WatchMapper $watchMapper, private TableManager $tableManager, ) { @@ -65,11 +67,14 @@ protected function run($argument) { // delete entries older than 1 day $this->watchMapper->deleteOldEntries(time() - 86400); - // purge entries virtually deleted more than 12 hour ago + // purge entries virtually deleted more than 12 hours ago $deleted['comments'] = $this->commentMapper->purgeDeletedComments(time() - 4320); $deleted['options'] = $this->optionMapper->purgeDeletedOptions(time() - 4320); $deleted['shares'] = $this->shareMapper->purgeDeletedShares(time() - 4320); + // purge orphaned votes; Votes without any corresponding option + $deleted['orphaned votes'] = $this->voteMapper->removeOrphanedVotes(); + // delete polls after defined days after archiving date $autoDeleteOffset = $this->appSettings->getAutoDeleteOffsetDays(); if ($this->appSettings->getAutoDeleteEnabled() && $autoDeleteOffset > 0) { diff --git a/lib/Db/VoteMapper.php b/lib/Db/VoteMapper.php index f669a02c45..a29cf18265 100644 --- a/lib/Db/VoteMapper.php +++ b/lib/Db/VoteMapper.php @@ -13,6 +13,7 @@ use OCP\AppFramework\Db\Entity; use OCP\DB\QueryBuilder\IQueryBuilder; use OCP\IDBConnection; +use PDO; use Psr\Log\LoggerInterface; /** @@ -174,6 +175,38 @@ public function findOrphanedByPoll(int $pollId): array { return $this->findEntities($qb); } + public function removeOrphanedVotes(): int { + + $qb = $this->db->getQueryBuilder(); + + $qb->select('votes.*'); + $qb->from($this->getTableName(), 'votes'); + $qb->leftJoin( + 'votes', + OPTION::TABLE, + 'options', + 'votes.poll_id = options.poll_id AND votes.vote_option_text = options.poll_option_text' + ); + $qb->where('options.poll_id IS NULL'); + + // get ids to delete from first column (=id of the vote table) + $idsToDelete = $qb->executeQuery()->fetchAll(PDO::FETCH_COLUMN, 0); + + if (empty($idsToDelete)) { + return 0; + } + + $delete = $this->db->getQueryBuilder() + ->delete($this->getTableName()) + ->where('id IN (:ids)') + ->setParameter('ids', $idsToDelete, IQueryBuilder::PARAM_INT_ARRAY); + + $this->logger->debug('Removing orphaned votes', [ + 'ids' => $idsToDelete, + ]); + + return $delete->executeStatement(); + } /** * Build the enhanced query with joined tables */ From 8c956714ac0b83167bc0bc35305dc0aa23437185 Mon Sep 17 00:00:00 2001 From: dartcafe Date: Sat, 26 Jul 2025 10:26:28 +0200 Subject: [PATCH 2/2] tidy and remove manual option for poll owner Signed-off-by: dartcafe --- lib/AppInfo/Application.php | 2 - lib/Controller/PollApiController.php | 15 ------- lib/Controller/PollController.php | 21 +--------- lib/Db/VoteMapper.php | 40 ++++++------------ lib/Event/VoteDeletedOrphanedEvent.php | 24 ----------- lib/Service/VoteService.php | 38 ----------------- src/Api/modules/api.types.ts | 1 - src/Api/modules/polls.ts | 15 +------ .../Configuration/ConfigDangerArea.vue | 42 ++----------------- src/stores/poll.ts | 18 -------- 10 files changed, 18 insertions(+), 198 deletions(-) delete mode 100644 lib/Event/VoteDeletedOrphanedEvent.php diff --git a/lib/AppInfo/Application.php b/lib/AppInfo/Application.php index 1d99925008..d92633f54e 100644 --- a/lib/AppInfo/Application.php +++ b/lib/AppInfo/Application.php @@ -42,7 +42,6 @@ use OCA\Polls\Event\ShareLockedEvent; use OCA\Polls\Event\ShareRegistrationEvent; use OCA\Polls\Event\ShareTypeChangedEvent; -use OCA\Polls\Event\VoteDeletedOrphanedEvent; use OCA\Polls\Event\VoteEvent; use OCA\Polls\Event\VoteSetEvent; use OCA\Polls\Listener\CommentListener; @@ -127,7 +126,6 @@ public function register(IRegistrationContext $context): void { $context->registerEventListener(VoteEvent::class, VoteListener::class); $context->registerEventListener(VoteSetEvent::class, VoteListener::class); - $context->registerEventListener(VoteDeletedOrphanedEvent::class, VoteListener::class); $context->registerEventListener(UserDeletedEvent::class, UserDeletedListener::class); $context->registerEventListener(GroupDeletedEvent::class, GroupDeletedListener::class); diff --git a/lib/Controller/PollApiController.php b/lib/Controller/PollApiController.php index 66c8fe5b14..5a51ada97a 100644 --- a/lib/Controller/PollApiController.php +++ b/lib/Controller/PollApiController.php @@ -67,7 +67,6 @@ public function get(int $pollId): DataResponse { 'poll' => $this->pollService->get($pollId), 'options' => $this->optionService->list($pollId), 'votes' => $this->voteService->list($pollId), - 'orphaned' => count($this->voteService->getOprhanedVotes($pollId)), 'comments' => $this->commentService->list($pollId), 'shares' => $this->shareService->list($pollId), 'subscribed' => $this->subscriptionService->get($pollId), @@ -196,20 +195,6 @@ public function getParticipantsEmailAddresses(int $pollId): DataResponse { return $this->response(fn () => ['addresses' => $this->pollService->getParticipantsEmailAddresses($pollId)]); } - /** - * Delete orphaned votes from pollId - * @param int $pollId poll id - */ - #[CORS] - #[NoAdminRequired] - #[NoCSRFRequired] - #[ApiRoute(verb: 'DELETE', url: '/api/v1.0/poll/{pollId}/votes/orphaned/all', requirements: ['apiVersion' => '(v2)'])] - public function deleteOrphaned(int $pollId): DataResponse { - return $this->response(fn () => [ - 'deleted' => $this->voteService->deleteOrphanedVotes($pollId) - ]); - } - #[CORS] #[NoAdminRequired] #[NoCSRFRequired] diff --git a/lib/Controller/PollController.php b/lib/Controller/PollController.php index 21312c8621..b61ff27b3d 100644 --- a/lib/Controller/PollController.php +++ b/lib/Controller/PollController.php @@ -121,9 +121,6 @@ private function getFullPoll(int $pollId, bool $withTimings = false): array { $votes = $this->voteService->list($pollId); $timerMicro['votes'] = microtime(true); - $orphaned = $this->voteService->getOprhanedVotes($pollId); - $timerMicro['orphaned'] = microtime(true); - $comments = $this->commentService->list($pollId); $timerMicro['comments'] = microtime(true); @@ -137,8 +134,7 @@ private function getFullPoll(int $pollId, bool $withTimings = false): array { $diffMicro['poll'] = $timerMicro['poll'] - $timerMicro['start']; $diffMicro['options'] = $timerMicro['options'] - $timerMicro['poll']; $diffMicro['votes'] = $timerMicro['votes'] - $timerMicro['options']; - $diffMicro['orphaned'] = $timerMicro['orphaned'] - $timerMicro['votes']; - $diffMicro['comments'] = $timerMicro['comments'] - $timerMicro['orphaned']; + $diffMicro['comments'] = $timerMicro['comments'] - $timerMicro['votes']; $diffMicro['shares'] = $timerMicro['shares'] - $timerMicro['comments']; $diffMicro['subscribed'] = $timerMicro['subscribed'] - $timerMicro['shares']; @@ -147,7 +143,6 @@ private function getFullPoll(int $pollId, bool $withTimings = false): array { 'poll' => $poll, 'options' => $options, 'votes' => $votes, - 'orphaned' => count($orphaned), 'comments' => $comments, 'shares' => $shares, 'subscribed' => $subscribed, @@ -158,7 +153,6 @@ private function getFullPoll(int $pollId, bool $withTimings = false): array { 'poll' => $poll, 'options' => $options, 'votes' => $votes, - 'orphaned' => count($orphaned), 'comments' => $comments, 'shares' => $shares, 'subscribed' => $subscribed, @@ -329,17 +323,4 @@ public function getParticipantsEmailAddresses(int $pollId): JSONResponse { return $this->response(fn () => $this->pollService->getParticipantsEmailAddresses($pollId)); } - /** - * Delete orphaned votes - * @param int $pollId poll id - */ - #[NoAdminRequired] - #[OpenAPI(OpenAPI::SCOPE_IGNORE)] - #[FrontpageRoute(verb: 'DELETE', url: '/poll/{pollId}/votes/orphaned/all')] - public function deleteOrphaned(int $pollId): JSONResponse { - return $this->response(fn () => [ - 'deleted' => $this->voteService->deleteOrphanedVotes($pollId) - ]); - } - } diff --git a/lib/Db/VoteMapper.php b/lib/Db/VoteMapper.php index a29cf18265..5061a3b2b8 100644 --- a/lib/Db/VoteMapper.php +++ b/lib/Db/VoteMapper.php @@ -156,46 +156,32 @@ public function findOrphanedByPollandUser(int $pollId, string $userId): array { } /** - * @throws \OCP\AppFramework\Db\DoesNotExistException if not found - * @return Vote[] - * @psalm-return array + * Remove all votes that no more belong to any existing option + * + * @return int Number of deleted votes */ - public function findOrphanedByPoll(int $pollId): array { - $qb = $this->db->getQueryBuilder(); - - $qb->select(self::TABLE . '.*') - ->where($qb->expr()->isNotNull(self::TABLE . '.poll_id')) - ->from($this->getTableName(), self::TABLE) - ->groupBy(self::TABLE . '.id'); - - $optionAlias = $this->joinOption($qb, self::TABLE); - - $qb->andWhere($qb->expr()->isNull($optionAlias . '.id')); - $qb->andWhere($qb->expr()->eq(self::TABLE . '.poll_id', $qb->createNamedParameter($pollId, IQueryBuilder::PARAM_INT))); - return $this->findEntities($qb); - } - public function removeOrphanedVotes(): int { - $qb = $this->db->getQueryBuilder(); - $qb->select('votes.*'); + // get ids of votes that have no option + $qb->select('votes.id'); $qb->from($this->getTableName(), 'votes'); $qb->leftJoin( - 'votes', - OPTION::TABLE, - 'options', - 'votes.poll_id = options.poll_id AND votes.vote_option_text = options.poll_option_text' - ); + 'votes', + Option::TABLE, + 'options', + 'votes.poll_id = options.poll_id AND votes.vote_option_text = options.poll_option_text' + ); $qb->where('options.poll_id IS NULL'); - // get ids to delete from first column (=id of the vote table) - $idsToDelete = $qb->executeQuery()->fetchAll(PDO::FETCH_COLUMN, 0); + // get the ids as array + $idsToDelete = $qb->executeQuery()->fetchAll(PDO::FETCH_COLUMN); if (empty($idsToDelete)) { return 0; } + // delete all votes contained in the id array $delete = $this->db->getQueryBuilder() ->delete($this->getTableName()) ->where('id IN (:ids)') diff --git a/lib/Event/VoteDeletedOrphanedEvent.php b/lib/Event/VoteDeletedOrphanedEvent.php deleted file mode 100644 index 751e2f294a..0000000000 --- a/lib/Event/VoteDeletedOrphanedEvent.php +++ /dev/null @@ -1,24 +0,0 @@ -log = $log; - $this->eventId = self::DELETED_ORPHANED; - } -} diff --git a/lib/Service/VoteService.php b/lib/Service/VoteService.php index 2df3e334a8..26ccc0146c 100644 --- a/lib/Service/VoteService.php +++ b/lib/Service/VoteService.php @@ -14,7 +14,6 @@ use OCA\Polls\Db\PollMapper; use OCA\Polls\Db\Vote; use OCA\Polls\Db\VoteMapper; -use OCA\Polls\Event\VoteDeletedOrphanedEvent; use OCA\Polls\Event\VoteSetEvent; use OCA\Polls\Exceptions\ForbiddenException; use OCA\Polls\Exceptions\NotFoundException; @@ -133,43 +132,6 @@ public function set(Option|int $optionOrOptionIdoptionId, string $setTo): ?Vote return $this->vote; } - /** - * Get all votes of a poll, which are not assigned to an option - * - * @param int $pollId poll id of the poll the votes get deleted from - * @return Vote[] - */ - public function getOprhanedVotes(int $pollId): array { - try { - $this->pollMapper->get($pollId, true, withRoles: true) - ->request(Poll::PERMISSION_POLL_EDIT); - return $this->voteMapper->findOrphanedByPoll($pollId); - } catch (ForbiddenException $e) { - return []; - } - } - - /** - * Delete all votes of a poll, which are not assigned to an option - * - * @param int $pollId poll id of the poll the votes get deleted from - * @return Vote[] - */ - public function deleteOrphanedVotes(int $pollId): array { - $this->pollMapper->get($pollId, withRoles: true) - ->request(Poll::PERMISSION_VOTE_FOREIGN_CHANGE); - - // delete all votes of the poll, which are not assigned to an option - $votes = $this->voteMapper->findOrphanedByPoll($pollId); - foreach ($votes as $vote) { - $this->voteMapper->delete($vote); - // TODO: rework notification methods - // keep this dispatch as reminder - // $this->eventDispatcher->dispatchTyped(new VoteDeletedOrphanedEvent($this->vote, false)); - } - return $votes; - } - /** * Remove user from poll * diff --git a/src/Api/modules/api.types.ts b/src/Api/modules/api.types.ts index 39db149188..a30e1427da 100644 --- a/src/Api/modules/api.types.ts +++ b/src/Api/modules/api.types.ts @@ -9,7 +9,6 @@ export type FullPollResponse = { poll: Poll options: Option[] votes: Vote[] - orphaned: number comments: Comment[] shares: Share[] subscribed: boolean diff --git a/src/Api/modules/polls.ts b/src/Api/modules/polls.ts index c2eeaa1d5c..0a4b66b823 100644 --- a/src/Api/modules/polls.ts +++ b/src/Api/modules/polls.ts @@ -5,7 +5,7 @@ import { Poll, PollConfiguration, PollType } from '../../stores/poll.ts' import { AxiosResponse } from '@nextcloud/axios' import { httpInstance, createCancelTokenHandler } from './HttpApi.js' -import { ApiEmailAdressList, Vote } from '../../Types/index.ts' +import { ApiEmailAdressList } from '../../Types/index.ts' import { PollGroup } from '../../stores/pollGroups.types.ts' import { FullPollResponse } from './api.types.ts' @@ -145,19 +145,6 @@ const polls = { }) }, - removeOrphanedVotes( - pollId: number, - ): Promise> { - return httpInstance.request({ - method: 'DELETE', - url: `poll/${pollId}/votes/orphaned/all`, - cancelToken: - cancelTokenHandlerObject[ - this.removeOrphanedVotes.name - ].handleRequestCancellation().token, - }) - }, - closePoll(pollId: number): Promise> { return httpInstance.request({ method: 'PUT', diff --git a/src/components/Configuration/ConfigDangerArea.vue b/src/components/Configuration/ConfigDangerArea.vue index a1b17d3ba8..c2ae5dbe42 100644 --- a/src/components/Configuration/ConfigDangerArea.vue +++ b/src/components/Configuration/ConfigDangerArea.vue @@ -4,10 +4,10 @@ -->