Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 10 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,17 @@
All notable changes to this project will be documented in this file.

## [unreleased]
### changes
### New
- Add option to delete orphaned votes

### Changes
- Make vote cell focusable
- Make shadow of sticky items transparent
- Changed experimental comments layout

### Fixes
- Force list view mode initially on mobile viewports
- Fix some viual issues of the vote page

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

### changes
### Changes
- Center poll table

## [8.1.0] - 2025-07-13
Expand Down
2 changes: 2 additions & 0 deletions lib/AppInfo/Application.php
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@
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;
Expand Down Expand Up @@ -126,6 +127,7 @@ 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);

Expand Down
14 changes: 13 additions & 1 deletion lib/Controller/PollApiController.php
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ 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),
Expand Down Expand Up @@ -196,8 +197,19 @@ public function getParticipantsEmailAddresses(int $pollId): DataResponse {
}

/**
* Get valid values for configuration options
* 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]
Expand Down
37 changes: 36 additions & 1 deletion lib/Controller/PollController.php
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
use OCP\AppFramework\Http;
use OCP\AppFramework\Http\Attribute\FrontpageRoute;
use OCP\AppFramework\Http\Attribute\NoAdminRequired;
use OCP\AppFramework\Http\Attribute\OpenAPI;
use OCP\AppFramework\Http\JSONResponse;
use OCP\IRequest;

Expand Down Expand Up @@ -56,6 +57,7 @@ public function __construct(
* }>
*/
#[NoAdminRequired]
#[OpenAPI(OpenAPI::SCOPE_IGNORE)]
#[FrontpageRoute(verb: 'GET', url: '/polls')]
public function listPolls(): JSONResponse {
return $this->response(function () {
Expand All @@ -78,6 +80,7 @@ public function listPolls(): JSONResponse {
* psalm-return JSONResponse<array{poll: Poll}>
*/
#[NoAdminRequired]
#[OpenAPI(OpenAPI::SCOPE_IGNORE)]
#[FrontpageRoute(verb: 'GET', url: '/poll/{pollId}/poll')]
public function get(int $pollId): JSONResponse {
return $this->response(fn () => [
Expand All @@ -100,6 +103,7 @@ public function get(int $pollId): JSONResponse {
*
*/
#[NoAdminRequired]
#[OpenAPI(OpenAPI::SCOPE_IGNORE)]
#[FrontpageRoute(verb: 'GET', url: '/poll/{pollId}')]
public function getFull(int $pollId): JSONResponse {
return $this->response(fn () => $this->getFullPoll($pollId, true), Http::STATUS_OK);
Expand All @@ -117,6 +121,9 @@ 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);

Expand All @@ -130,7 +137,8 @@ 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['comments'] = $timerMicro['comments'] - $timerMicro['votes'];
$diffMicro['orphaned'] = $timerMicro['orphaned'] - $timerMicro['votes'];
$diffMicro['comments'] = $timerMicro['comments'] - $timerMicro['orphaned'];
$diffMicro['shares'] = $timerMicro['shares'] - $timerMicro['comments'];
$diffMicro['subscribed'] = $timerMicro['subscribed'] - $timerMicro['shares'];

Expand All @@ -139,6 +147,7 @@ private function getFullPoll(int $pollId, bool $withTimings = false): array {
'poll' => $poll,
'options' => $options,
'votes' => $votes,
'orphaned' => count($orphaned),
'comments' => $comments,
'shares' => $shares,
'subscribed' => $subscribed,
Expand All @@ -149,6 +158,7 @@ private function getFullPoll(int $pollId, bool $withTimings = false): array {
'poll' => $poll,
'options' => $options,
'votes' => $votes,
'orphaned' => count($orphaned),
'comments' => $comments,
'shares' => $shares,
'subscribed' => $subscribed,
Expand All @@ -164,6 +174,7 @@ private function getFullPoll(int $pollId, bool $withTimings = false): array {
* psalm-return JSONResponse<array{poll: Poll}>
*/
#[NoAdminRequired]
#[OpenAPI(OpenAPI::SCOPE_IGNORE)]
#[FrontpageRoute(verb: 'POST', url: '/poll/add')]
public function add(string $type, string $title, string $votingVariant = Poll::VARIANT_SIMPLE): JSONResponse {
return $this->response(
Expand All @@ -182,6 +193,7 @@ public function add(string $type, string $title, string $votingVariant = Poll::V
* psalm-return JSONResponse<array{poll: Poll}>
*/
#[NoAdminRequired]
#[OpenAPI(OpenAPI::SCOPE_IGNORE)]
#[FrontpageRoute(verb: 'PUT', url: '/poll/{pollId}')]
public function update(int $pollId, array $poll): JSONResponse {
return $this->response(fn () => [
Expand All @@ -194,6 +206,7 @@ public function update(int $pollId, array $poll): JSONResponse {
* @param int $pollId Poll id
*/
#[NoAdminRequired]
#[OpenAPI(OpenAPI::SCOPE_IGNORE)]
#[FrontpageRoute(verb: 'PUT', url: '/poll/{pollId}/lockAnonymous')]
public function lockAnonymous(int $pollId): JSONResponse {
return $this->response(fn () => [
Expand All @@ -206,6 +219,7 @@ public function lockAnonymous(int $pollId): JSONResponse {
* @param int $pollId Poll id
*/
#[NoAdminRequired]
#[OpenAPI(OpenAPI::SCOPE_IGNORE)]
#[FrontpageRoute(verb: 'POST', url: '/poll/{pollId}/confirmation')]
public function sendConfirmation(int $pollId): JSONResponse {
return $this->response(fn () => [
Expand All @@ -218,6 +232,7 @@ public function sendConfirmation(int $pollId): JSONResponse {
* @param int $pollId Poll id
*/
#[NoAdminRequired]
#[OpenAPI(OpenAPI::SCOPE_IGNORE)]
#[FrontpageRoute(verb: 'PUT', url: '/poll/{pollId}/toggleArchive')]
public function toggleArchive(int $pollId): JSONResponse {
return $this->response(fn () => [
Expand All @@ -230,6 +245,7 @@ public function toggleArchive(int $pollId): JSONResponse {
* @param int $pollId Poll id
*/
#[NoAdminRequired]
#[OpenAPI(OpenAPI::SCOPE_IGNORE)]
#[FrontpageRoute(verb: 'DELETE', url: '/poll/{pollId}')]
public function delete(int $pollId): JSONResponse {
return $this->response(fn () => [
Expand All @@ -242,6 +258,7 @@ public function delete(int $pollId): JSONResponse {
* @param int $pollId Poll id
*/
#[NoAdminRequired]
#[OpenAPI(OpenAPI::SCOPE_IGNORE)]
#[FrontpageRoute(verb: 'PUT', url: '/poll/{pollId}/close')]
public function close(int $pollId): JSONResponse {
return $this->response(fn () => [
Expand All @@ -254,6 +271,7 @@ public function close(int $pollId): JSONResponse {
* @param int $pollId Poll id
*/
#[NoAdminRequired]
#[OpenAPI(OpenAPI::SCOPE_IGNORE)]
#[FrontpageRoute(verb: 'PUT', url: '/poll/{pollId}/reopen')]
public function reopen(int $pollId): JSONResponse {
return $this->response(fn () => [
Expand Down Expand Up @@ -284,6 +302,7 @@ private function clonePoll(int $pollId): Poll {
* @param string $sourceUserId User id to transfer polls from
* @param string $targetUserId User id to transfer polls to
*/
#[OpenAPI(OpenAPI::SCOPE_IGNORE)]
#[FrontpageRoute(verb: 'PUT', url: '/poll/transfer/{sourceUserId}/{targetUserId}')]
public function transferPolls(string $sourceUserId, string $targetUserId): JSONResponse {
return $this->response(fn () => $this->pollService->transferPolls($sourceUserId, $targetUserId));
Expand All @@ -295,6 +314,7 @@ public function transferPolls(string $sourceUserId, string $targetUserId): JSONR
* @param string $targetUserId User to transfer polls to
*/
#[NoAdminRequired]
#[OpenAPI(OpenAPI::SCOPE_IGNORE)]
#[FrontpageRoute(verb: 'PUT', url: '/poll/{pollId}/changeowner/{targetUserId}')]
public function changeOwner(int $pollId, string $targetUserId): JSONResponse {
return $this->response(fn () => $this->pollService->transferPoll($pollId, $targetUserId));
Expand All @@ -305,8 +325,23 @@ public function changeOwner(int $pollId, string $targetUserId): JSONResponse {
* @param int $pollId Poll id
*/
#[NoAdminRequired]
#[OpenAPI(OpenAPI::SCOPE_IGNORE)]
#[FrontpageRoute(verb: 'GET', url: '/poll/{pollId}/addresses')]
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)
]);
}

}
1 change: 1 addition & 0 deletions lib/Controller/PublicController.php
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,7 @@ public function getPoll(): JSONResponse {
'poll' => $this->pollService->get($this->userSession->getShare()->getPollId()),
'options' => $this->optionService->list($this->userSession->getShare()->getPollId()),
'votes' => $this->voteService->list($this->userSession->getShare()->getPollId()),
'orphaned' => 0,
'comments' => $this->commentService->list($this->userSession->getShare()->getPollId()),
'shares' => $this->shareService->list($this->userSession->getShare()->getPollId()),
'subscribed' => $this->subscriptionService->get($this->userSession->getShare()->getPollId()),
Expand Down
20 changes: 20 additions & 0 deletions lib/Db/VoteMapper.php
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,26 @@ public function findOrphanedByPollandUser(int $pollId, string $userId): array {
return $this->findEntities($qb);
}

/**
* @throws \OCP\AppFramework\Db\DoesNotExistException if not found
* @return Vote[]
* @psalm-return array<array-key, Vote>
*/
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);
}

/**
* Build the enhanced query with joined tables
*/
Expand Down
24 changes: 24 additions & 0 deletions lib/Event/VoteDeletedOrphanedEvent.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
<?php

/**
* SPDX-FileCopyrightText: 2021 Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

namespace OCA\Polls\Event;

use OCA\Polls\Db\Vote;

class VoteDeletedOrphanedEvent extends VoteEvent {
/**
* @psalm-suppress PossiblyUnusedMethod
*/
public function __construct(
protected Vote $vote,
protected bool $log = true,
) {
parent::__construct($vote);
$this->log = $log;
$this->eventId = self::DELETED_ORPHANED;
}
}
1 change: 1 addition & 0 deletions lib/Event/VoteEvent.php
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
*/
abstract class VoteEvent extends BaseEvent {
public const SET = 'vote_set';
public const DELETED_ORPHANED = 'vote_deleted_orphaned';

public function __construct(
protected Vote $vote,
Expand Down
6 changes: 6 additions & 0 deletions lib/Service/ActivityService.php
Original file line number Diff line number Diff line change
Expand Up @@ -242,6 +242,12 @@ private function getMatchedMessages(): array {
self::FIRST_PERSON_FILTERED => $this->l10n->t('You have voted'),
self::THIRD_PERSON_FILTERED => $this->l10n->t('{actor} has voted'),
],
VoteEvent::DELETED_ORPHANED => [
self::FIRST_PERSON_FULL => $this->l10n->t('One or more orphaned votes heve been deleted from {pollTitle}'),
self::THIRD_PERSON_FULL => $this->l10n->t('{actor} has deleted your orphaned polls from poll {pollTitle}'),
self::FIRST_PERSON_FILTERED => $this->l10n->t('Orphaned votes deleted'),
self::THIRD_PERSON_FILTERED => $this->l10n->t('{actor} deleted orphaned votes'),
],
ShareEvent::LOCKED => [
self::FIRST_PERSON_FULL => $this->l10n->t('You have locked the share of {sharee}'),
self::THIRD_PERSON_FULL => $this->l10n->t('{actor} has locked the share of {sharee}'),
Expand Down
39 changes: 39 additions & 0 deletions lib/Service/VoteService.php
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,9 @@
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;
use OCA\Polls\Exceptions\VoteLimitExceededException;
use OCA\Polls\UserSession;
Expand Down Expand Up @@ -130,6 +132,43 @@ 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 {
$poll = $this->pollMapper->get($pollId, withRoles: true);
$poll->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 {
$poll = $this->pollMapper->get($pollId, withRoles: true);
$poll->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
*
Expand Down
Loading