From b866c03096db60c4f5128a3a81ea4f0908a15872 Mon Sep 17 00:00:00 2001 From: dartcafe Date: Sun, 21 Dec 2025 15:34:50 +0100 Subject: [PATCH 01/36] add iso date and timezone name Signed-off-by: dartcafe --- lib/Controller/PollController.php | 40 ++----------------- lib/Db/Option.php | 14 +++++++ lib/Db/Poll.php | 6 ++- lib/Migration/V5/TableSchema.php | 3 ++ lib/Model/SimpleOption.php | 14 +++++++ lib/Service/OptionService.php | 4 +- lib/Service/PollService.php | 3 +- src/Api/modules/polls.ts | 9 ++--- .../Configuration/ConfigClosing.vue | 4 +- .../Configuration/ConfigProposals.vue | 4 +- .../Options/OptionsDateAddDialog.vue | 6 ++- src/stores/options.types.ts | 13 +++--- src/stores/poll.ts | 6 ++- src/stores/poll.types.ts | 3 ++ 14 files changed, 70 insertions(+), 59 deletions(-) diff --git a/lib/Controller/PollController.php b/lib/Controller/PollController.php index b61ff27b3d..b0a37713c2 100644 --- a/lib/Controller/PollController.php +++ b/lib/Controller/PollController.php @@ -106,49 +106,17 @@ public function get(int $pollId): JSONResponse { #[OpenAPI(OpenAPI::SCOPE_IGNORE)] #[FrontpageRoute(verb: 'GET', url: '/poll/{pollId}')] public function getFull(int $pollId): JSONResponse { - return $this->response(fn () => $this->getFullPoll($pollId, true), Http::STATUS_OK); + return $this->response(fn () => $this->getFullPoll($pollId), Http::STATUS_OK); } - private function getFullPoll(int $pollId, bool $withTimings = false): array { - $timerMicro['start'] = microtime(true); - + private function getFullPoll(int $pollId): array { $poll = $this->pollService->get($pollId); - $timerMicro['poll'] = microtime(true); - $options = $this->optionService->list($pollId); - $timerMicro['options'] = microtime(true); - $votes = $this->voteService->list($pollId); - $timerMicro['votes'] = microtime(true); - $comments = $this->commentService->list($pollId); - $timerMicro['comments'] = microtime(true); - $shares = $this->shareService->list($pollId); - $timerMicro['shares'] = microtime(true); - $subscribed = $this->subscriptionService->get($pollId); - $timerMicro['subscribed'] = microtime(true); - - $diffMicro['total'] = microtime(true) - $timerMicro['start']; - $diffMicro['poll'] = $timerMicro['poll'] - $timerMicro['start']; - $diffMicro['options'] = $timerMicro['options'] - $timerMicro['poll']; - $diffMicro['votes'] = $timerMicro['votes'] - $timerMicro['options']; - $diffMicro['comments'] = $timerMicro['comments'] - $timerMicro['votes']; - $diffMicro['shares'] = $timerMicro['shares'] - $timerMicro['comments']; - $diffMicro['subscribed'] = $timerMicro['subscribed'] - $timerMicro['shares']; - if ($withTimings) { - return [ - 'poll' => $poll, - 'options' => $options, - 'votes' => $votes, - 'comments' => $comments, - 'shares' => $shares, - 'subscribed' => $subscribed, - 'diffMicro' => $diffMicro, - ]; - } return [ 'poll' => $poll, 'options' => $options, @@ -170,10 +138,10 @@ private function getFullPoll(int $pollId, bool $withTimings = false): array { #[NoAdminRequired] #[OpenAPI(OpenAPI::SCOPE_IGNORE)] #[FrontpageRoute(verb: 'POST', url: '/poll/add')] - public function add(string $type, string $title, string $votingVariant = Poll::VARIANT_SIMPLE): JSONResponse { + public function add(string $type, string $title, ?string $timezoneName = null, string $votingVariant = Poll::VARIANT_SIMPLE): JSONResponse { return $this->response( fn () => [ - 'poll' => $this->pollService->add($type, $title, $votingVariant) + 'poll' => $this->pollService->add($type, $title, $timezoneName, $votingVariant) ], Http::STATUS_CREATED ); diff --git a/lib/Db/Option.php b/lib/Db/Option.php index 0f97a428c8..ff38546fd6 100644 --- a/lib/Db/Option.php +++ b/lib/Db/Option.php @@ -43,6 +43,10 @@ * @method void setTimestamp(int $value) * @method int getDeleted() * @method void setDeleted(int $value) + * @method int getIsoTimestamp() + * @method void setIsoTimestamp(string $value) + * @method int getIsoDuration() + * @method void setIsoDuration(string $value) * * Joined Attributes * @method string getUserVoteAnswer() @@ -64,7 +68,9 @@ class Option extends EntityWithUser implements JsonSerializable { protected string $pollOptionText = ''; protected string $pollOptionHash = ''; protected int $timestamp = 0; + protected string $isoTimestamp = ''; protected int $duration = 0; + protected string $isoDuration = ''; protected int $order = 0; protected int $confirmed = 0; protected string $owner = ''; @@ -113,6 +119,8 @@ public function jsonSerialize(): array { 'pollId' => $this->getPollId(), 'text' => $this->getPollOptionText(), 'timestamp' => $this->getTimestamp(), + 'isoTimestamp' => $this->getIsoTimestamp(), + 'isoDuration' => $this->getIsoDuration(), 'deleted' => $this->getDeleted(), 'order' => $this->getOrder(), 'confirmed' => $this->getConfirmed(), @@ -148,6 +156,8 @@ public function setFromSimpleOption(SimpleOption $option): void { $option->getDuration(), $option->getText(), $option->getOrder(), + $option->getIsoTimestamp(), + $option->getIsoDuration(), ); $this->setDeleted(0); $this->syncOption(); @@ -168,11 +178,15 @@ public function setOption( int $duration = 0, string $pollOptionText = '', int $order = 0, + string $isoTimestamp = '', + string $isoDuration = '', ): void { if ($timestamp) { $this->setTimestamp($timestamp); $this->setDuration($duration); + $this->setIsoTimestamp($isoTimestamp); + $this->setIsoDuration($isoDuration); } elseif ($pollOptionText) { $this->setPollOptionText($pollOptionText); if ($order > 0) { diff --git a/lib/Db/Poll.php b/lib/Db/Poll.php index 61f2d29949..f0cf8a8007 100644 --- a/lib/Db/Poll.php +++ b/lib/Db/Poll.php @@ -14,7 +14,6 @@ use OCA\Polls\Helper\Container; use OCA\Polls\Model\Settings\AppSettings; use OCA\Polls\Model\Settings\SystemSettings; -use OCA\Polls\Model\User\User; use OCA\Polls\Model\UserBase; use OCA\Polls\UserSession; use OCP\IURLGenerator; @@ -65,6 +64,8 @@ * @method void setMiscSettings(string $value) * @method string getVotingVariant() * @method void setVotingVariant(string $value) + * @method ?string getTimezoneName() + * @method void setTimezoneName(?string $value) * * Magic functions for joined columns * @method string getShareToken() @@ -162,6 +163,7 @@ class Poll extends EntityWithUser implements JsonSerializable { protected int $lastInteraction = 0; protected ?string $miscSettings = ''; protected string $votingVariant = ''; + protected ?string $timezoneName = null; // joined columns protected ?int $isCurrentUserLocked = 0; @@ -268,6 +270,7 @@ public function getConfigurationArray(): array { 'showResults' => $this->getShowResults(), 'title' => $this->getTitle(), 'useNo' => boolval($this->getUseNo()), + 'timezoneName' => $this->getTimezoneName(), ]; } @@ -336,6 +339,7 @@ public function deserializeArray(array $pollConfiguration): self { $this->setUseNo($pollConfiguration['useNo'] ?? $this->getUseNo()); $this->setOptionLimit($pollConfiguration['maxVotesPerOption'] ?? $this->getOptionLimit()); $this->setVoteLimit($pollConfiguration['maxVotesPerUser'] ?? $this->getVoteLimit()); + $this->setTimezoneName($pollConfiguration['timezoneName'] ?? $this->getTimezoneName()); return $this; } diff --git a/lib/Migration/V5/TableSchema.php b/lib/Migration/V5/TableSchema.php index fefea712dc..75be5f17fc 100644 --- a/lib/Migration/V5/TableSchema.php +++ b/lib/Migration/V5/TableSchema.php @@ -254,6 +254,7 @@ abstract class TableSchema { 'last_interaction' => ['type' => Types::BIGINT, 'options' => ['notnull' => true, 'default' => 0, 'length' => 20]], 'misc_settings' => ['type' => Types::TEXT, 'options' => ['notnull' => false, 'default' => null, 'length' => 65535]], 'voting_variant' => ['type' => Types::STRING, 'options' => ['notnull' => true, 'default' => 'simple', 'length' => 64]], + 'timezone_name' => ['type' => Types::STRING, 'options' => ['notnull' => false, 'default' => null, 'length' => 64]], ], Option::TABLE => [ 'id' => ['type' => Types::BIGINT, 'options' => ['autoincrement' => true, 'notnull' => true, 'length' => 20]], @@ -261,6 +262,8 @@ abstract class TableSchema { 'poll_option_text' => ['type' => Types::STRING, 'options' => ['notnull' => true, 'default' => '', 'length' => 1024]], 'poll_option_hash' => ['type' => Types::STRING, 'options' => ['notnull' => false, 'default' => '', 'length' => 32]], 'timestamp' => ['type' => Types::BIGINT, 'options' => ['notnull' => true, 'default' => 0, 'length' => 20]], + 'iso_timestamp' => ['type' => Types::STRING, 'options' => ['notnull' => true, 'default' => '', 'length' => 32]], + 'iso_duration' => ['type' => Types::STRING, 'options' => ['notnull' => true, 'default' => '', 'length' => 32]], 'duration' => ['type' => Types::BIGINT, 'options' => ['notnull' => true, 'default' => 0, 'length' => 20]], 'order' => ['type' => Types::BIGINT, 'options' => ['notnull' => true, 'default' => 0, 'length' => 20]], 'confirmed' => ['type' => Types::BIGINT, 'options' => ['notnull' => true, 'default' => 0, 'length' => 20]], diff --git a/lib/Model/SimpleOption.php b/lib/Model/SimpleOption.php index 9810c6fa7a..92ad275249 100644 --- a/lib/Model/SimpleOption.php +++ b/lib/Model/SimpleOption.php @@ -22,6 +22,8 @@ public function __construct( protected ?int $timestamp, protected ?int $duration = 0, protected ?int $order = 0, + protected string $isoTimestamp = '', + protected string $isoDuration = '', ) { } @@ -31,6 +33,8 @@ public function jsonSerialize(): array { 'timestamp' => $this->timestamp ?? 0, 'duration' => $this->duration ?? 0, 'order' => $this->order ?? 0, + 'isoDate' => $this->isoTimestamp, + 'isoDuration' => $this->isoDuration, ]; } @@ -42,10 +46,18 @@ public function getTimestamp(): int { return $this->timestamp ?? 0; } + public function getIsoTimestamp(): string { + return $this->isoTimestamp; + } + public function getDuration(): int { return $this->duration ?? 0; } + public function getIsoDuration(): string { + return $this->isoDuration; + } + public function getOrder(): int { return $this->order ?? 0; } @@ -60,6 +72,8 @@ public static function fromArray(array $option): SimpleOption { $option['timestamp'] ?? 0, $option['duration'] ?? 0, $option['order'] ?? 0, + $option['isoTimestamp'] ?? '', + $option['isoDuration'] ?? '', ); } } diff --git a/lib/Service/OptionService.php b/lib/Service/OptionService.php index f5572c367d..a8cd9ec046 100644 --- a/lib/Service/OptionService.php +++ b/lib/Service/OptionService.php @@ -77,12 +77,12 @@ public function list(int $pollId): array { */ public function addWithSequenceAndAutoVote( int $pollId, - SimpleOption $option, + SimpleOption $simpleOption, bool $voteYes = false, ?Sequence $sequence = null, ): array { - $newOption = $this->add($pollId, $option, $voteYes); + $newOption = $this->add($pollId, $simpleOption, $voteYes); if ($sequence) { diff --git a/lib/Service/PollService.php b/lib/Service/PollService.php index d3975b2880..a06a7d48e2 100644 --- a/lib/Service/PollService.php +++ b/lib/Service/PollService.php @@ -189,7 +189,7 @@ public function getPollOwnerFromDB(int $pollId): UserBase { /** * Add poll */ - public function add(string $type, string $title, string $votingVariant = Poll::VARIANT_SIMPLE): Poll { + public function add(string $type, string $title, ?string $timezoneName = null, string $votingVariant = Poll::VARIANT_SIMPLE): Poll { if (!$this->appSettings->getPollCreationAllowed()) { throw new ForbiddenException('Poll creation is disabled'); } @@ -211,6 +211,7 @@ public function add(string $type, string $title, string $votingVariant = Poll::V $this->poll->setCreated($timestamp); $this->poll->setLastInteraction($timestamp); $this->poll->setOwner($this->userSession->getCurrentUserId()); + $this->poll->setTimezoneName($timezoneName); // create new poll before resetting all values to // ensure that the poll has all required values and an id diff --git a/src/Api/modules/polls.ts b/src/Api/modules/polls.ts index 6bb8e09275..f2b1f30ce5 100644 --- a/src/Api/modules/polls.ts +++ b/src/Api/modules/polls.ts @@ -7,7 +7,7 @@ import { httpInstance, createCancelTokenHandler } from './HttpApi' import type { AxiosResponse } from '@nextcloud/axios' import type { ApiEmailAdressList, FullPollResponse } from './api.types' import type { PollGroup } from '../../stores/pollGroups.types' -import type { Poll, PollConfiguration, PollType } from '../../stores/poll.types' +import type { Poll, PollConfiguration, PollMandatory } from '../../stores/poll.types' export type Confirmations = { sentMails: { emailAddress: string; displayName: string }[] @@ -87,14 +87,11 @@ const polls = { }) }, - addPoll(type: PollType, title: string): Promise> { + addPoll(payload: PollMandatory): Promise> { return httpInstance.request({ method: 'POST', url: 'poll/add', - data: { - type, - title, - }, + data: payload, cancelToken: cancelTokenHandlerObject[ this.addPoll.name diff --git a/src/components/Configuration/ConfigClosing.vue b/src/components/Configuration/ConfigClosing.vue index 721cdf752a..4ef98403d8 100644 --- a/src/components/Configuration/ConfigClosing.vue +++ b/src/components/Configuration/ConfigClosing.vue @@ -22,7 +22,7 @@ const pollStore = usePollStore() const expire = computed({ get: () => pollStore.getExpirationDateTime.toJSDate(), set: (value) => { - pollStore.configuration.expire = DateTime.fromJSDate(value).toSeconds() + pollStore.configuration.expire = DateTime.fromJSDate(value).toUnixInteger() pollStore.write() }, }) @@ -33,7 +33,7 @@ const useExpire = computed({ if (value) { pollStore.configuration.expire = DateTime.now() .plus({ week: 1 }) - .toSeconds() + .toUnixInteger() } else { pollStore.configuration.expire = 0 } diff --git a/src/components/Configuration/ConfigProposals.vue b/src/components/Configuration/ConfigProposals.vue index 55d3942ecb..7318c77566 100644 --- a/src/components/Configuration/ConfigProposals.vue +++ b/src/components/Configuration/ConfigProposals.vue @@ -27,7 +27,7 @@ const pollExpire = computed({ get: () => pollStore.getProposalExpirationDateTime.toJSDate(), set: (value) => { pollStore.configuration.proposalsExpire = - DateTime.fromJSDate(value).toSeconds() + DateTime.fromJSDate(value).toUnixInteger() pollStore.write() }, }) @@ -38,7 +38,7 @@ const proposalExpiration = computed({ if (value) { pollStore.configuration.proposalsExpire = DateTime.now() .plus({ week: 1 }) - .toSeconds() + .toUnixInteger() } else { pollStore.configuration.proposalsExpire = 0 } diff --git a/src/components/Options/OptionsDateAddDialog.vue b/src/components/Options/OptionsDateAddDialog.vue index 482b7657e2..8b44a675b4 100644 --- a/src/components/Options/OptionsDateAddDialog.vue +++ b/src/components/Options/OptionsDateAddDialog.vue @@ -127,7 +127,7 @@ const blockedOption = computed(() => { const sameOption = computed(() => { const option = optionsStore.find( - from.value.toSeconds(), + from.value.toUnixInteger(), duration.value.as('seconds'), ) return option @@ -178,8 +178,10 @@ async function addOption(): Promise { await optionsStore.add( { text: '', - timestamp: from.value.toSeconds(), + timestamp: from.value.toUnixInteger(), duration: duration.value.as('seconds'), + isoTimestamp: from.value.toISO() || '', + isoDuration: duration.value.toISO() || '', }, sequenceInput.value, voteYes.value, diff --git a/src/stores/options.types.ts b/src/stores/options.types.ts index b572ba6d46..6c76da57aa 100644 --- a/src/stores/options.types.ts +++ b/src/stores/options.types.ts @@ -23,21 +23,17 @@ export type OptionVotes = { currentUser?: Answer } -export type SimpleOption = { - text?: string - timestamp?: number - duration?: number -} - export type Option = { id: number pollId: number text: string timestamp: number + isoTimestamp: string deleted: number order: number confirmed: number duration: number + isoDuration: string locked: boolean hash: string isOwner: boolean @@ -45,6 +41,11 @@ export type Option = { owner: User | undefined } +export type SimpleOption = Pick< + Option, + 'text' | 'timestamp' | 'isoTimestamp' | 'duration' | 'isoDuration' +> + export type OptionsStore = { options: Option[] ranked: RankedType diff --git a/src/stores/poll.ts b/src/stores/poll.ts index 585a456fe0..679b34d410 100644 --- a/src/stores/poll.ts +++ b/src/stores/poll.ts @@ -70,6 +70,7 @@ export const usePollStore = defineStore('poll', { useNo: true, maxVotesPerOption: 0, maxVotesPerUser: 0, + timezoneName: null, }, owner: createDefault(), pollGroups: [], @@ -292,7 +293,10 @@ export const usePollStore = defineStore('poll', { const pollsStore = usePollsStore() try { - const response = await PollsAPI.addPoll(payload.type, payload.title) + const response = await PollsAPI.addPoll({ + ...payload, + timezoneName: Intl.DateTimeFormat().resolvedOptions().timeZone, + }) return response.data.poll } catch (error) { if ((error as AxiosError)?.code === 'ERR_CANCELED') { diff --git a/src/stores/poll.types.ts b/src/stores/poll.types.ts index 81f1a1bffe..23d5833724 100644 --- a/src/stores/poll.types.ts +++ b/src/stores/poll.types.ts @@ -42,6 +42,7 @@ export type PollConfiguration = { showResults: ShowResults title: string useNo: boolean + timezoneName: string | null } export type PollStatus = { @@ -115,5 +116,7 @@ export type Poll = { sortParticipants: SortParticipants meta: Meta } +export type PollMandatory = Pick + & Pick export type PollStore = Poll From f61e29cadc3a1adb3c04a075c20a18ebf8517de9 Mon Sep 17 00:00:00 2001 From: dartcafe Date: Mon, 29 Dec 2025 09:24:23 +0100 Subject: [PATCH 02/36] change cloning and permission checks Signed-off-by: dartcafe --- lib/Controller/PublicController.php | 7 +++ lib/Db/Option.php | 7 +++ lib/Db/Poll.php | 10 ++++ .../InsufficientAttributesException.php | 2 +- lib/Service/OptionService.php | 55 ++++++++----------- lib/Service/PollService.php | 37 +++---------- lib/UserSession.php | 2 +- 7 files changed, 58 insertions(+), 62 deletions(-) diff --git a/lib/Controller/PublicController.php b/lib/Controller/PublicController.php index f7b76a3f0b..5d1408b59a 100644 --- a/lib/Controller/PublicController.php +++ b/lib/Controller/PublicController.php @@ -10,6 +10,7 @@ use OCA\Polls\AppConstants; use OCA\Polls\Attributes\ShareTokenRequired; +use OCA\Polls\Exceptions\InsufficientAttributesException; use OCA\Polls\Model\SentResult; use OCA\Polls\Model\Sequence; use OCA\Polls\Model\Settings\AppSettings; @@ -224,6 +225,12 @@ public function addOption( ?array $sequence = null, ): JSONResponse { $pollId = $this->userSession->getShare()->getPollId(); + + /** @psalm-suppress RiskyTruthyFalsyComparison */ + if (!$pollId) { + throw new InsufficientAttributesException('PollId of share is missing or invalid'); + } + return $this->response(fn () => array_merge( $this->optionService->addWithSequenceAndAutoVote( $pollId, diff --git a/lib/Db/Option.php b/lib/Db/Option.php index ff38546fd6..2de2c137e1 100644 --- a/lib/Db/Option.php +++ b/lib/Db/Option.php @@ -108,6 +108,13 @@ public function __construct() { $this->addType('showResults', 'integer'); } + public function __clone() { + $this->setPollId(0); + $this->setDeleted(0); + $this->setConfirmed(0); + $this->setOwner(''); + $this->updateHash(); + } /** * @return array * diff --git a/lib/Db/Poll.php b/lib/Db/Poll.php index f0cf8a8007..185304a7fb 100644 --- a/lib/Db/Poll.php +++ b/lib/Db/Poll.php @@ -211,6 +211,16 @@ public function __construct() { $this->userSession = Container::queryClass(UserSession::class); } + public function __clone() { + $this->setId(0); + $this->setTitle('Clone of ' . $this->getTitle()); + $this->setDeleted(0); + $this->setCreated(time()); + $this->setAccess(self::ACCESS_PRIVATE); + // deanonymize cloned polls by default, to avoid locked anonymous polls + $this->setAnonymous(0); + } + /** * @return array * diff --git a/lib/Exceptions/InsufficientAttributesException.php b/lib/Exceptions/InsufficientAttributesException.php index 98eb17d8e7..5c61bb61e1 100644 --- a/lib/Exceptions/InsufficientAttributesException.php +++ b/lib/Exceptions/InsufficientAttributesException.php @@ -14,6 +14,6 @@ class InsufficientAttributesException extends Exception { public function __construct( string $e = 'Attribut constraints not met', ) { - parent::__construct($e, Http::STATUS_CONFLICT); + parent::__construct($e, Http::STATUS_BAD_REQUEST); } } diff --git a/lib/Service/OptionService.php b/lib/Service/OptionService.php index a8cd9ec046..87a66f4529 100644 --- a/lib/Service/OptionService.php +++ b/lib/Service/OptionService.php @@ -59,7 +59,8 @@ public function get(int $optionId): Option { * @psalm-return array */ public function list(int $pollId): array { - $this->getPoll($pollId, Poll::PERMISSION_POLL_ACCESS); + $this->getPoll($pollId) + ->request(Poll::PERMISSION_POLL_ACCESS); try { $this->options = $this->optionMapper->findByPoll($pollId, !$this->poll->getIsAllowed(Poll::PERMISSION_POLL_RESULTS_VIEW)); @@ -84,7 +85,6 @@ public function addWithSequenceAndAutoVote( $newOption = $this->add($pollId, $simpleOption, $voteYes); - if ($sequence) { $repetitions = $this->sequence($newOption, $sequence, $voteYes); } else { @@ -106,7 +106,8 @@ public function addWithSequenceAndAutoVote( * @return Option */ public function add(int $pollId, SimpleOption $simpleOption, bool $voteYes = false): Option { - $this->getPoll($pollId, Poll::PERMISSION_OPTION_ADD); + $this->getPoll($pollId) + ->request(Poll::PERMISSION_OPTION_ADD); if ($this->poll->getType() === Poll::TYPE_TEXT) { $simpleOption->setOrder($this->getHighestOrder($pollId) + 1); @@ -160,7 +161,8 @@ public function add(int $pollId, SimpleOption $simpleOption, bool $voteYes = fal * @return Option[] */ public function addBulk(int $pollId, string $bulkText = ''): array { - $this->getPoll($pollId, Poll::PERMISSION_OPTION_ADD); + $this->getPoll($pollId) + ->request(Poll::PERMISSION_OPTION_ADD); $newOptionsTexts = array_unique(explode(PHP_EOL, $bulkText)); @@ -217,7 +219,8 @@ public function delete(int $optionId, bool $restore = false): Option { */ public function confirm(int $optionId): Option { $option = $this->optionMapper->find($optionId); - $this->getPoll($option->getPollId(), Poll::PERMISSION_OPTION_CONFIRM); + $this->getPoll($option->getPollId()) + ->request(Poll::PERMISSION_OPTION_CONFIRM); $option->setConfirmed($option->getConfirmed() ? 0 : time()); $option = $this->optionMapper->update($option); @@ -234,10 +237,10 @@ public function confirm(int $optionId): Option { /** * Make a sequence of date poll options * - * @param int | Option $optionOrOptionId Option od optionId of the option to clone + * @param int|Option $optionOrOptionId Option or optionId of the option to clone * @param Sequence $sequence Sequence object * @param bool $voteYes Directly vote 'yes' for the new options - * @return Option[] + * @return Option[] Returns all options of the poll * * @psalm-return array */ @@ -252,7 +255,8 @@ public function sequence(int|Option $optionOrOptionId, Sequence $sequence, bool $baseOption = $this->optionMapper->find($optionOrOptionId); } - $this->getPoll($baseOption->getPollId(), Poll::PERMISSION_OPTION_ADD); + $this->getPoll($baseOption->getPollId()) + ->request(Poll::PERMISSION_OPTION_ADD); if ($this->poll->getType() !== Poll::TYPE_DATE) { throw new InvalidPollTypeException('Sequences are only available in date polls'); @@ -308,7 +312,7 @@ public function shift(int $pollId, int $step, string $unit): array { $options = $this->optionMapper->findByPoll($pollId); if ($this->countProposals($options) > 0) { - throw new ForbiddenException('dates is not allowed'); + throw new ForbiddenException('Shifting dates is not allowed, when proposals exist'); } $timezone = new DateTimeZone($this->userSession->getClientTimeZone()); @@ -338,15 +342,8 @@ public function clone(int $fromPollId, int $toPollId): void { ->request(Poll::PERMISSION_OPTION_ADD); foreach ($this->optionMapper->findByPoll($fromPollId) as $origin) { - $option = new Option(); + $option = clone $origin; $option->setPollId($toPollId); - $option->setConfirmed(0); - $option->setOption( - $origin->getTimestamp(), - $origin->getDuration(), - $origin->getPollOptionText(), - ); - $option->setOrder($origin->getOrder()); $option = $this->optionMapper->insert($option); $this->eventDispatcher->dispatchTyped(new OptionCreatedEvent($option)); } @@ -360,11 +357,8 @@ public function clone(int $fromPollId, int $toPollId): void { * @psalm-return array */ public function reorder(int $pollId, array $options): array { - $this->getPoll($pollId, Poll::PERMISSION_POLL_EDIT); - - if ($this->poll->getType() === Poll::TYPE_DATE) { - throw new InvalidPollTypeException('Not allowed in date polls'); - } + $this->getPoll($pollId) + ->request(Poll::PERMISSION_OPTIONS_REORDER); $i = 0; foreach ($options as $option) { @@ -397,11 +391,8 @@ public function reorder(int $pollId, array $options): array { */ public function setOrder(int $optionId, int $newOrder): array { $option = $this->optionMapper->find($optionId); - $this->getPoll($option->getPollId(), Poll::PERMISSION_POLL_EDIT); - - if ($this->poll->getType() === Poll::TYPE_DATE) { - throw new InvalidPollTypeException('Not allowed in date polls'); - } + $this->getPoll($option->getPollId()) + ->request(Poll::PERMISSION_OPTIONS_REORDER); if ($newOrder < 1) { $newOrder = 1; @@ -440,15 +431,15 @@ private function moveModifier(int $moveFrom, int $moveTo, int $currentPosition): } /** - * Load the poll and check permissions + * Load the poll if not already loaded * - * @return void + * @return Poll */ - private function getPoll(int $pollId, string $permission = Poll::PERMISSION_POLL_ACCESS): void { + private function getPoll(int $pollId): Poll { if ($this->poll->getId() !== $pollId) { $this->poll = $this->pollMapper->get($pollId); } - $this->poll->request($permission); + return $this->poll; } /** @@ -472,7 +463,7 @@ private function filterBookedUp() { * * @return int */ - public function getHighestOrder(int $pollId): int { + private function getHighestOrder(int $pollId): int { $result = intval($this->optionMapper->getOrderBoundaries($pollId)['max']); return $result; } diff --git a/lib/Service/PollService.php b/lib/Service/PollService.php index a06a7d48e2..4f179fa0d8 100644 --- a/lib/Service/PollService.php +++ b/lib/Service/PollService.php @@ -52,6 +52,7 @@ public function __construct( /** * Get list of polls including Threshold for "relevant polls" + * @return Poll[] */ public function listPolls(): array { $pollList = $this->pollMapper->findForMe($this->userSession->getCurrentUserId()); @@ -66,6 +67,7 @@ public function listPolls(): array { /** * Get list of polls + * @return Poll[] */ public function search(ISearchQuery $query): array { $pollList = []; @@ -103,6 +105,7 @@ public function listForAdmin(): array { } /** + * Transfer all polls from one user to another * @return Poll[] * @psalm-return array */ @@ -166,9 +169,8 @@ public function transferPoll(int|Poll $poll, string|UserBase $targetUser): Poll /** * get poll configuration - * @return Poll */ - public function get(int $pollId) { + public function get(int $pollId): Poll { try { $this->poll = $this->pollMapper->get($pollId); $this->poll->request(Poll::PERMISSION_POLL_ACCESS); @@ -240,7 +242,6 @@ public function add(string $type, string $title, ?string $timezoneName = null, s * * @param int $pollId Poll id * @param array $pollConfiguration Poll configuration - * @return array * * @psalm-return array{poll: Poll, diff: array, changes: array} */ @@ -297,7 +298,6 @@ public function update(int $pollId, array $pollConfiguration): array { /** * Manually lock anonymization - * @return Poll */ public function lockAnonymous(int $pollId): Poll { $this->poll = $this->pollMapper->get($pollId); @@ -330,7 +330,6 @@ public function setLastInteraction(int $pollId): void { /** * Move to archive or restore - * @return Poll */ public function toggleArchive(int $pollId): Poll { $this->poll = $this->pollMapper->get($pollId) @@ -350,7 +349,6 @@ public function toggleArchive(int $pollId): Poll { /** * Delete poll - * @return Poll */ public function delete(int $pollId): Poll { try { @@ -368,7 +366,6 @@ public function delete(int $pollId): Poll { /** * Close poll - * @return Poll */ public function close(int $pollId): Poll { $this->pollMapper->get($pollId) @@ -378,7 +375,6 @@ public function close(int $pollId): Poll { /** * Reopen poll - * @return Poll */ public function reopen(int $pollId): Poll { $this->pollMapper->get($pollId) @@ -388,7 +384,6 @@ public function reopen(int $pollId): Poll { /** * Close poll - * @return Poll */ private function toggleClose(int $pollId, int $expiry): Poll { $this->poll = $this->pollMapper->get($pollId) @@ -408,30 +403,17 @@ private function toggleClose(int $pollId, int $expiry): Poll { /** * Clone poll - * @return Poll */ public function clone(int $pollId): Poll { $origin = $this->pollMapper->get($pollId) ->request(Poll::PERMISSION_POLL_ACCESS); - $this->appSettings->getPollCreationAllowed(); + if (!$this->appSettings->getPollCreationAllowed()) { + throw new ForbiddenException('Poll creation is disabled'); + } - $this->poll = new Poll(); - $this->poll->setCreated(time()); - $this->poll->setOwner($this->userSession->getCurrentUserId()); - $this->poll->setTitle('Clone of ' . $origin->getTitle()); - $this->poll->setDeleted(0); - $this->poll->setAccess(Poll::ACCESS_PRIVATE); + $this->poll = clone $origin; - $this->poll->setType($origin->getType()); - $this->poll->setVotingVariant($origin->getVotingVariant()); - $this->poll->setDescription($origin->getDescription()); - $this->poll->setExpire($origin->getExpire()); - // deanonymize cloned polls by default, to avoid locked anonymous polls - $this->poll->setAnonymous(0); - $this->poll->setAllowMaybe($origin->getAllowMaybe()); - $this->poll->setVoteLimit($origin->getVoteLimit()); - $this->poll->setShowResults($origin->getShowResults()); - $this->poll->setAdminAccess($origin->getAdminAccess()); + $this->poll->setOwner($this->userSession->getCurrentUserId()); $this->poll = $this->pollMapper->insert($this->poll); $this->eventDispatcher->dispatchTyped(new PollCreatedEvent($this->poll)); @@ -440,7 +422,6 @@ public function clone(int $pollId): Poll { /** * Collect email addresses from particitipants - * */ public function getParticipantsEmailAddresses(int $pollId): array { $this->poll = $this->pollMapper->get($pollId) diff --git a/lib/UserSession.php b/lib/UserSession.php index 10be5077ec..f2ecbc2241 100644 --- a/lib/UserSession.php +++ b/lib/UserSession.php @@ -182,7 +182,7 @@ public function setClientId(string $clientId): void { } /** - * + * Get client time zone from session or return default time zone * @return non-empty-string */ public function getClientTimeZone(): string { From 8f76a3dcfc7147caadf3aed5f33eebd766691e18 Mon Sep 17 00:00:00 2001 From: dartcafe Date: Sun, 4 Jan 2026 14:46:02 +0100 Subject: [PATCH 03/36] add timezone name to poll configuration Signed-off-by: dartcafe --- lib/Db/Poll.php | 36 ++++++++++--------- lib/Provider/ReferenceProvider.php | 2 +- lib/UserSession.php | 1 + .../Configuration/ConfigTimezone.vue | 19 ++++++++++ .../SideBar/SideBarTabConfiguration.vue | 13 +++++-- src/stores/options.types.ts | 2 +- src/stores/poll.types.ts | 2 +- 7 files changed, 54 insertions(+), 21 deletions(-) create mode 100644 src/components/Configuration/ConfigTimezone.vue diff --git a/lib/Db/Poll.php b/lib/Db/Poll.php index 185304a7fb..5a5418b8e9 100644 --- a/lib/Db/Poll.php +++ b/lib/Db/Poll.php @@ -64,7 +64,6 @@ * @method void setMiscSettings(string $value) * @method string getVotingVariant() * @method void setVotingVariant(string $value) - * @method ?string getTimezoneName() * @method void setTimezoneName(?string $value) * * Magic functions for joined columns @@ -249,7 +248,7 @@ public function getStatusArray(): array { 'created' => $this->getCreated(), 'isAnonymous' => boolval($this->getAnonymous()), 'isArchived' => boolval($this->getDeleted()), - 'isExpired' => $this->getExpired(), + 'isExpired' => $this->getIsExpired(), 'isRealAnonymous' => $this->getAnonymous() < 0, 'relevantThreshold' => $this->getRelevantThreshold(), 'deletionDate' => $this->getDeletionDate(), @@ -353,7 +352,7 @@ public function deserializeArray(array $pollConfiguration): self { return $this; } - public function getExpired(): bool { + public function getIsExpired(): bool { $compareTime = time(); $expiry = $this->getExpire(); @@ -367,6 +366,16 @@ public function getPollOwnerId() { return $this->getOwner(); } + /** + * @psalm-return non-empty-string|null + */ + public function getTimezoneName(): ?string { + if ($this->timezoneName === '') { + return null; + } + return $this->timezoneName; + } + public function getUserRole(): string { if ($this->getCurrentUserIsEntityUser()) { return self::ROLE_OWNER; @@ -487,6 +496,7 @@ private function getGroupShares(): array { * @psalm-return list */ public function getPollGroups(): array { + /** @psalm-suppress RiskyTruthyFalsyComparison */ if (!$this->pollGroups) { return []; } @@ -501,6 +511,7 @@ public function getPollGroups(): array { * @psalm-return list */ public function getPollGroupUserShares(): array { + /** @psalm-suppress RiskyTruthyFalsyComparison */ if (!$this->pollGroupUserShares) { return []; } @@ -517,7 +528,7 @@ private function getAccess(): string { return $this->access; } - private function getProposalsExpired(): bool { + private function getProposalPeriodIsExpired(): bool { return ( $this->getProposalsExpire() > 0 && $this->getProposalsExpire() < time() @@ -812,7 +823,7 @@ private function getAllowAddOptions(): bool { } // Request for option proposals is expired, deny - if ($this->getProposalsExpired()) { + if ($this->getProposalPeriodIsExpired()) { return false; } @@ -840,21 +851,14 @@ private function getAllowDeleteOption(): bool { * Is current user allowed to confirm options */ private function getAllowConfirmOption(): bool { - return $this->getAllowEditPoll() && $this->getExpired(); + return $this->getAllowEditPoll() && $this->getIsExpired(); } /** * Is current user allowed to confirm options */ private function getAllowReorderOptions(): bool { - return $this->getAllowEditPoll() && !$this->getExpired() && $this->getType() === Poll::TYPE_TEXT; - } - - /** - * Compare $userId with current user's id - */ - public function matchUser(string $userId): bool { - return (bool)$this->userSession->getCurrentUser()->getId() && $this->userSession->getCurrentUser()->getId() === $userId; + return $this->getAllowEditPoll() && !$this->getIsExpired() && $this->getType() === Poll::TYPE_TEXT; } /** @@ -945,7 +949,7 @@ private function getAllowVote(): bool { } // deny votes, if poll is expired - return !$this->getExpired(); + return !$this->getIsExpired(); } /** @@ -975,7 +979,7 @@ private function getAllowShowResults(): bool { } // show results, when poll is closed - if ($this->getShowResults() === Poll::SHOW_RESULTS_CLOSED && $this->getExpired()) { + if ($this->getShowResults() === Poll::SHOW_RESULTS_CLOSED && $this->getIsExpired()) { return true; } // return poll settings diff --git a/lib/Provider/ReferenceProvider.php b/lib/Provider/ReferenceProvider.php index dfe27afe21..9560e58c66 100644 --- a/lib/Provider/ReferenceProvider.php +++ b/lib/Provider/ReferenceProvider.php @@ -74,7 +74,7 @@ public function resolveReference(string $referenceText): ?IReference { $ownerId = $poll->getUser()->getId(); $ownerDisplayName = $poll->getUser()->getDisplayName(); $url = $poll->getVoteUrl(); - $expired = $poll->getExpired(); + $expired = $poll->getIsExpired(); $expiry = $poll->getExpire(); $participated = $poll->getCurrentUserVotes() ? true : false; diff --git a/lib/UserSession.php b/lib/UserSession.php index f2ecbc2241..525ea728b7 100644 --- a/lib/UserSession.php +++ b/lib/UserSession.php @@ -187,6 +187,7 @@ public function setClientId(string $clientId): void { */ public function getClientTimeZone(): string { return $this->session->get(self::CLIENT_TZ) ?? date_default_timezone_get(); + // TODO: Use \OCP\IDateTimeZone::getDefaultTimezone() when available (NC32+) } public function setClientTimeZone(string $clientTimeZone): void { diff --git a/src/components/Configuration/ConfigTimezone.vue b/src/components/Configuration/ConfigTimezone.vue new file mode 100644 index 0000000000..33dd525073 --- /dev/null +++ b/src/components/Configuration/ConfigTimezone.vue @@ -0,0 +1,19 @@ + + + + + diff --git a/src/components/SideBar/SideBarTabConfiguration.vue b/src/components/SideBar/SideBarTabConfiguration.vue index fefa4d4cc7..4d34f5e6c5 100644 --- a/src/components/SideBar/SideBarTabConfiguration.vue +++ b/src/components/SideBar/SideBarTabConfiguration.vue @@ -17,6 +17,7 @@ import HideResultsUntilClosedIcon from 'vue-material-design-icons/MonitorLock.vu import UserPreferenceIcon from 'vue-material-design-icons/AccountCogOutline.vue' import ShowResultsNeverIcon from 'vue-material-design-icons/MonitorOff.vue' import ListViewIcon from 'vue-material-design-icons/ViewListOutline.vue' +import TimezoneIcon from 'vue-material-design-icons/MapClockOutline.vue' import TableViewIcon from 'vue-material-design-icons/Table.vue' import CardDiv from '../Base/modules/CardDiv.vue' @@ -25,17 +26,18 @@ import ConfigAllowMayBe from '../Configuration/ConfigAllowMayBe.vue' import ConfigAnonymous from '../Configuration/ConfigAnonymous.vue' import ConfigAutoReminder from '../Configuration/ConfigAutoReminder.vue' import ConfigClosing from '../Configuration/ConfigClosing.vue' +import ConfigDangerArea from '../Configuration/ConfigDangerArea.vue' import ConfigDescription from '../Configuration/ConfigDescription.vue' +import ConfigForceViewMode from '../Configuration/ConfigForceViewMode.vue' import ConfigOptionLimit from '../Configuration/ConfigOptionLimit.vue' import ConfigShowResults from '../Configuration/ConfigShowResults.vue' +import ConfigTimezone from '../Configuration/ConfigTimezone.vue' import ConfigTitle from '../Configuration/ConfigTitle.vue' import ConfigUseNo from '../Configuration/ConfigUseNo.vue' import ConfigVoteLimit from '../Configuration/ConfigVoteLimit.vue' import { usePollStore } from '../../stores/poll' import { useVotesStore } from '../../stores/votes' -import ConfigDangerArea from '../Configuration/ConfigDangerArea.vue' -import ConfigForceViewMode from '../Configuration/ConfigForceViewMode.vue' const pollStore = usePollStore() const votesStore = useVotesStore() @@ -111,6 +113,13 @@ const votesStore = useVotesStore() + + + + +