diff --git a/lib/Db/Option.php b/lib/Db/Option.php index 0cb0bf785e..9349942d2c 100644 --- a/lib/Db/Option.php +++ b/lib/Db/Option.php @@ -115,17 +115,21 @@ public function __construct() { } /** - * Clone this option and reset the id, pollId, deleted - * and confirmed status, as well as the owner and the hash. - * - * @return void + * Build a fresh Option entity pre-populated from this option's data, + * assigned to $toPollId. The fresh entity's id is never set, so + * QBMapper::insert() relies on the DB autoincrement to assign a fresh id. */ - public function __clone() { - $this->setDeleted(0); - $this->setConfirmed(0); - $this->setOwner(''); - /** @psalm-suppress UndefinedMagicMethod */ - $this->setPollId(0); + public function createClone(int $toPollId): self { + $clone = new self(); + $clone->setPoll($toPollId); + if ($this->getTimestamp() !== 0) { + $clone->setDateTime($this->getDateTime()); + $clone->setInterval($this->getInterval()); + } else { + $clone->setText($this->getPollOptionText()); + $clone->setOrder($this->order); + } + return $clone; } /** @@ -200,6 +204,10 @@ public function getPollOptionHash(): string { $this->getPollOptionText(), ); } + + public function getPollOptionHashInDB(): string { + return $this->pollOptionHash; + } /** * Get the order of the option. If the option has a valid timestamp, * the order will be determined by the timestamp to ensure correct @@ -470,6 +478,16 @@ public function getIsLockedByVotesLimit(): bool { return $this->getVoteLimit() && $this->getUserCountYesVotes() >= $this->getVoteLimit(); } + public function getNewVote(string $userId): Vote { + $vote = new Vote(); + $vote->setPollId($this->getPollId()); + $vote->setUserId($userId); + $vote->setVoteOptionText($this->getPollOptionText()); + $vote->setVoteOptionId($this->getId()); + $vote->setVoteOptionHash($this->getPollOptionHash()); + + return $vote; + } /** * Set option's text, date and time, and duration from a SimpleOption instance. * This will also sync the option to update the order and hash. diff --git a/lib/Db/V6/TableManager.php b/lib/Db/V6/TableManager.php index 47b7905a56..8c919de0b5 100644 --- a/lib/Db/V6/TableManager.php +++ b/lib/Db/V6/TableManager.php @@ -635,12 +635,20 @@ private function updateVoteHashes(): array { foreach ($this->voteMapper->getAll(includeNull: true) as $vote) { try { // if the hash of the vote differs from calculated hash update the vote hash - if ($vote->getVoteOptionHash() !== Hash::getOptionHash($vote->getPollId(), $vote->getVoteOptionText())) { + if ($vote->getVoteOptionHashInDB() !== Hash::getOptionHash($vote->getPollId(), $vote->getVoteOptionText())) { + $messages[] = 'update voteId ' . $vote->getId() + . ' hash: ' . $vote->getVoteOptionHashInDB() + . ':' . Hash::getOptionHash($vote->getPollId(), $vote->getVoteOptionText()) + . '\'' . $vote->getVoteOptionText() . '\''; + $vote->setVoteOptionHash(Hash::getOptionHash($vote->getPollId(), $vote->getVoteOptionText())); $vote = $this->voteMapper->update($vote); + + $updated++; } + $count++; } catch (Exception $e) { @@ -690,9 +698,17 @@ private function updateOptionHashes(): array { foreach ($this->optionMapper->getAll(includeNull: true) as $option) { try { // if the option's hash differs from $actualHash update the option - if ($option->getPollOptionHash() !== Hash::getOptionHash($option->getPollId(), $option->getPollOptionText())) { + if ($option->getPollOptionHashInDB() !== Hash::getOptionHash($option->getPollId(), $option->getPollOptionText())) { + + $messages[] = 'update optionId ' . $option->getId() + . ' hash: ' . $option->getPollOptionHashInDB() + . ':' . Hash::getOptionHash($option->getPollId(), $option->getPollOptionText()) + . '\'' . $option->getPollOptionText() . '\''; + $option->setText($option->getPollOptionText()); $option = $this->optionMapper->update($option); + + $updated++; } diff --git a/lib/Db/Vote.php b/lib/Db/Vote.php index 5ce56387c1..6257b1a3cb 100644 --- a/lib/Db/Vote.php +++ b/lib/Db/Vote.php @@ -9,6 +9,7 @@ namespace OCA\Polls\Db; use JsonSerializable; +use OCA\Polls\Helper\Hash; /** * @psalm-suppress UnusedProperty @@ -72,6 +73,34 @@ private function getAnswerSymbol(): string { } } + /** + * Get the hash for the vote option. This is generated from the option text + * and poll id. + * + * @return string + * + * @psalm-suppress PossiblyUnusedMethod + */ + public function getVoteOptionHash(): string { + return Hash::getOptionHash( + $this->getPollId(), + $this->getVoteOptionText(), + ); + } + + /** + * Get the hash for the vote option as stored in the database. This is used + * to identify incorrect hashes stored in the db, because getVoteOptionHash() + * which is generated from the option text and poll id. + * + * @return string + * + * @psalm-suppress PossiblyUnusedMethod + */ + public function getVoteOptionHashInDb(): string { + return $this->voteOptionHash; + } + /** * @return array * diff --git a/lib/Model/DateInterval.php b/lib/Model/DateInterval.php index d0c7716cb6..aa27d54445 100644 --- a/lib/Model/DateInterval.php +++ b/lib/Model/DateInterval.php @@ -43,22 +43,56 @@ public function isZeroDuration(): bool { * Get the ISO 8601 duration string representation of this DateInterval. * If the interval is zero (all components are zero), this method returns null. * + * NOTE: Currently not used + * * @return string|null The ISO 8601 duration string or null if the interval is zero. + * + * @psalm-suppress PossiblyUnusedMethod */ - public function getISO(): ?string { - if ( - $this->y === 0 - && $this->m === 0 - && $this->d === 0 - && $this->h === 0 - && $this->i === 0 - && $this->s === 0 - ) { + public function getISOExpanded(): ?string { + if ($this->isZeroDuration()) { return null; } return $this->format('P%yY%mM%dDT%hH%iM%sS'); } + /** + * Get the ISO 8601 duration string representation of this DateInterval, rescaled to a more compact form. + * For example, if the interval represents 2 days and 3 hours, it will return "P2DT3H" instead of "P0Y0M2DT3H0M0S". + * If the interval is zero (all components are zero), this method returns null. + * + * @return string The rescaled ISO 8601 duration string. + */ + public function getISO(): ?string { + if ($this->isZeroDuration()) { + return null; + } + + $parts = ['P']; + if ($this->y > 0) { + $parts[] = $this->y . 'Y'; + } + if ($this->m > 0) { + $parts[] = $this->m . 'M'; + } + if ($this->d > 0) { + $parts[] = $this->d . 'D'; + } + if ($this->h > 0 || $this->i > 0 || $this->s > 0) { + $parts[] = 'T'; + if ($this->h > 0) { + $parts[] = $this->h . 'H'; + } + if ($this->i > 0) { + $parts[] = $this->i . 'M'; + } + if ($this->s > 0) { + $parts[] = $this->s . 'S'; + } + } + return implode('', $parts); + } + /** * Determine if this DateInterval represents one or more full days. * diff --git a/lib/Service/MailService.php b/lib/Service/MailService.php index 5dc49b944e..f9d786b280 100644 --- a/lib/Service/MailService.php +++ b/lib/Service/MailService.php @@ -178,26 +178,20 @@ public function sendNotifications(): void { } } - public function sendInvitation(Share $share, ?SentResult &$sentResult = null): ?SentResult { + public function sendInvitation(Share $share, SentResult &$sentResult = new SentResult()): SentResult { foreach ($this->userMapper->getUserFromShare($share)->getMembers() as $recipient) { $invitation = new InvitationMail($recipient->getId(), $share); try { $invitation->send(); - if ($sentResult) { - $sentResult->AddSentMail($recipient); - } + $sentResult->AddSentMail($recipient); } catch (InvalidEmailAddress $e) { - if ($sentResult) { - $sentResult->AddAbortedMail($recipient, SentResult::INVALID_EMAIL_ADDRESS); - } + $sentResult->AddAbortedMail($recipient, SentResult::INVALID_EMAIL_ADDRESS); $this->logger->warning('Invalid or no email address for invitation', ['recipient' => json_encode($recipient)]); } catch (\Exception $e) { - if ($sentResult) { - $sentResult->AddAbortedMail($recipient); - } + $sentResult->AddAbortedMail($recipient); $this->logger->error('Error sending invitation', [ 'recipient' => json_encode($recipient), diff --git a/lib/Service/OptionService.php b/lib/Service/OptionService.php index 4a2a4c5f4c..cc22f84696 100644 --- a/lib/Service/OptionService.php +++ b/lib/Service/OptionService.php @@ -373,8 +373,7 @@ public function clone(int $fromPollId, int $toPollId): void { ->request(Poll::PERMISSION_OPTION_ADD); foreach ($this->optionMapper->findByPoll($fromPollId) as $origin) { - $option = clone $origin; - $option->setPoll($toPollId); + $option = $origin->createClone($toPollId); $option = $this->optionMapper->insert($option); $this->eventDispatcher->dispatchTyped(new OptionCreatedEvent($option)); } diff --git a/lib/Service/ShareService.php b/lib/Service/ShareService.php index 7cfc718107..071b388d72 100644 --- a/lib/Service/ShareService.php +++ b/lib/Service/ShareService.php @@ -496,11 +496,11 @@ private function lock(Share $share, bool $unlock = false): Share { return $share; } - public function sendAllInvitations(int $pollId): ?SentResult { + public function sendAllInvitations(int $pollId): SentResult { $sentResult = new SentResult(); + $shares = $this->listNotInvited($pollId); // first resolve group shares - $shares = $this->listNotInvited($pollId); foreach ($shares as $share) { if (in_array($share->getType(), Share::RESOLVABLE_SHARES)) { $this->resolveGroup($share); @@ -560,7 +560,7 @@ private function resolveGroup(Share $share): array { * @param Share $share * @param SentResult $sentResult to collect results */ - public function sendInvitation(Share $share, ?SentResult &$sentResult = null): ?SentResult { + public function sendInvitation(Share $share, SentResult &$sentResult = new SentResult()): SentResult { if (in_array($share->getType(), [Share::TYPE_USER, Share::TYPE_ADMIN], true)) { $this->notificationService->sendInvitation($share->getPollId(), $share->getUserId()); } elseif ($share->getType() === Share::TYPE_GROUP) { diff --git a/lib/Service/SubscriptionService.php b/lib/Service/SubscriptionService.php index 864f5240c8..2f0651f1ef 100644 --- a/lib/Service/SubscriptionService.php +++ b/lib/Service/SubscriptionService.php @@ -15,7 +15,6 @@ use OCA\Polls\Exceptions\ForbiddenException; use OCA\Polls\UserSession; use OCP\AppFramework\Db\DoesNotExistException; -use OCP\DB\Exception; class SubscriptionService { /** @psalm-suppress PossiblyUnusedMethod */ @@ -44,7 +43,13 @@ public function set(bool $setToSubscribed, int $pollId): bool { try { if ($setToSubscribed) { $this->pollMapper->get($pollId)->request(Poll::PERMISSION_POLL_SUBSCRIBE); - $this->add($pollId, $this->userSession->getCurrentUserId()); + try { + $this->subscriptionMapper->findByPollAndUser($pollId, $this->userSession->getCurrentUserId()); + // Already subscribed — nothing to do + return true; + } catch (DoesNotExistException) { + $this->add($pollId, $this->userSession->getCurrentUserId()); + } } else { $subscription = $this->subscriptionMapper->findByPollAndUser($pollId, $this->userSession->getCurrentUserId()); $this->subscriptionMapper->delete($subscription); @@ -57,12 +62,6 @@ public function set(bool $setToSubscribed, int $pollId): bool { } catch (ForbiddenException $e) { // Is not allowed to subscribe return false; - } catch (Exception $e) { - if ($e->getReason() === Exception::REASON_UNIQUE_CONSTRAINT_VIOLATION) { - // Is already subscribed - return true; - } - throw $e; } } diff --git a/lib/Service/VoteService.php b/lib/Service/VoteService.php index 94cac01b51..e38015be2e 100644 --- a/lib/Service/VoteService.php +++ b/lib/Service/VoteService.php @@ -54,18 +54,6 @@ public function list(int $pollId): array { return $votes; } - private function checkLimits(Option $option): void { - // check, if the optionlimit is reached or exceeded, if one is set - if ($option->getIsLockedByOptionLimit()) { - throw new VoteLimitExceededException; - } - - if ($option->getIsLockedByVotesLimit()) { - throw new VoteLimitExceededException; - } - return; - } - private function checkVoteLimit(Option $option): void { // check, if the optionlimit is reached or exceeded, if one is set if ($option->getIsLockedByOptionLimit()) { @@ -75,17 +63,16 @@ private function checkVoteLimit(Option $option): void { if ($option->getIsLockedByVotesLimit()) { throw new VoteLimitExceededException; } - return; } /** * Set vote */ - public function set(Option|int $optionOrOptionIdoptionId, string $setTo): ?Vote { - if ($optionOrOptionIdoptionId instanceof Option) { - $option = $optionOrOptionIdoptionId; + public function set(Option|int $optionOrOptionId, string $setTo): ?Vote { + if ($optionOrOptionId instanceof Option) { + $option = $optionOrOptionId; } else { - $option = $this->optionMapper->find($optionOrOptionIdoptionId); + $option = $this->optionMapper->find($optionOrOptionId); } $poll = $this->pollMapper->get($option->getPollId()) ->request(Poll::PERMISSION_VOTE_EDIT); @@ -103,27 +90,24 @@ public function set(Option|int $optionOrOptionIdoptionId, string $setTo): ?Vote } if ($setTo === Vote::VOTE_YES) { - $this->checkLimits($option); + $this->checkVoteLimit($option); } // delete no votes, if poll setting is set to useNo === 0 $deleteVoteInsteadOfNoVote = in_array(trim($setTo), [Vote::VOTE_NO, '']) && !boolval($poll->getUseNo()); if ($deleteVoteInsteadOfNoVote) { - $this->vote->setVoteAnswer(''); $this->voteMapper->delete($this->vote); + $this->eventDispatcher->dispatchTyped(new VoteSetEvent($this->vote)); + return null; } else { $this->vote->setVoteAnswer($setTo); $this->vote = $this->voteMapper->update($this->vote); } } catch (DoesNotExistException $e) { // Vote does not exist, insert as new Vote - $this->vote = new Vote(); + $this->vote = $option->getNewVote($this->userSession->getCurrentUserId()); - $this->vote->setPollId($poll->getId()); - $this->vote->setUserId($this->userSession->getCurrentUserId()); - $this->vote->setVoteOptionText($option->getPollOptionText()); - $this->vote->setVoteOptionId($option->getId()); $this->vote->setVoteAnswer($setTo); $this->vote = $this->voteMapper->insert($this->vote); } @@ -174,6 +158,7 @@ private function delete(int $pollId, string $userId, bool $deleteOnlyOrphaned = // fake a vote so that the event can be triggered // suppress logging of this action $this->vote = new Vote(); + $this->vote->setPollId($pollId); $this->vote->setUserId($userId); $this->voteMapper->deleteByPollAndUserId($pollId, $userId); diff --git a/src/Api/modules/HttpApi.js b/src/Api/modules/HttpApi.js index 6a0adb0467..9ae36d015a 100644 --- a/src/Api/modules/HttpApi.js +++ b/src/Api/modules/HttpApi.js @@ -47,18 +47,18 @@ httpInstance.interceptors.request.use((config) => { const createCancelTokenHandler = (apiObject) => { const cancelTokenHandler = {} Object.getOwnPropertyNames(apiObject).forEach((propertyName) => { - const cancelTokenRequestHandler = { - cancelToken: undefined, - } + const handlers = {} cancelTokenHandler[propertyName] = { - handleRequestCancellation: () => { - cancelTokenRequestHandler.cancelToken - && cancelTokenRequestHandler.cancelToken.cancel( - `${propertyName} canceled`, - ) - cancelTokenRequestHandler.cancelToken = CancelToken.source() - return cancelTokenRequestHandler.cancelToken + handleRequestCancellation: (subKey) => { + const key = String(subKey ?? '__default__') + if (!handlers[key]) { + handlers[key] = { cancelToken: undefined } + } + const handler = handlers[key] + handler.cancelToken?.cancel(`${propertyName} canceled`) + handler.cancelToken = CancelToken.source() + return handler.cancelToken }, } }) diff --git a/src/Api/modules/options.ts b/src/Api/modules/options.ts index 1aeb8c0f99..789470dc11 100644 --- a/src/Api/modules/options.ts +++ b/src/Api/modules/options.ts @@ -7,10 +7,10 @@ import { httpInstance, createCancelTokenHandler } from './HttpApi' import type { DateTimeUnits } from '../../Types/dateTime' import type { AxiosResponse } from '@nextcloud/axios' import type { Vote } from '../../stores/votes.types' -import type { Option, Sequence, SimpleOption } from '../../stores/options.types' +import type { Option, OptionDto, Sequence, SimpleOption } from '../../stores/options.types' const options = { - getOptions(pollId: number): Promise> { + getOptions(pollId: number): Promise> { return httpInstance.request({ method: 'GET', url: `poll/${pollId}/options`, @@ -29,8 +29,8 @@ const options = { voteYes: boolean = false, ): Promise< AxiosResponse<{ - added: Option[] - options: Option[] + added: OptionDto[] + options: OptionDto[] votes: Vote[] }> > { @@ -46,7 +46,7 @@ const options = { }) }, - updateOption(option: Option): Promise> { + updateOption(option: Option): Promise> { return httpInstance.request({ method: 'PUT', url: `option/${option.id}`, @@ -58,7 +58,7 @@ const options = { }) }, - deleteOption(optionId: number): Promise> { + deleteOption(optionId: number): Promise> { return httpInstance.request({ method: 'DELETE', url: `option/${optionId}`, @@ -70,7 +70,7 @@ const options = { }) }, - restoreOption(optionId: number): Promise> { + restoreOption(optionId: number): Promise> { return httpInstance.request({ method: 'PUT', url: `option/${optionId}/restore`, @@ -88,9 +88,9 @@ const options = { optionsBatch: string, ): Promise< AxiosResponse<{ - option: Option - repetitions: Option[] - options: Option[] + option: OptionDto + repetitions: OptionDto[] + options: OptionDto[] votes: Vote[] }> > { @@ -108,7 +108,7 @@ const options = { }) }, - confirmOption(optionId: number): Promise> { + confirmOption(optionId: number): Promise> { return httpInstance.request({ method: 'PUT', url: `option/${optionId}/confirm`, @@ -125,7 +125,7 @@ const options = { id: number text: string }[], - ): Promise> { + ): Promise> { return httpInstance.request({ method: 'POST', url: `poll/${pollId}/options/reorder`, @@ -140,7 +140,7 @@ const options = { addOptionsSequence( optionId: number, sequence: Sequence, - ): Promise> { + ): Promise> { return httpInstance.request({ method: 'POST', url: `option/${optionId}/sequence`, @@ -158,7 +158,7 @@ const options = { pollId: number, step: number, unit: DateTimeUnits, - ): Promise> { + ): Promise> { return httpInstance.request({ method: 'POST', url: `poll/${pollId}/shift`, diff --git a/src/Api/modules/public.ts b/src/Api/modules/public.ts index 957682dfcc..f864372e03 100644 --- a/src/Api/modules/public.ts +++ b/src/Api/modules/public.ts @@ -9,7 +9,7 @@ import type { AxiosResponse } from '@nextcloud/axios' import type { Session } from '../../stores/session.types' import type { Comment } from '../../stores/comments.types' import type { Share } from '../../stores/shares.types' -import type { Option, Sequence, SimpleOption } from '../../stores/options.types' +import type { OptionDto, Sequence, SimpleOption } from '../../stores/options.types' import type { Answer, Vote } from '../../stores/votes.types' import type { @@ -44,7 +44,7 @@ const publicPoll = { }) }, - getOptions(shareToken: string): Promise> { + getOptions(shareToken: string): Promise> { return httpInstance.request({ method: 'GET', url: `/s/${shareToken}/options`, @@ -76,7 +76,7 @@ const publicPoll = { deleteOption( shareToken: string, optionId: number, - ): Promise> { + ): Promise> { return httpInstance.request({ method: 'DELETE', url: `s/${shareToken}/option/${optionId}`, @@ -91,7 +91,7 @@ const publicPoll = { restoreOption( shareToken: string, optionId: number, - ): Promise> { + ): Promise> { return httpInstance.request({ method: 'PUT', url: `s/${shareToken}/option/${optionId}/restore`, @@ -131,7 +131,7 @@ const publicPoll = { cancelToken: cancelTokenHandlerObject[ this.setVote.name - ].handleRequestCancellation().token, + ].handleRequestCancellation(optionId).token, }) }, diff --git a/src/Api/modules/shares.ts b/src/Api/modules/shares.ts index bdb36265ba..da5dc9b9bc 100644 --- a/src/Api/modules/shares.ts +++ b/src/Api/modules/shares.ts @@ -181,7 +181,7 @@ const shares = { inviteAll( pollId: number, - ): Promise> { + ): Promise> { return httpInstance.request({ method: 'PUT', url: `poll/${pollId}/inviteAll`, diff --git a/src/Api/modules/votes.ts b/src/Api/modules/votes.ts index 4e9b269ca3..5e5ce76a43 100644 --- a/src/Api/modules/votes.ts +++ b/src/Api/modules/votes.ts @@ -35,7 +35,7 @@ const votes = { cancelToken: cancelTokenHandlerObject[ this.setVote.name - ].handleRequestCancellation().token, + ].handleRequestCancellation(optionId).token, }) }, diff --git a/src/App.vue b/src/App.vue index b20de7803b..081e1245a4 100644 --- a/src/App.vue +++ b/src/App.vue @@ -86,18 +86,18 @@ function transitionsOff(delay: number) { } } -/** - * - * @param payload - * @param payload.store - * @param payload.message - */ -function notify(payload: { store: string; message: string }) { - debounce(async function () { - if (payload.store === 'poll') { - showSuccess(payload.message) - } - }, 1500) +const debouncedNotify = debounce(async function (payload: { store: string; message: string }) { + if (payload.store === 'poll') { + showSuccess(payload.message) + } +}, 1500) + +function onTransitionsOff(delay: number) { + transitionsOff(delay) +} + +function onTransitionsOn() { + transitionsOn() } watchEffect(() => { @@ -105,25 +105,15 @@ watchEffect(() => { }) onMounted(() => { - subscribe(Event.TransitionsOff, (delay) => { - transitionsOff(delay) - }) - - subscribe(Event.TransitionsOn, () => { - transitionsOn() - }) - - subscribe(Event.UpdatePoll, (payload) => { - notify(payload) - }) + subscribe(Event.TransitionsOff, onTransitionsOff) + subscribe(Event.TransitionsOn, onTransitionsOn) + subscribe(Event.UpdatePoll, debouncedNotify) }) onUnmounted(() => { - unsubscribe(Event.TransitionsOn, () => { - transitionsOn() - }) - unsubscribe(Event.TransitionsOff, () => {}) - unsubscribe(Event.UpdatePoll, () => {}) + unsubscribe(Event.TransitionsOff, onTransitionsOff) + unsubscribe(Event.TransitionsOn, onTransitionsOn) + unsubscribe(Event.UpdatePoll, debouncedNotify) }) diff --git a/src/Types/index.ts b/src/Types/index.ts index d21c84f33e..9bedf6f402 100644 --- a/src/Types/index.ts +++ b/src/Types/index.ts @@ -90,9 +90,22 @@ export type Participant = { user: User } -/** - * - */ -export function createDefault(): T { - return {} as T +export const defaultUser: User = { + id: '', + displayName: '', + emailAddress: '', + isAdmin: false, + isNoUser: true, + isGuest: true, + type: '', + subName: null, + subtitle: null, + desc: null, + organisation: null, + languageCode: '', + languageCodeIntl: '', + localeCode: null, + localeCodeIntl: null, + timeZone: null, + categories: null, } diff --git a/src/components/Comments/CommentAdd.vue b/src/components/Comments/CommentAdd.vue index b6e8f8e839..f3c8b05cd6 100644 --- a/src/components/Comments/CommentAdd.vue +++ b/src/components/Comments/CommentAdd.vue @@ -21,7 +21,8 @@ const commentsStore = useCommentsStore() const sessionStore = useSessionStore() const pollStore = usePollStore() const comment = ref('') -const confidantial = ref(false) +const confidential = ref(false) + const confidentialText = pollStore.owner.id === sessionStore.currentUser.id ? t('polls', 'Only visible to me') @@ -29,15 +30,12 @@ const confidentialText = displayName: pollStore.owner.displayName, }) -/** - * - */ async function writeComment() { if (comment.value) { try { await commentsStore.add({ message: comment.value, - confidential: confidantial.value, + confidential: confidential.value, }) comment.value = '' } catch { @@ -58,7 +56,7 @@ async function writeComment() { @submit="writeComment()" /> {{ confidentialText }} diff --git a/src/components/Options/OptionsDateAddDialog.vue b/src/components/Options/OptionsDateAddDialog.vue index ca1f8b36be..321279556d 100644 --- a/src/components/Options/OptionsDateAddDialog.vue +++ b/src/components/Options/OptionsDateAddDialog.vue @@ -90,7 +90,6 @@ const startDateTime = ref( /** * Resulting option based on the inputs */ - const newOption = computed(() => { const dateTime = startDateTime.value .startOf(allDay.value ? 'day' : 'minute') @@ -99,7 +98,7 @@ const newOption = computed(() => { const dur = duration.value const option: SimpleOption = { text: '', - isoTimestamp: dateTime.toISO() || '', + isoTimestamp: dateTime.toISO({ precision: 'seconds' }) || '', isoDuration: dur.toISO() || 'PT0S', getDateTime: () => dateTime, getDuration: () => dur, @@ -156,13 +155,13 @@ const duration = computed(() => ? Duration.fromObject({ day: 1 }) : Duration.fromObject({ [durationInput.value.unit.id]: durationInput.value.amount, - }), + }).rescale(), ) /** * Computed as boolean if the option is blocked by an existing option */ -const blockedOption = computed(() => { +const isBlockedOption = computed(() => { const option = duplicateOption.value return option && !option.deleted }) @@ -189,7 +188,7 @@ const voteYes = ref(true) * Used to enable/disable the add button * @return computed boolean */ -const addable = computed(() => !blockedOption.value && result.value !== 'loading') +const addable = computed(() => !isBlockedOption.value && result.value !== 'loading') // *** Miscellaneous captions @@ -353,13 +352,13 @@ const SequenceUnitSelectProps = computed(() => ({
{{ t('polls', 'Option already exists') }}
{ + try { + const result = await sharesStore.inviteAll({ pollId: pollStore.id }) + + result.sentMails.forEach((item) => { showSuccess( t('polls', 'Invitation sent to {displayName} ({emailAddress})', { emailAddress: item.emailAddress, @@ -35,9 +36,8 @@ async function sendAllInvitations() { }), ) }) - } - if (response?.data.sentResult?.abortedMails) { - response.data.sentResult.abortedMails.forEach((item) => { + + result.abortedMails.forEach((item) => { Logger.error('Mail could not be sent!', { recipient: item }) showError( t( @@ -50,6 +50,8 @@ async function sendAllInvitations() { ), ) }) + } catch { + showError(t('polls', 'Error sending invitations')) } } diff --git a/src/components/User/UserAvatar.vue b/src/components/User/UserAvatar.vue index f1aacc1533..38aa7a78e0 100644 --- a/src/components/User/UserAvatar.vue +++ b/src/components/User/UserAvatar.vue @@ -7,7 +7,7 @@ import NcAvatar from '@nextcloud/vue/components/NcAvatar' import { computed } from 'vue' import { useRoute } from 'vue-router' -import { createDefault } from '../../Types' +import { defaultUser } from '../../Types' import type { User, VirtualUserItemType } from '../../Types' import LinkIcon from 'vue-material-design-icons/Link.vue' import ContactGroupIcon from 'vue-material-design-icons/AccountGroupOutline.vue' @@ -26,7 +26,7 @@ interface Props { const { mdIconSize = 20, virtualUserType, - user = createDefault(), + user = defaultUser, } = defineProps() const computedIsGuest = computed( diff --git a/src/components/User/UserBubble.vue b/src/components/User/UserBubble.vue index 520d7a4ddf..ab97867544 100644 --- a/src/components/User/UserBubble.vue +++ b/src/components/User/UserBubble.vue @@ -9,13 +9,13 @@ import { t } from '@nextcloud/l10n' import NcUserBubble from '@nextcloud/vue/components/NcUserBubble' -import { createDefault, type User } from '../../Types' +import { defaultUser, type User } from '../../Types' interface Props { user: User } -const { user = createDefault() } = defineProps() +const { user = defaultUser } = defineProps() const bubbleProps = computed(() => ({ user: user.isNoUser || user.isGuest ? undefined : user.id, diff --git a/src/components/User/UserItem.vue b/src/components/User/UserItem.vue index f90ec9b79e..a368f36f2c 100644 --- a/src/components/User/UserItem.vue +++ b/src/components/User/UserItem.vue @@ -7,7 +7,7 @@ import { computed } from 'vue' import { t } from '@nextcloud/l10n' -import { createDefault, User, UserType, VirtualUserItemType } from '../../Types' +import { defaultUser, User, UserType, VirtualUserItemType } from '../../Types' import UserAvatar from './UserAvatar.vue' defineOptions({ inheritAttrs: false }) @@ -32,7 +32,7 @@ const { showEmail = false, tag = 'div', virtualUserType, - user = createDefault(), + user = defaultUser, itemStyle = {}, } = defineProps() diff --git a/src/components/VoteTable/VoteButton.vue b/src/components/VoteTable/VoteButton.vue index e8e010c46e..aeb46fce13 100644 --- a/src/components/VoteTable/VoteButton.vue +++ b/src/components/VoteTable/VoteButton.vue @@ -4,7 +4,8 @@ --> diff --git a/src/composables/context.ts b/src/composables/context.ts index b03b348a0e..a53d98e300 100644 --- a/src/composables/context.ts +++ b/src/composables/context.ts @@ -25,16 +25,21 @@ async function loadContext( const sessionStore = useSessionStore() const preferencesStore = usePreferencesStore() - await sessionStore.load({ to, cheapLoading }) + sessionStore.setRouter(to) - if (!cheapLoading) { - Settings.defaultLocale = - sessionStore.currentUser.localeCodeIntl - || sessionStore.currentUser.languageCodeIntl + if (cheapLoading) { + Logger.info('Context loaded (cheap)') + return + } + + await sessionStore.loadSession() + + Settings.defaultLocale = + sessionStore.currentUser.localeCodeIntl + || sessionStore.currentUser.languageCodeIntl - if (sessionStore.userStatus.isLoggedin) { - await preferencesStore.load() - } + if (sessionStore.userStatus.isLoggedin) { + await preferencesStore.load() } Logger.info('Context loaded') } diff --git a/src/composables/usePollWatcher.ts b/src/composables/usePollWatcher.ts index 886955a3e3..1a8727b87a 100644 --- a/src/composables/usePollWatcher.ts +++ b/src/composables/usePollWatcher.ts @@ -116,7 +116,9 @@ export const usePollWatcher = (interval = 30000) => { if (message) Logger.info(`[PollWatcher] ${message}`, { params }) if (status === 'modeChanged') { - sessionStore.load() + sessionStore.loadSession().catch((error) => { + Logger.error("[PollWatcher] Error reloading session after mode change", { error }) + }) } break @@ -192,7 +194,11 @@ export const usePollWatcher = (interval = 30000) => { // User is not allowed to load shares. // instead assume a changed share affecting the user. // Reload the session context to update the share - sessionStore.load() + try { + await sessionStore.loadSession() + } catch (loadError) { + Logger.error('[PollWatcher] Error reloading session after share change', { error: loadError }) + } return } Logger.error('Error loading poll shares', { error }) diff --git a/src/router.ts b/src/router.ts index 0a737f624c..fe9d389012 100644 --- a/src/router.ts +++ b/src/router.ts @@ -66,6 +66,9 @@ async function validateToken(to: RouteLocationNormalized) { } } } + + // Proceed with the public vote page + return true } const Combo = () => import('./views/Combo.vue') diff --git a/src/stores/comments.ts b/src/stores/comments.ts index cad4c48929..33c6a5970b 100644 --- a/src/stores/comments.ts +++ b/src/stores/comments.ts @@ -77,10 +77,7 @@ export const useCommentsStore = defineStore('comments', { if (!response) { this.$reset() - return } - - this.load() } catch (error) { if ((error as AxiosError)?.code === 'ERR_CANCELED') { return @@ -90,6 +87,8 @@ export const useCommentsStore = defineStore('comments', { payload, }) throw error + } finally { + this.load() } }, diff --git a/src/stores/options.ts b/src/stores/options.ts index 82ffc637ed..a9486ae346 100644 --- a/src/stores/options.ts +++ b/src/stores/options.ts @@ -21,7 +21,6 @@ import type { OptionDto, Option, OptionsStore, - DateOptionFinder, HasIsoFields, OptionDurationMethod, OptionTimestampMethod, @@ -36,7 +35,7 @@ const withLuxon = ( ...dto, getDuration() { - return Duration.fromISO(this.isoDuration ?? 'PT0S') + return Duration.fromISO(this.isoDuration ?? 'PT0S').rescale() }, getDateTime() { @@ -101,12 +100,13 @@ export const useOptionsStore = defineStore('options', { return optionsDto.map(hydrateOption) }, - find(option: DateOptionFinder): Option | undefined { + find(option: SimpleOption): Option | undefined { if (option) { return this.options.find( (opt) => - opt.isoTimestamp === option.isoTimestamp - && opt.isoDuration === option.isoDuration, + opt.getDateTime().toSeconds() + === option.getDateTime().toSeconds() + && opt.getDuration().equals(option.getDuration()), ) } }, @@ -144,15 +144,16 @@ export const useOptionsStore = defineStore('options', { } }, - updateOption(payload: { option: Option }) { + updateOption(payload: { option: OptionDto }) { + const hydrated = hydrateOption(payload.option) const index = this.options.findIndex( - (option) => option.id === payload.option.id, + (option) => option.id === hydrated.id, ) if (index < 0) { - this.options.push(payload.option) + this.options.push(hydrated) } else { - this.options.splice(index, 1, payload.option) + this.options.splice(index, 1, hydrated) } this.options.sort((a, b) => a.order < b.order ? -1 : a.order > b.order ? 1 : 0, diff --git a/src/stores/poll.ts b/src/stores/poll.ts index 241c3454f7..2c1021edf0 100644 --- a/src/stores/poll.ts +++ b/src/stores/poll.ts @@ -16,13 +16,12 @@ import { emit } from '@nextcloud/event-bus' import { Logger } from '../helpers/modules/logger' import { PublicAPI, PollsAPI } from '../Api' -import { createDefault, Event } from '../Types' +import { defaultUser, Event } from '../Types' import { usePollsStore } from './polls' import { useSessionStore } from './session' import type { AxiosError } from '@nextcloud/axios' -import type { User } from '../Types' import type { Poll, PollType, @@ -72,7 +71,7 @@ export const usePollStore = defineStore('poll', { maxVotesPerUser: 0, timezoneName: null, }, - owner: createDefault(), + owner: defaultUser, pollGroups: [], status: { anonymizeLevel: 'ANON_NONE', @@ -181,7 +180,7 @@ export const usePollStore = defineStore('poll', { return ( state.configuration.showResults === 'always' || (state.configuration.showResults === 'closed' - && !this.status.isExpired) + && this.status.isExpired) ) }, @@ -214,7 +213,7 @@ export const usePollStore = defineStore('poll', { }, isConfirmationAllowed(state): boolean { - return state.permissions.confirmOptions || !this.isClosed + return state.permissions.confirmOptions && this.isClosed }, isOptionCloneAllowed(state): boolean { diff --git a/src/stores/pollGroups.ts b/src/stores/pollGroups.ts index 611f0211cf..4d66809782 100644 --- a/src/stores/pollGroups.ts +++ b/src/stores/pollGroups.ts @@ -211,7 +211,7 @@ export const usePollGroupsStore = defineStore('pollGroups', () => { throw error } } finally { - // pollsStore.load() + pollsStore.load() } } diff --git a/src/stores/polls.ts b/src/stores/polls.ts index 2f907ad254..686d8310c8 100644 --- a/src/stores/polls.ts +++ b/src/stores/polls.ts @@ -454,7 +454,7 @@ export const usePollsStore = defineStore('polls', { if ((error as AxiosError)?.code === 'ERR_CANCELED') { return } - Logger.error('Error archiving/restoring poll', { + Logger.error('Error taking over poll', { error, payload, }) diff --git a/src/stores/session.ts b/src/stores/session.ts index 5607ab3c00..a329f5ef1c 100644 --- a/src/stores/session.ts +++ b/src/stores/session.ts @@ -18,14 +18,13 @@ import { usePreferencesStore } from './preferences' import { usePollStore } from './poll' import { usePollsStore } from './polls' -import { createDefault } from '../Types' +import { defaultUser } from '../Types' import type { AxiosError } from '@nextcloud/axios' import type { ViewMode } from './preferences.types' -import type { Share } from './shares.types' +import { defaultShare } from './shares.types' import type { PollType } from './poll.types' import type { FilterType } from './polls.types' -import type { User } from '../Types' import type { SessionStore } from './session.types' import { IANAZone } from 'luxon' @@ -113,8 +112,8 @@ export const useSessionStore = defineStore('session', { lastUpdate: Math.floor(Date.now() / 1000), }, token: null, - currentUser: createDefault(), - share: createDefault(), + currentUser: defaultUser, + share: defaultShare, }), getters: { @@ -255,33 +254,10 @@ export const useSessionStore = defineStore('session', { this.watcher.id = Math.random().toString(36).substring(2) }, - async load( - payload: { - to?: null | RouteLocationNormalized - cheapLoading?: boolean - reload?: boolean - } = { - to: null, - cheapLoading: false, - reload: false, - }, - ) { + async loadSession() { Logger.debug('Loading session') this.generateWatcherId() - if (payload.to) { - Logger.debug('Set requested route', { to: payload.to }) - await this.setRouter(payload.to) - Logger.debug('Route set', { route: this.route }) - } - - if (payload.reload) { - Logger.debug('Reloading session') - } else if (payload.cheapLoading) { - Logger.debug('Same route, skipping session load') - return - } - try { const response = await (() => { if (this.route.name === 'publicVote') { @@ -297,11 +273,7 @@ export const useSessionStore = defineStore('session', { } this.$reset() - if (this.route.name === null) { - this.$reset() - } else { - throw error - } + throw error } Logger.debug('Session loaded') }, @@ -327,7 +299,7 @@ export const useSessionStore = defineStore('session', { // Share store async loadShare(): Promise { if (this.route.name !== 'publicVote') { - this.share = createDefault() + this.share = defaultShare return } diff --git a/src/stores/shares.ts b/src/stores/shares.ts index 2e2323b298..bf2b69716a 100644 --- a/src/stores/shares.ts +++ b/src/stores/shares.ts @@ -215,15 +215,20 @@ export const useSharesStore = defineStore('shares', { } }, - async inviteAll(payload: { pollId: number }) { + async inviteAll(payload: { pollId: number }): Promise { try { const response = await SharesAPI.inviteAll(payload.pollId) this.load() - return response + return response.data.sentResult } catch (error) { this.handleError(error, 'Error inviting all users', payload) } + return { + sentMails: [], + abortedMails: [], + } }, + async sendInvitation(payload: { share: Share }): Promise<{ share: Share sentResult: null | SentResults diff --git a/src/stores/shares.types.ts b/src/stores/shares.types.ts index bfa90247ba..32912d1eea 100644 --- a/src/stores/shares.types.ts +++ b/src/stores/shares.types.ts @@ -3,6 +3,7 @@ * SPDX-License-Identifier: AGPL-3.0-or-later */ +import { defaultUser } from '../Types' import type { User } from '../Types' export type ShareType = @@ -40,6 +41,26 @@ export type Share = { deleted: boolean } +export const defaultShare: Share = { + displayName: '', + id: '', + invitationSent: false, + locked: false, + pollId: null, + groupId: null, + token: '', + type: 'public', + emailAddress: '', + userId: '', + publicPollEmail: 'optional', + user: defaultUser, + reminderSent: false, + label: '', + URL: '', + voted: false, + deleted: false, +} + export type SharesStore = { shares: Share[] } diff --git a/src/stores/votes.ts b/src/stores/votes.ts index ecf8fb0061..8f604b43a8 100644 --- a/src/stores/votes.ts +++ b/src/stores/votes.ts @@ -43,20 +43,19 @@ export const useVotesStore = defineStore('votes', { * @param state * @return */ - participants: (state): User[] => - Array.from( + participants(state): User[] { + const { localeCodeIntl } = useSessionStore().currentUser + return Array.from( new Map( state.votes.map((vote) => [vote.user.id, vote.user]), ).values(), - ).sort((aUser, bUser) => { - if (aUser.displayName < bUser.displayName) { - return -1 - } - if (aUser.displayName > bUser.displayName) { - return 1 - } - return 0 - }), + ).sort((a, b) => + a.displayName.localeCompare( + b.displayName, + localeCodeIntl ?? navigator.language, + ), + ) + }, loadedParticipants(state: VotesStore): number { return Math.min( @@ -79,7 +78,11 @@ export const useVotesStore = defineStore('votes', { const sessionStore = useSessionStore() const pollStore = usePollStore() if (pollStore.viewMode === 'list-view') { - return ['user', 'external', 'admin'].includes(sessionStore.currentUser.type) ? [sessionStore.currentUser] : [] + return ['user', 'external', 'admin'].includes( + sessionStore.currentUser.type, + ) + ? [sessionStore.currentUser] + : [] } if ( @@ -118,7 +121,9 @@ export const useVotesStore = defineStore('votes', { if ( currentUserIndex < 0 && !pollStore.status.isExpired - && ['user', 'external', 'admin'].includes(sessionStore.currentUser.type) + && ['user', 'external', 'admin'].includes( + sessionStore.currentUser.type, + ) ) { // add current user to the begining of the list if not already present // and if the poll is not expired @@ -229,14 +234,12 @@ export const useVotesStore = defineStore('votes', { && matchingAnswer.includes(vote.answer), ) .map((vote) => vote.user) - .sort((aUser, bUser) => { - if (aUser.displayName < bUser.displayName) { - return -1 - } - if (aUser.displayName > bUser.displayName) { - return 1 - } - return 0 + .sort((a, b) => { + const { localeCodeIntl } = useSessionStore().currentUser + return a.displayName.localeCompare( + b.displayName, + localeCodeIntl ?? navigator.language, + ) }) }, @@ -304,8 +307,7 @@ export const useVotesStore = defineStore('votes', { this.votes.push(payload.vote) }, - async set(payload: { option: Option; setTo: Answer }) { - // place a fake vote for a slightly better UX in huge polls + setOptimistic(payload: { option: Option; setTo: Answer }) { this.setItem({ option: payload.option, vote: { @@ -319,6 +321,10 @@ export const useVotesStore = defineStore('votes', { pollId: payload.option.pollId, }, }) + }, + + async set(payload: { option: Option; setTo: Answer }) { + this.setOptimistic(payload) const sessionStore = useSessionStore() const pollStore = usePollStore() @@ -342,10 +348,6 @@ export const useVotesStore = defineStore('votes', { pollStore.load() } - this.setItem({ - option: payload.option, - vote: response.data.vote, - }) StoreHelper.updateStores(response.data) return response @@ -358,7 +360,7 @@ export const useVotesStore = defineStore('votes', { pollStore.load() throw error } else { - Logger.error('Error setting vote aa', { + Logger.error('Error setting vote', { error, payload, }) @@ -367,7 +369,7 @@ export const useVotesStore = defineStore('votes', { } }, - async setSort(payload: { optionId: number }) { + setSort(payload: { optionId: number }) { this.sortByOption = payload.optionId }, diff --git a/src/views/Navigation.vue b/src/views/Navigation.vue index 20bf5bcfdf..5caf39250d 100644 --- a/src/views/Navigation.vue +++ b/src/views/Navigation.vue @@ -107,11 +107,9 @@ function getIconComponent(iconId: FilterType) { * @param pollId */ function toggleArchive(pollId: number) { - try { - pollsStore.toggleArchive({ pollId }) - } catch { + pollsStore.toggleArchive({ pollId }).catch(() => { showError(t('polls', 'Error archiving/restoring poll.')) - } + }) } /** @@ -120,11 +118,9 @@ function toggleArchive(pollId: number) { * @param pollId poll id to delete */ function deletePoll(pollId: number) { - try { - pollsStore.delete({ pollId }) - } catch { + pollsStore.delete({ pollId }).catch(() => { showError(t('polls', 'Error deleting poll.')) - } + }) } /** @@ -132,11 +128,9 @@ function deletePoll(pollId: number) { * @param pollId poll id to clone */ function clonePoll(pollId: number) { - try { - pollsStore.clone({ pollId }) - } catch { + pollsStore.clone({ pollId }).catch(() => { showError(t('polls', 'Error cloning poll.')) - } + }) } /** diff --git a/src/views/Vote.vue b/src/views/Vote.vue index 10287ce491..d4aeea0b88 100644 --- a/src/views/Vote.vue +++ b/src/views/Vote.vue @@ -140,14 +140,24 @@ const showBottomObserver = computed( async function loadPoll(isChanging: boolean = false) { try { - pollStore.load(isChanging) - } catch (error) { + await pollStore.load(isChanging) + } catch { showError(t('polls', 'Error loading poll')) + votesStore.$reset() + optionsStore.$reset() + subscriptionStore.$reset() + return } - votesStore.load() - optionsStore.load() - subscriptionStore.load() + const results = await Promise.allSettled([ + votesStore.load(), + optionsStore.load(), + subscriptionStore.load(), + ]) + + if (results.some((result) => result.status === 'rejected')) { + showError(t('polls', 'Error loading poll data')) + } } async function resetPoll() { diff --git a/tests/Unit/Service/VoteServiceTest.php b/tests/Unit/Service/VoteServiceTest.php index 4cb69059d8..0d4cb161d4 100644 --- a/tests/Unit/Service/VoteServiceTest.php +++ b/tests/Unit/Service/VoteServiceTest.php @@ -147,8 +147,8 @@ public function testSetVoteNoDeletesExistingVoteWhenUseNoIsFalse(): void { // useNo=0 (default): setting 'no' on an existing vote deletes it $this->voteService->set($this->option->getId(), Vote::VOTE_YES); $result = $this->voteService->set($this->option->getId(), Vote::VOTE_NO); - // Service returns the vote entity with empty answer after deletion - $this->assertSame('', $result->getVoteAnswer()); + // Service returns null after deletion + $this->assertNull($result); } public function testSetVoteNoStoredWhenUseNoIsTrue(): void {