Skip to content

Commit d34230a

Browse files
authored
Merge pull request #4176 from nextcloud/enh/reset-poll
Delete all orphaned votes from poll
2 parents 0c17164 + 21c84d3 commit d34230a

16 files changed

Lines changed: 276 additions & 54 deletions

CHANGELOG.md

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,17 @@
66
All notable changes to this project will be documented in this file.
77

88
## [unreleased]
9-
### changes
9+
### New
10+
- Add option to delete orphaned votes
11+
12+
### Changes
1013
- Make vote cell focusable
1114
- Make shadow of sticky items transparent
15+
- Changed experimental comments layout
16+
17+
### Fixes
18+
- Force list view mode initially on mobile viewports
19+
- Fix some viual issues of the vote page
1220

1321
## [8.1.4] - 2025-07-15
1422
### Fixes
@@ -19,7 +27,7 @@ All notable changes to this project will be documented in this file.
1927
- Fixed visual bug when scrolling in list view
2028
- Fixed exception on notifications which may cause resending notification mails
2129

22-
### changes
30+
### Changes
2331
- Center poll table
2432

2533
## [8.1.0] - 2025-07-13

lib/AppInfo/Application.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@
4242
use OCA\Polls\Event\ShareLockedEvent;
4343
use OCA\Polls\Event\ShareRegistrationEvent;
4444
use OCA\Polls\Event\ShareTypeChangedEvent;
45+
use OCA\Polls\Event\VoteDeletedOrphanedEvent;
4546
use OCA\Polls\Event\VoteEvent;
4647
use OCA\Polls\Event\VoteSetEvent;
4748
use OCA\Polls\Listener\CommentListener;
@@ -126,6 +127,7 @@ public function register(IRegistrationContext $context): void {
126127

127128
$context->registerEventListener(VoteEvent::class, VoteListener::class);
128129
$context->registerEventListener(VoteSetEvent::class, VoteListener::class);
130+
$context->registerEventListener(VoteDeletedOrphanedEvent::class, VoteListener::class);
129131
$context->registerEventListener(UserDeletedEvent::class, UserDeletedListener::class);
130132
$context->registerEventListener(GroupDeletedEvent::class, GroupDeletedListener::class);
131133

lib/Controller/PollApiController.php

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@ public function get(int $pollId): DataResponse {
6767
'poll' => $this->pollService->get($pollId),
6868
'options' => $this->optionService->list($pollId),
6969
'votes' => $this->voteService->list($pollId),
70+
'orphaned' => count($this->voteService->getOprhanedVotes($pollId)),
7071
'comments' => $this->commentService->list($pollId),
7172
'shares' => $this->shareService->list($pollId),
7273
'subscribed' => $this->subscriptionService->get($pollId),
@@ -196,8 +197,19 @@ public function getParticipantsEmailAddresses(int $pollId): DataResponse {
196197
}
197198

198199
/**
199-
* Get valid values for configuration options
200+
* Delete orphaned votes from pollId
201+
* @param int $pollId poll id
200202
*/
203+
#[CORS]
204+
#[NoAdminRequired]
205+
#[NoCSRFRequired]
206+
#[ApiRoute(verb: 'DELETE', url: '/api/v1.0/poll/{pollId}/votes/orphaned/all', requirements: ['apiVersion' => '(v2)'])]
207+
public function deleteOrphaned(int $pollId): DataResponse {
208+
return $this->response(fn () => [
209+
'deleted' => $this->voteService->deleteOrphanedVotes($pollId)
210+
]);
211+
}
212+
201213
#[CORS]
202214
#[NoAdminRequired]
203215
#[NoCSRFRequired]

lib/Controller/PollController.php

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
use OCP\AppFramework\Http;
2323
use OCP\AppFramework\Http\Attribute\FrontpageRoute;
2424
use OCP\AppFramework\Http\Attribute\NoAdminRequired;
25+
use OCP\AppFramework\Http\Attribute\OpenAPI;
2526
use OCP\AppFramework\Http\JSONResponse;
2627
use OCP\IRequest;
2728

@@ -56,6 +57,7 @@ public function __construct(
5657
* }>
5758
*/
5859
#[NoAdminRequired]
60+
#[OpenAPI(OpenAPI::SCOPE_IGNORE)]
5961
#[FrontpageRoute(verb: 'GET', url: '/polls')]
6062
public function listPolls(): JSONResponse {
6163
return $this->response(function () {
@@ -78,6 +80,7 @@ public function listPolls(): JSONResponse {
7880
* psalm-return JSONResponse<array{poll: Poll}>
7981
*/
8082
#[NoAdminRequired]
83+
#[OpenAPI(OpenAPI::SCOPE_IGNORE)]
8184
#[FrontpageRoute(verb: 'GET', url: '/poll/{pollId}/poll')]
8285
public function get(int $pollId): JSONResponse {
8386
return $this->response(fn () => [
@@ -100,6 +103,7 @@ public function get(int $pollId): JSONResponse {
100103
*
101104
*/
102105
#[NoAdminRequired]
106+
#[OpenAPI(OpenAPI::SCOPE_IGNORE)]
103107
#[FrontpageRoute(verb: 'GET', url: '/poll/{pollId}')]
104108
public function getFull(int $pollId): JSONResponse {
105109
return $this->response(fn () => $this->getFullPoll($pollId, true), Http::STATUS_OK);
@@ -117,6 +121,9 @@ private function getFullPoll(int $pollId, bool $withTimings = false): array {
117121
$votes = $this->voteService->list($pollId);
118122
$timerMicro['votes'] = microtime(true);
119123

124+
$orphaned = $this->voteService->getOprhanedVotes($pollId);
125+
$timerMicro['orphaned'] = microtime(true);
126+
120127
$comments = $this->commentService->list($pollId);
121128
$timerMicro['comments'] = microtime(true);
122129

@@ -130,7 +137,8 @@ private function getFullPoll(int $pollId, bool $withTimings = false): array {
130137
$diffMicro['poll'] = $timerMicro['poll'] - $timerMicro['start'];
131138
$diffMicro['options'] = $timerMicro['options'] - $timerMicro['poll'];
132139
$diffMicro['votes'] = $timerMicro['votes'] - $timerMicro['options'];
133-
$diffMicro['comments'] = $timerMicro['comments'] - $timerMicro['votes'];
140+
$diffMicro['orphaned'] = $timerMicro['orphaned'] - $timerMicro['votes'];
141+
$diffMicro['comments'] = $timerMicro['comments'] - $timerMicro['orphaned'];
134142
$diffMicro['shares'] = $timerMicro['shares'] - $timerMicro['comments'];
135143
$diffMicro['subscribed'] = $timerMicro['subscribed'] - $timerMicro['shares'];
136144

@@ -139,6 +147,7 @@ private function getFullPoll(int $pollId, bool $withTimings = false): array {
139147
'poll' => $poll,
140148
'options' => $options,
141149
'votes' => $votes,
150+
'orphaned' => count($orphaned),
142151
'comments' => $comments,
143152
'shares' => $shares,
144153
'subscribed' => $subscribed,
@@ -149,6 +158,7 @@ private function getFullPoll(int $pollId, bool $withTimings = false): array {
149158
'poll' => $poll,
150159
'options' => $options,
151160
'votes' => $votes,
161+
'orphaned' => count($orphaned),
152162
'comments' => $comments,
153163
'shares' => $shares,
154164
'subscribed' => $subscribed,
@@ -164,6 +174,7 @@ private function getFullPoll(int $pollId, bool $withTimings = false): array {
164174
* psalm-return JSONResponse<array{poll: Poll}>
165175
*/
166176
#[NoAdminRequired]
177+
#[OpenAPI(OpenAPI::SCOPE_IGNORE)]
167178
#[FrontpageRoute(verb: 'POST', url: '/poll/add')]
168179
public function add(string $type, string $title, string $votingVariant = Poll::VARIANT_SIMPLE): JSONResponse {
169180
return $this->response(
@@ -182,6 +193,7 @@ public function add(string $type, string $title, string $votingVariant = Poll::V
182193
* psalm-return JSONResponse<array{poll: Poll}>
183194
*/
184195
#[NoAdminRequired]
196+
#[OpenAPI(OpenAPI::SCOPE_IGNORE)]
185197
#[FrontpageRoute(verb: 'PUT', url: '/poll/{pollId}')]
186198
public function update(int $pollId, array $poll): JSONResponse {
187199
return $this->response(fn () => [
@@ -194,6 +206,7 @@ public function update(int $pollId, array $poll): JSONResponse {
194206
* @param int $pollId Poll id
195207
*/
196208
#[NoAdminRequired]
209+
#[OpenAPI(OpenAPI::SCOPE_IGNORE)]
197210
#[FrontpageRoute(verb: 'PUT', url: '/poll/{pollId}/lockAnonymous')]
198211
public function lockAnonymous(int $pollId): JSONResponse {
199212
return $this->response(fn () => [
@@ -206,6 +219,7 @@ public function lockAnonymous(int $pollId): JSONResponse {
206219
* @param int $pollId Poll id
207220
*/
208221
#[NoAdminRequired]
222+
#[OpenAPI(OpenAPI::SCOPE_IGNORE)]
209223
#[FrontpageRoute(verb: 'POST', url: '/poll/{pollId}/confirmation')]
210224
public function sendConfirmation(int $pollId): JSONResponse {
211225
return $this->response(fn () => [
@@ -218,6 +232,7 @@ public function sendConfirmation(int $pollId): JSONResponse {
218232
* @param int $pollId Poll id
219233
*/
220234
#[NoAdminRequired]
235+
#[OpenAPI(OpenAPI::SCOPE_IGNORE)]
221236
#[FrontpageRoute(verb: 'PUT', url: '/poll/{pollId}/toggleArchive')]
222237
public function toggleArchive(int $pollId): JSONResponse {
223238
return $this->response(fn () => [
@@ -230,6 +245,7 @@ public function toggleArchive(int $pollId): JSONResponse {
230245
* @param int $pollId Poll id
231246
*/
232247
#[NoAdminRequired]
248+
#[OpenAPI(OpenAPI::SCOPE_IGNORE)]
233249
#[FrontpageRoute(verb: 'DELETE', url: '/poll/{pollId}')]
234250
public function delete(int $pollId): JSONResponse {
235251
return $this->response(fn () => [
@@ -242,6 +258,7 @@ public function delete(int $pollId): JSONResponse {
242258
* @param int $pollId Poll id
243259
*/
244260
#[NoAdminRequired]
261+
#[OpenAPI(OpenAPI::SCOPE_IGNORE)]
245262
#[FrontpageRoute(verb: 'PUT', url: '/poll/{pollId}/close')]
246263
public function close(int $pollId): JSONResponse {
247264
return $this->response(fn () => [
@@ -254,6 +271,7 @@ public function close(int $pollId): JSONResponse {
254271
* @param int $pollId Poll id
255272
*/
256273
#[NoAdminRequired]
274+
#[OpenAPI(OpenAPI::SCOPE_IGNORE)]
257275
#[FrontpageRoute(verb: 'PUT', url: '/poll/{pollId}/reopen')]
258276
public function reopen(int $pollId): JSONResponse {
259277
return $this->response(fn () => [
@@ -284,6 +302,7 @@ private function clonePoll(int $pollId): Poll {
284302
* @param string $sourceUserId User id to transfer polls from
285303
* @param string $targetUserId User id to transfer polls to
286304
*/
305+
#[OpenAPI(OpenAPI::SCOPE_IGNORE)]
287306
#[FrontpageRoute(verb: 'PUT', url: '/poll/transfer/{sourceUserId}/{targetUserId}')]
288307
public function transferPolls(string $sourceUserId, string $targetUserId): JSONResponse {
289308
return $this->response(fn () => $this->pollService->transferPolls($sourceUserId, $targetUserId));
@@ -295,6 +314,7 @@ public function transferPolls(string $sourceUserId, string $targetUserId): JSONR
295314
* @param string $targetUserId User to transfer polls to
296315
*/
297316
#[NoAdminRequired]
317+
#[OpenAPI(OpenAPI::SCOPE_IGNORE)]
298318
#[FrontpageRoute(verb: 'PUT', url: '/poll/{pollId}/changeowner/{targetUserId}')]
299319
public function changeOwner(int $pollId, string $targetUserId): JSONResponse {
300320
return $this->response(fn () => $this->pollService->transferPoll($pollId, $targetUserId));
@@ -305,8 +325,23 @@ public function changeOwner(int $pollId, string $targetUserId): JSONResponse {
305325
* @param int $pollId Poll id
306326
*/
307327
#[NoAdminRequired]
328+
#[OpenAPI(OpenAPI::SCOPE_IGNORE)]
308329
#[FrontpageRoute(verb: 'GET', url: '/poll/{pollId}/addresses')]
309330
public function getParticipantsEmailAddresses(int $pollId): JSONResponse {
310331
return $this->response(fn () => $this->pollService->getParticipantsEmailAddresses($pollId));
311332
}
333+
334+
/**
335+
* Delete orphaned votes
336+
* @param int $pollId poll id
337+
*/
338+
#[NoAdminRequired]
339+
#[OpenAPI(OpenAPI::SCOPE_IGNORE)]
340+
#[FrontpageRoute(verb: 'DELETE', url: '/poll/{pollId}/votes/orphaned/all')]
341+
public function deleteOrphaned(int $pollId): JSONResponse {
342+
return $this->response(fn () => [
343+
'deleted' => $this->voteService->deleteOrphanedVotes($pollId)
344+
]);
345+
}
346+
312347
}

lib/Controller/PublicController.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,7 @@ public function getPoll(): JSONResponse {
9494
'poll' => $this->pollService->get($this->userSession->getShare()->getPollId()),
9595
'options' => $this->optionService->list($this->userSession->getShare()->getPollId()),
9696
'votes' => $this->voteService->list($this->userSession->getShare()->getPollId()),
97+
'orphaned' => 0,
9798
'comments' => $this->commentService->list($this->userSession->getShare()->getPollId()),
9899
'shares' => $this->shareService->list($this->userSession->getShare()->getPollId()),
99100
'subscribed' => $this->subscriptionService->get($this->userSession->getShare()->getPollId()),

lib/Db/VoteMapper.php

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -154,6 +154,26 @@ public function findOrphanedByPollandUser(int $pollId, string $userId): array {
154154
return $this->findEntities($qb);
155155
}
156156

157+
/**
158+
* @throws \OCP\AppFramework\Db\DoesNotExistException if not found
159+
* @return Vote[]
160+
* @psalm-return array<array-key, Vote>
161+
*/
162+
public function findOrphanedByPoll(int $pollId): array {
163+
$qb = $this->db->getQueryBuilder();
164+
165+
$qb->select(self::TABLE . '.*')
166+
->where($qb->expr()->isNotNull(self::TABLE . '.poll_id'))
167+
->from($this->getTableName(), self::TABLE)
168+
->groupBy(self::TABLE . '.id');
169+
170+
$optionAlias = $this->joinOption($qb, self::TABLE);
171+
172+
$qb->andWhere($qb->expr()->isNull($optionAlias . '.id'));
173+
$qb->andWhere($qb->expr()->eq(self::TABLE . '.poll_id', $qb->createNamedParameter($pollId, IQueryBuilder::PARAM_INT)));
174+
return $this->findEntities($qb);
175+
}
176+
157177
/**
158178
* Build the enhanced query with joined tables
159179
*/
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
<?php
2+
3+
/**
4+
* SPDX-FileCopyrightText: 2021 Nextcloud contributors
5+
* SPDX-License-Identifier: AGPL-3.0-or-later
6+
*/
7+
8+
namespace OCA\Polls\Event;
9+
10+
use OCA\Polls\Db\Vote;
11+
12+
class VoteDeletedOrphanedEvent extends VoteEvent {
13+
/**
14+
* @psalm-suppress PossiblyUnusedMethod
15+
*/
16+
public function __construct(
17+
protected Vote $vote,
18+
protected bool $log = true,
19+
) {
20+
parent::__construct($vote);
21+
$this->log = $log;
22+
$this->eventId = self::DELETED_ORPHANED;
23+
}
24+
}

lib/Event/VoteEvent.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
*/
1515
abstract class VoteEvent extends BaseEvent {
1616
public const SET = 'vote_set';
17+
public const DELETED_ORPHANED = 'vote_deleted_orphaned';
1718

1819
public function __construct(
1920
protected Vote $vote,

lib/Service/ActivityService.php

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -242,6 +242,12 @@ private function getMatchedMessages(): array {
242242
self::FIRST_PERSON_FILTERED => $this->l10n->t('You have voted'),
243243
self::THIRD_PERSON_FILTERED => $this->l10n->t('{actor} has voted'),
244244
],
245+
VoteEvent::DELETED_ORPHANED => [
246+
self::FIRST_PERSON_FULL => $this->l10n->t('One or more orphaned votes heve been deleted from {pollTitle}'),
247+
self::THIRD_PERSON_FULL => $this->l10n->t('{actor} has deleted your orphaned polls from poll {pollTitle}'),
248+
self::FIRST_PERSON_FILTERED => $this->l10n->t('Orphaned votes deleted'),
249+
self::THIRD_PERSON_FILTERED => $this->l10n->t('{actor} deleted orphaned votes'),
250+
],
245251
ShareEvent::LOCKED => [
246252
self::FIRST_PERSON_FULL => $this->l10n->t('You have locked the share of {sharee}'),
247253
self::THIRD_PERSON_FULL => $this->l10n->t('{actor} has locked the share of {sharee}'),

lib/Service/VoteService.php

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,9 @@
1414
use OCA\Polls\Db\PollMapper;
1515
use OCA\Polls\Db\Vote;
1616
use OCA\Polls\Db\VoteMapper;
17+
use OCA\Polls\Event\VoteDeletedOrphanedEvent;
1718
use OCA\Polls\Event\VoteSetEvent;
19+
use OCA\Polls\Exceptions\ForbiddenException;
1820
use OCA\Polls\Exceptions\NotFoundException;
1921
use OCA\Polls\Exceptions\VoteLimitExceededException;
2022
use OCA\Polls\UserSession;
@@ -130,6 +132,43 @@ public function set(Option|int $optionOrOptionIdoptionId, string $setTo): ?Vote
130132
return $this->vote;
131133
}
132134

135+
/**
136+
* Get all votes of a poll, which are not assigned to an option
137+
*
138+
* @param int $pollId poll id of the poll the votes get deleted from
139+
* @return Vote[]
140+
*/
141+
public function getOprhanedVotes(int $pollId): array {
142+
try {
143+
$poll = $this->pollMapper->get($pollId, withRoles: true);
144+
$poll->request(Poll::PERMISSION_POLL_EDIT);
145+
return $this->voteMapper->findOrphanedByPoll($pollId);
146+
} catch (ForbiddenException $e) {
147+
return [];
148+
}
149+
}
150+
151+
/**
152+
* Delete all votes of a poll, which are not assigned to an option
153+
*
154+
* @param int $pollId poll id of the poll the votes get deleted from
155+
* @return Vote[]
156+
*/
157+
public function deleteOrphanedVotes(int $pollId): array {
158+
$poll = $this->pollMapper->get($pollId, withRoles: true);
159+
$poll->request(Poll::PERMISSION_VOTE_FOREIGN_CHANGE);
160+
161+
// delete all votes of the poll, which are not assigned to an option
162+
$votes = $this->voteMapper->findOrphanedByPoll($pollId);
163+
foreach ($votes as $vote) {
164+
$this->voteMapper->delete($vote);
165+
// TODO: rework notification methods
166+
// keep this dispatch as reminder
167+
// $this->eventDispatcher->dispatchTyped(new VoteDeletedOrphanedEvent($this->vote, false));
168+
}
169+
return $votes;
170+
}
171+
133172
/**
134173
* Remove user from poll
135174
*

0 commit comments

Comments
 (0)