From 9f11cc551cb414ee23e75aa7cf23711dd2dc836d Mon Sep 17 00:00:00 2001 From: dartcafe Date: Sun, 8 Mar 2026 07:59:07 +0100 Subject: [PATCH 01/22] change event handling in App.vue Signed-off-by: dartcafe --- src/App.vue | 46 ++++++++++++++++++---------------------------- 1 file changed, 18 insertions(+), 28 deletions(-) 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) }) From 9fb0f4dd9579ecf3ca3fafe35b21db08945d3cf8 Mon Sep 17 00:00:00 2001 From: dartcafe Date: Sun, 8 Mar 2026 08:03:25 +0100 Subject: [PATCH 02/22] fix generic defaults Signed-off-by: dartcafe --- src/Types/index.ts | 23 ++++++++++++++++++----- src/components/User/UserAvatar.vue | 4 ++-- src/components/User/UserBubble.vue | 4 ++-- src/components/User/UserItem.vue | 4 ++-- src/stores/poll.ts | 5 ++--- src/stores/session.ts | 11 +++++------ src/stores/shares.types.ts | 21 +++++++++++++++++++++ 7 files changed, 52 insertions(+), 20 deletions(-) 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/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/stores/poll.ts b/src/stores/poll.ts index 241c3454f7..c18f56b70e 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', diff --git a/src/stores/session.ts b/src/stores/session.ts index 5607ab3c00..4299584ca8 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: { @@ -327,7 +326,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.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[] } From cadc555058225c9d5effcd0a7a6cc43b5b19692c Mon Sep 17 00:00:00 2001 From: dartcafe Date: Sun, 8 Mar 2026 08:13:48 +0100 Subject: [PATCH 03/22] sort participants locale aware Signed-off-by: dartcafe --- src/stores/votes.ts | 29 ++++++++++------------------- 1 file changed, 10 insertions(+), 19 deletions(-) diff --git a/src/stores/votes.ts b/src/stores/votes.ts index ecf8fb0061..a817c0a044 100644 --- a/src/stores/votes.ts +++ b/src/stores/votes.ts @@ -43,20 +43,16 @@ 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( @@ -229,14 +225,9 @@ 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) }) }, From 5b4feb617b895ab2faab51695c28bd7dc7b107c0 Mon Sep 17 00:00:00 2001 From: dartcafe Date: Sun, 8 Mar 2026 08:42:03 +0100 Subject: [PATCH 04/22] async store loading with error handling Signed-off-by: dartcafe --- src/views/Vote.vue | 20 +++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) 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() { From 27d77f4585ef8e02c2a6bb47e9eb9ea678ea7b5f Mon Sep 17 00:00:00 2001 From: dartcafe Date: Sun, 8 Mar 2026 13:08:48 +0100 Subject: [PATCH 05/22] fixed condition Signed-off-by: dartcafe --- src/stores/poll.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/stores/poll.ts b/src/stores/poll.ts index c18f56b70e..f63cb2d7f8 100644 --- a/src/stores/poll.ts +++ b/src/stores/poll.ts @@ -180,7 +180,7 @@ export const usePollStore = defineStore('poll', { return ( state.configuration.showResults === 'always' || (state.configuration.showResults === 'closed' - && !this.status.isExpired) + && this.status.isExpired) ) }, From 8cf29ff0ac67bcd494ea0e1f810e884ee4d78c29 Mon Sep 17 00:00:00 2001 From: dartcafe Date: Sun, 8 Mar 2026 13:14:09 +0100 Subject: [PATCH 06/22] better debounced handling of votes Signed-off-by: dartcafe --- src/Api/modules/HttpApi.js | 20 ++++++++++---------- src/Api/modules/public.ts | 2 +- src/Api/modules/votes.ts | 2 +- src/components/VoteTable/VoteButton.vue | 19 +++++++++++++++---- src/stores/votes.ts | 7 +++++-- 5 files changed, 32 insertions(+), 18 deletions(-) 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/public.ts b/src/Api/modules/public.ts index 957682dfcc..174234d217 100644 --- a/src/Api/modules/public.ts +++ b/src/Api/modules/public.ts @@ -131,7 +131,7 @@ const publicPoll = { cancelToken: cancelTokenHandlerObject[ this.setVote.name - ].handleRequestCancellation().token, + ].handleRequestCancellation(optionId).token, }) }, 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/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/stores/votes.ts b/src/stores/votes.ts index a817c0a044..a7e8c50e11 100644 --- a/src/stores/votes.ts +++ b/src/stores/votes.ts @@ -295,8 +295,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: { @@ -310,6 +309,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() From d192d7ee84c469876a23a732690332920b5a095c Mon Sep 17 00:00:00 2001 From: dartcafe Date: Sun, 8 Mar 2026 15:18:07 +0100 Subject: [PATCH 07/22] cleanup sessoin loading Signed-off-by: dartcafe --- src/composables/context.ts | 21 +++++++++++++-------- src/composables/usePollWatcher.ts | 10 ++++++++-- src/router.ts | 3 +++ src/stores/session.ts | 31 ++----------------------------- 4 files changed, 26 insertions(+), 39 deletions(-) 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/session.ts b/src/stores/session.ts index 4299584ca8..a329f5ef1c 100644 --- a/src/stores/session.ts +++ b/src/stores/session.ts @@ -254,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') { @@ -296,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') }, From 604dd840473e8bf2929f2d164360102244380d83 Mon Sep 17 00:00:00 2001 From: dartcafe Date: Sun, 8 Mar 2026 17:21:45 +0100 Subject: [PATCH 08/22] fixing options Signed-off-by: dartcafe --- src/Api/modules/options.ts | 28 ++++++++++++++-------------- src/Api/modules/public.ts | 8 ++++---- src/stores/options.ts | 9 +++++---- 3 files changed, 23 insertions(+), 22 deletions(-) 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 174234d217..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`, diff --git a/src/stores/options.ts b/src/stores/options.ts index 82ffc637ed..d80e978b8e 100644 --- a/src/stores/options.ts +++ b/src/stores/options.ts @@ -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, From 92c06cc3cb51a7862a38d12637391fd86ca5ae7a Mon Sep 17 00:00:00 2001 From: dartcafe Date: Sun, 8 Mar 2026 19:36:39 +0100 Subject: [PATCH 09/22] fix option cloning Signed-off-by: dartcafe --- lib/Db/Option.php | 24 ++++++++++++++---------- lib/Service/OptionService.php | 3 +-- 2 files changed, 15 insertions(+), 12 deletions(-) diff --git a/lib/Db/Option.php b/lib/Db/Option.php index 0cb0bf785e..bd3408e3d9 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; } /** 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)); } From 3886e29f391f2e2311840261d03aeda9b9ebdf6d Mon Sep 17 00:00:00 2001 From: dartcafe Date: Sun, 8 Mar 2026 20:46:30 +0100 Subject: [PATCH 10/22] fix typo and remove duplicated function Signed-off-by: dartcafe --- lib/Service/VoteService.php | 23 +++++------------------ 1 file changed, 5 insertions(+), 18 deletions(-) diff --git a/lib/Service/VoteService.php b/lib/Service/VoteService.php index 94cac01b51..f685e564b0 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,7 +90,7 @@ 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 From 8b2bcaea8e71ba07741e30bcdbf6a9919df4bb9c Mon Sep 17 00:00:00 2001 From: dartcafe Date: Sun, 8 Mar 2026 20:46:48 +0100 Subject: [PATCH 11/22] properly catch reject Signed-off-by: dartcafe --- src/views/Navigation.vue | 18 ++++++------------ 1 file changed, 6 insertions(+), 12 deletions(-) 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.')) - } + }) } /** From d4cfd211a22a4f251cc1aeeffb26ea495fe06b79 Mon Sep 17 00:00:00 2001 From: dartcafe Date: Sun, 8 Mar 2026 20:55:26 +0100 Subject: [PATCH 12/22] fix typo in error message and linting, removed async Signed-off-by: dartcafe --- src/stores/votes.ts | 24 ++++++++++++++++++------ 1 file changed, 18 insertions(+), 6 deletions(-) diff --git a/src/stores/votes.ts b/src/stores/votes.ts index a7e8c50e11..aa3e961921 100644 --- a/src/stores/votes.ts +++ b/src/stores/votes.ts @@ -50,7 +50,10 @@ export const useVotesStore = defineStore('votes', { state.votes.map((vote) => [vote.user.id, vote.user]), ).values(), ).sort((a, b) => - a.displayName.localeCompare(b.displayName, localeCodeIntl ?? navigator.language), + a.displayName.localeCompare( + b.displayName, + localeCodeIntl ?? navigator.language, + ), ) }, @@ -75,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 ( @@ -114,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 @@ -227,7 +236,10 @@ export const useVotesStore = defineStore('votes', { .map((vote) => vote.user) .sort((a, b) => { const { localeCodeIntl } = useSessionStore().currentUser - return a.displayName.localeCompare(b.displayName, localeCodeIntl ?? navigator.language) + return a.displayName.localeCompare( + b.displayName, + localeCodeIntl ?? navigator.language, + ) }) }, @@ -352,7 +364,7 @@ export const useVotesStore = defineStore('votes', { pollStore.load() throw error } else { - Logger.error('Error setting vote aa', { + Logger.error('Error setting vote', { error, payload, }) @@ -361,7 +373,7 @@ export const useVotesStore = defineStore('votes', { } }, - async setSort(payload: { optionId: number }) { + setSort(payload: { optionId: number }) { this.sortByOption = payload.optionId }, From 4abb5e041df811ef28dd7aaedb69bc83a1631f22 Mon Sep 17 00:00:00 2001 From: dartcafe Date: Mon, 9 Mar 2026 13:49:39 +0100 Subject: [PATCH 13/22] avoid nullish sentResult Signed-off-by: dartcafe --- lib/Service/MailService.php | 14 ++++---------- lib/Service/ShareService.php | 6 +++--- src/Api/modules/shares.ts | 2 +- src/components/Shares/SharesListUnsent.vue | 14 ++++++++------ src/stores/shares.ts | 9 +++++++-- 5 files changed, 23 insertions(+), 22 deletions(-) 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/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/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/components/Shares/SharesListUnsent.vue b/src/components/Shares/SharesListUnsent.vue index f5b43b2235..0fd4b7802e 100644 --- a/src/components/Shares/SharesListUnsent.vue +++ b/src/components/Shares/SharesListUnsent.vue @@ -25,9 +25,10 @@ const sharesStore = useSharesStore() * */ async function sendAllInvitations() { - const response = await sharesStore.inviteAll({ pollId: pollStore.id }) - if (response?.data.sentResult?.sentMails) { - response.data.sentResult.sentMails.forEach((item) => { + 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/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 From af201c625c25d00a19963b4fde18499e9ce911b2 Mon Sep 17 00:00:00 2001 From: dartcafe Date: Mon, 9 Mar 2026 13:50:28 +0100 Subject: [PATCH 14/22] fix hashing Signed-off-by: dartcafe --- lib/Db/Option.php | 4 ++++ lib/Db/V6/TableManager.php | 20 ++++++++++++++++++-- lib/Db/Vote.php | 12 ++++++++++++ 3 files changed, 34 insertions(+), 2 deletions(-) diff --git a/lib/Db/Option.php b/lib/Db/Option.php index bd3408e3d9..91f3225c25 100644 --- a/lib/Db/Option.php +++ b/lib/Db/Option.php @@ -204,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 diff --git a/lib/Db/V6/TableManager.php b/lib/Db/V6/TableManager.php index 47b7905a56..b50c4fcb23 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..18c3d597b0 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,17 @@ private function getAnswerSymbol(): string { } } + public function getVoteOptionHash(): string { + return Hash::getOptionHash( + $this->getPollId(), + $this->getVoteOptionText(), + ); + } + + public function getVoteOptionHashInDb(): string { + return $this->voteOptionHash; + } + /** * @return array * From 1606d6527662f61ca4f6f4251a28db551ec46066 Mon Sep 17 00:00:00 2001 From: dartcafe Date: Mon, 9 Mar 2026 14:32:57 +0100 Subject: [PATCH 15/22] get new vote from option Signed-off-by: dartcafe --- lib/Db/Option.php | 10 ++++++++++ lib/Service/VoteService.php | 8 ++------ 2 files changed, 12 insertions(+), 6 deletions(-) diff --git a/lib/Db/Option.php b/lib/Db/Option.php index 91f3225c25..9349942d2c 100644 --- a/lib/Db/Option.php +++ b/lib/Db/Option.php @@ -478,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/Service/VoteService.php b/lib/Service/VoteService.php index f685e564b0..ac0afa41b8 100644 --- a/lib/Service/VoteService.php +++ b/lib/Service/VoteService.php @@ -97,7 +97,6 @@ public function set(Option|int $optionOrOptionId, string $setTo): ?Vote { $deleteVoteInsteadOfNoVote = in_array(trim($setTo), [Vote::VOTE_NO, '']) && !boolval($poll->getUseNo()); if ($deleteVoteInsteadOfNoVote) { - $this->vote->setVoteAnswer(''); $this->voteMapper->delete($this->vote); } else { $this->vote->setVoteAnswer($setTo); @@ -105,12 +104,8 @@ public function set(Option|int $optionOrOptionId, string $setTo): ?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); } @@ -161,6 +156,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); From defed2464c1e0a5bca923af95de44f81ff93fd0e Mon Sep 17 00:00:00 2001 From: dartcafe Date: Mon, 9 Mar 2026 14:33:58 +0100 Subject: [PATCH 16/22] minor fixes, tidy Signed-off-by: dartcafe --- src/stores/comments.ts | 5 ++--- src/stores/pollGroups.ts | 2 +- src/stores/polls.ts | 2 +- 3 files changed, 4 insertions(+), 5 deletions(-) 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/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, }) From 239e3323b00cd6b887f3e62472d2eba0ddd408c6 Mon Sep 17 00:00:00 2001 From: dartcafe Date: Mon, 9 Mar 2026 14:34:25 +0100 Subject: [PATCH 17/22] fix confirmationPermission Signed-off-by: dartcafe --- src/stores/poll.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/stores/poll.ts b/src/stores/poll.ts index f63cb2d7f8..2c1021edf0 100644 --- a/src/stores/poll.ts +++ b/src/stores/poll.ts @@ -213,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 { From 8bc05331b316a08bbcdb78087d1fca991df0721a Mon Sep 17 00:00:00 2001 From: dartcafe Date: Tue, 10 Mar 2026 00:40:26 +0100 Subject: [PATCH 18/22] get compact duration Signed-off-by: dartcafe --- lib/Model/DateInterval.php | 48 +++++++++++++++++++++++++++++++------- 1 file changed, 39 insertions(+), 9 deletions(-) diff --git a/lib/Model/DateInterval.php b/lib/Model/DateInterval.php index d0c7716cb6..b507d60d1d 100644 --- a/lib/Model/DateInterval.php +++ b/lib/Model/DateInterval.php @@ -45,20 +45,50 @@ public function isZeroDuration(): bool { * * @return string|null The ISO 8601 duration string or null if the interval is zero. */ - 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. * From abb8bf087f1a7d3bb269e3d34d91e49f640ba121 Mon Sep 17 00:00:00 2001 From: dartcafe Date: Wed, 11 Mar 2026 08:34:59 +0100 Subject: [PATCH 19/22] properly find existing option Signed-off-by: dartcafe --- src/components/Comments/CommentAdd.vue | 10 ++++------ src/components/Options/OptionsDateAddDialog.vue | 13 ++++++------- src/stores/options.ts | 10 +++++----- 3 files changed, 15 insertions(+), 18 deletions(-) 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') }}
( ...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()), ) } }, From c8daef1d971cb40a40d9f06294f75c3e5e73b1a3 Mon Sep 17 00:00:00 2001 From: dartcafe Date: Wed, 11 Mar 2026 08:42:25 +0100 Subject: [PATCH 20/22] linting Signed-off-by: dartcafe --- lib/Db/V6/TableManager.php | 4 ++-- lib/Db/Vote.php | 17 +++++++++++++++++ lib/Model/DateInterval.php | 4 ++++ 3 files changed, 23 insertions(+), 2 deletions(-) diff --git a/lib/Db/V6/TableManager.php b/lib/Db/V6/TableManager.php index b50c4fcb23..8c919de0b5 100644 --- a/lib/Db/V6/TableManager.php +++ b/lib/Db/V6/TableManager.php @@ -639,7 +639,7 @@ private function updateVoteHashes(): array { $messages[] = 'update voteId ' . $vote->getId() . ' hash: ' . $vote->getVoteOptionHashInDB() . ':' . Hash::getOptionHash($vote->getPollId(), $vote->getVoteOptionText()) - .'\'' . $vote->getVoteOptionText() . '\''; + . '\'' . $vote->getVoteOptionText() . '\''; $vote->setVoteOptionHash(Hash::getOptionHash($vote->getPollId(), $vote->getVoteOptionText())); $vote = $this->voteMapper->update($vote); @@ -703,7 +703,7 @@ private function updateOptionHashes(): array { $messages[] = 'update optionId ' . $option->getId() . ' hash: ' . $option->getPollOptionHashInDB() . ':' . Hash::getOptionHash($option->getPollId(), $option->getPollOptionText()) - .'\'' . $option->getPollOptionText() . '\''; + . '\'' . $option->getPollOptionText() . '\''; $option->setText($option->getPollOptionText()); $option = $this->optionMapper->update($option); diff --git a/lib/Db/Vote.php b/lib/Db/Vote.php index 18c3d597b0..6257b1a3cb 100644 --- a/lib/Db/Vote.php +++ b/lib/Db/Vote.php @@ -73,6 +73,14 @@ 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(), @@ -80,6 +88,15 @@ public function getVoteOptionHash(): string { ); } + /** + * 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; } diff --git a/lib/Model/DateInterval.php b/lib/Model/DateInterval.php index b507d60d1d..aa27d54445 100644 --- a/lib/Model/DateInterval.php +++ b/lib/Model/DateInterval.php @@ -43,7 +43,11 @@ 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 getISOExpanded(): ?string { if ($this->isZeroDuration()) { From 2a84863307f2cd93914d30ff29584dd94c67ffb0 Mon Sep 17 00:00:00 2001 From: dartcafe Date: Wed, 11 Mar 2026 11:18:41 +0100 Subject: [PATCH 21/22] vix voting and tests Signed-off-by: dartcafe --- lib/Service/VoteService.php | 2 ++ src/stores/votes.ts | 4 ---- tests/Unit/Service/VoteServiceTest.php | 4 ++-- 3 files changed, 4 insertions(+), 6 deletions(-) diff --git a/lib/Service/VoteService.php b/lib/Service/VoteService.php index ac0afa41b8..e38015be2e 100644 --- a/lib/Service/VoteService.php +++ b/lib/Service/VoteService.php @@ -98,6 +98,8 @@ public function set(Option|int $optionOrOptionId, string $setTo): ?Vote { if ($deleteVoteInsteadOfNoVote) { $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); diff --git a/src/stores/votes.ts b/src/stores/votes.ts index aa3e961921..8f604b43a8 100644 --- a/src/stores/votes.ts +++ b/src/stores/votes.ts @@ -348,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 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 { From b06dc8e00535f6af40279f26ffdee16c9c3303ce Mon Sep 17 00:00:00 2001 From: dartcafe Date: Wed, 11 Mar 2026 12:10:34 +0100 Subject: [PATCH 22/22] add guard to subscriptions Signed-off-by: dartcafe --- lib/Service/SubscriptionService.php | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) 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; } }