diff --git a/CHANGELOG.md b/CHANGELOG.md index f7d31c55e3..d57675b86c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,16 @@ # Changelog All notable changes to this project will be documented in this file. +## [unreleased] - tbd +### Added + - Timezone support for polls and options + - Set default timezone for any poll + - Choose timezone to display, if the poll's original timezone differs from the user's one + - Choose timezone, when adding options in case of different timezones between poll and user + +### Changed + - Use ISO formatted timestamps and durations + ## [8.6.3] - 2026-01-18 ### Fixed - Fixed missing user menu items, when table view is active 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/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 0f97a428c8..2de2c137e1 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 = ''; @@ -102,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 * @@ -113,6 +126,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 +163,8 @@ public function setFromSimpleOption(SimpleOption $option): void { $option->getDuration(), $option->getText(), $option->getOrder(), + $option->getIsoTimestamp(), + $option->getIsoDuration(), ); $this->setDeleted(0); $this->syncOption(); @@ -168,11 +185,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..5a5418b8e9 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,7 @@ * @method void setMiscSettings(string $value) * @method string getVotingVariant() * @method void setVotingVariant(string $value) + * @method void setTimezoneName(?string $value) * * Magic functions for joined columns * @method string getShareToken() @@ -162,6 +162,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; @@ -209,6 +210,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 * @@ -237,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(), @@ -268,6 +279,7 @@ public function getConfigurationArray(): array { 'showResults' => $this->getShowResults(), 'title' => $this->getTitle(), 'useNo' => boolval($this->getUseNo()), + 'timezoneName' => $this->getTimezoneName(), ]; } @@ -336,10 +348,11 @@ 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; } - public function getExpired(): bool { + public function getIsExpired(): bool { $compareTime = time(); $expiry = $this->getExpire(); @@ -353,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; @@ -473,6 +496,7 @@ private function getGroupShares(): array { * @psalm-return list */ public function getPollGroups(): array { + /** @psalm-suppress RiskyTruthyFalsyComparison */ if (!$this->pollGroups) { return []; } @@ -487,6 +511,7 @@ public function getPollGroups(): array { * @psalm-return list */ public function getPollGroupUserShares(): array { + /** @psalm-suppress RiskyTruthyFalsyComparison */ if (!$this->pollGroupUserShares) { return []; } @@ -503,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() @@ -798,7 +823,7 @@ private function getAllowAddOptions(): bool { } // Request for option proposals is expired, deny - if ($this->getProposalsExpired()) { + if ($this->getProposalPeriodIsExpired()) { return false; } @@ -826,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; } /** @@ -931,7 +949,7 @@ private function getAllowVote(): bool { } // deny votes, if poll is expired - return !$this->getExpired(); + return !$this->getIsExpired(); } /** @@ -961,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/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/Helper/DateHelper.php b/lib/Helper/DateHelper.php new file mode 100644 index 0000000000..a19d865028 --- /dev/null +++ b/lib/Helper/DateHelper.php @@ -0,0 +1,183 @@ +setTimestamp($dateValue); + } elseif (is_string($dateValue)) { + // Treat as ISO 8601 date string and create DateTimeImmutable + $dateTime = new DateTimeImmutable($dateValue); + } else { + // Null input, return null + return null; + } + + try { + if (is_string($timeZone)) { + // Convert string to DateTimeZone + $timeZone = new DateTimeZone($timeZone); + } + + if ($timeZone instanceof DateTimeZone) { + // Apply time zone + return $dateTime->setTimezone($timeZone); + } + } catch (Exception $e) { + // Invalid time zone, ignore and return original dateTime + } + + return $dateTime; + } + + /** + * Convert duration in seconds to DateInterval + * + * Note: We always need an offset date to correctly handle month/year durations. + * To avoid issues with Daylight Saving Time changes, additionally provide a time zone. + * + * @param null|int|string|DateInterval $duration Duration in seconds + * @param null|int|non-empty-string|DateTimeImmutable $offsetDate Offset date for context + * @param null|non-empty-string|DateTimeZone $timeZone Optional time zone as string or DateTimeZone + * @return null|DateInterval Normalized DateInterval object or null if duration is null or invalid + */ + public static function getDateInterval( + null|int|string|DateInterval $duration, + null|int|string|DateTimeImmutable $offsetDate, + null|string|DateTimeZone $timeZone = null, + ): ?DateInterval { + if ($duration === null) { + // Handle null duration + return null; + } + if ($duration instanceof DateInterval) { + // If already a DateInterval, return as is + return $duration; + } + + if (is_string($duration)) { + // If duration is a string, assume it's an ISO 8601 duration and create DateInterval directly + return new DateInterval($duration); + } + + $baseDate = self::getDateTimeImmutable($offsetDate, $timeZone); + + if ($baseDate === null) { + // If base date is null, we cannot compute interval + return null; + } + + if ($duration % 86400 === 0) { + // If numeric duration is set to full days, return a DateInterval with only days + $days = (int)($duration / 86400); + return new DateInterval('P' . $days . 'D'); + } + + // For other durations, compute end date and get the interval + $endDate = $baseDate->add(new DateInterval('PT' . $duration . 'S')); + return $baseDate->diff($endDate); + } + + public static function dateIntervalToSeconds(?DateInterval $interval, DateTimeImmutable $baseDate): ?int { + if ($interval === null) { + return null; + } + $endDate = $baseDate->add($interval); + return (int)($endDate->getTimestamp() - $baseDate->getTimestamp()); + } + /** + * Get compressed ISO 8601 duration string from DateInterval + * + * Only return non-zero parts of the interval. + * + * Possible alternative implementation found at: https://stackoverflow.com/questions/33787039/format-dateinterval-as-iso8601/42598056#42598056 + * + * @param null|DateInterval $interval DateInterval to convert + * @return null|non-empty-string Compressed ISO 8601 duration string + */ + public static function dateIntervalToIso(?DateInterval $interval): ?string { + if ($interval === null) { + return null; + } + $dateParts = []; + if ($interval->y !== 0) { + $dateParts[] = $interval->y . 'Y'; + } + if ($interval->m !== 0) { + $dateParts[] = $interval->m . 'M'; + } + if ($interval->d !== 0) { + $dateParts[] = $interval->d . 'D'; + } + + $timeParts = []; + + if ($interval->h !== 0) { + $timeParts[] = $interval->h . 'H'; + } + if ($interval->i !== 0) { + $timeParts[] = $interval->i . 'M'; + } + if ($interval->s !== 0) { + $timeParts[] = $interval->s . 'S'; + } + + $result = 'P' . implode('', $dateParts); + if (!empty($timeParts)) { + $result = $result . 'T' . implode('', $timeParts); + } + + if ($result === 'P') { + return 'PT0S'; // For zero duration + } + + return $result; + } + + /** + * Convert duration in seconds to normalized and compressed ISO 8601 duration string + * + * @param int $duration Duration in seconds + * @param int|non-empty-string|DateTimeImmutable $offsetDate Offset date for context + * @param null|DateTimeZone|non-empty-string $timeZone Optional time zone + * @return null|non-empty-string ISO 8601 duration string + */ + public static function durationToIso( + int $duration, + int|string|DateTimeImmutable $offsetDate, + null|DateTimeZone|string $timeZone = null, + ) : ?string { + $interval = self::getDateInterval($duration, $offsetDate, $timeZone); + return self::dateIntervalToIso($interval); + } + +} 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/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/Service/OptionService.php b/lib/Service/OptionService.php index f5572c367d..e2a4e1685c 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)); @@ -77,13 +78,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) { $repetitions = $this->sequence($newOption, $sequence, $voteYes); @@ -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)); @@ -180,7 +182,7 @@ public function addBulk(int $pollId, string $bulkText = ''): array { */ public function update(int $optionId, int $timestamp = 0, string $pollOptionText = '', int $duration = 0): Option { $option = $this->optionMapper->find($optionId); - $this->getPoll($option->getPollId(), Poll::PERMISSION_POLL_EDIT); + $this->getPoll($option->getPollId())->request(Poll::PERMISSION_POLL_EDIT); $option->setOption($timestamp, $duration, $pollOptionText); @@ -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 d3975b2880..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); @@ -189,7 +191,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 +213,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 @@ -239,7 +242,6 @@ public function add(string $type, string $title, string $votingVariant = Poll::V * * @param int $pollId Poll id * @param array $pollConfiguration Poll configuration - * @return array * * @psalm-return array{poll: Poll, diff: array, changes: array} */ @@ -296,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); @@ -329,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) @@ -349,7 +349,6 @@ public function toggleArchive(int $pollId): Poll { /** * Delete poll - * @return Poll */ public function delete(int $pollId): Poll { try { @@ -367,7 +366,6 @@ public function delete(int $pollId): Poll { /** * Close poll - * @return Poll */ public function close(int $pollId): Poll { $this->pollMapper->get($pollId) @@ -377,7 +375,6 @@ public function close(int $pollId): Poll { /** * Reopen poll - * @return Poll */ public function reopen(int $pollId): Poll { $this->pollMapper->get($pollId) @@ -387,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) @@ -407,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)); @@ -439,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..525ea728b7 100644 --- a/lib/UserSession.php +++ b/lib/UserSession.php @@ -182,11 +182,12 @@ 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 { 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/Api/modules/options.ts b/src/Api/modules/options.ts index 4a0bb7418f..5964d721dc 100644 --- a/src/Api/modules/options.ts +++ b/src/Api/modules/options.ts @@ -4,7 +4,7 @@ */ import { httpInstance, createCancelTokenHandler } from './HttpApi' -import type { DateTimeUnit } from '../../constants/dateUnits' +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' @@ -158,7 +158,7 @@ const options = { shiftOptions( pollId: number, step: number, - unit: DateTimeUnit, + unit: DateTimeUnits, ): Promise> { return httpInstance.request({ method: 'POST', 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/Types/dateTime.ts b/src/Types/dateTime.ts new file mode 100644 index 0000000000..9de6f655bd --- /dev/null +++ b/src/Types/dateTime.ts @@ -0,0 +1,30 @@ +/** + * SPDX-FileCopyrightText: 2020 Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +export type DateTimeUnits = 'minute' | 'hour' | 'day' | 'week' | 'month' | 'year' + +export type DateTimeUnitType = { + id: DateTimeUnits + name: string + timeOption: boolean +} +export type TimeZoneTypes = 'local' | 'poll' + +export type TimeZoneOption = { + label: string + value: TimeZoneTypes +} + +export type TimeUnitsType = { + unit: DateTimeUnitType + value: number +} + +export type DurationType = { + unit: DateTimeUnitType + amount: number +} + +export type DateFormats = 'dateTime' | 'dateShort' diff --git a/src/Types/index.ts b/src/Types/index.ts index bcf67b2201..b86d9c381c 100644 --- a/src/Types/index.ts +++ b/src/Types/index.ts @@ -4,11 +4,11 @@ */ export { - DateTimeUnit, + DateTimeUnits as DateTimeUnit, DateTimeUnitType, TimeUnitsType, DurationType, -} from '../constants/dateUnits' +} from './dateTime' export enum Event { TransitionsOff = 'polls:transitions:off', diff --git a/src/components/Base/modules/DateBox.vue b/src/components/Base/modules/DateBox.vue index 3161803b39..4dd06e8440 100644 --- a/src/components/Base/modules/DateBox.vue +++ b/src/components/Base/modules/DateBox.vue @@ -7,23 +7,40 @@ import { computed } from 'vue' import { DateTime, Duration } from 'luxon' import { getDates } from '../../../composables/optionDateTime' +import { useSessionStore } from '@/stores/session' interface Props { startDate: DateTime duration?: Duration + timezone?: string | undefined } -const { startDate, duration = Duration.fromMillis(0) } = defineProps() +const sessionStore = useSessionStore() -const optionDateTimes = computed(() => getDates(startDate, duration)) +const { + startDate, + duration = Duration.fromMillis(0), + timezone = undefined, +} = defineProps() + +const useTimeZone = computed( + () => + timezone + || sessionStore.currentTimezoneName + || Intl.DateTimeFormat().resolvedOptions().timeZone, +) + +const optionDateTimes = computed(() => + getDates(startDate, duration, useTimeZone.value), +)