From 157fb476a5ca8681d6a57a106635cd37c1e969ff Mon Sep 17 00:00:00 2001 From: Marcel Klehr Date: Thu, 28 May 2026 16:39:25 +0200 Subject: [PATCH 1/3] fix(assignments): Add timezone to assignments Signed-off-by: Marcel Klehr --- appinfo/info.xml | 2 +- lib/Controller/AssignmentsApiController.php | 16 +++++-- lib/Db/Assignment.php | 22 ++++++++- .../Version030500Date20260528083738.php | 45 +++++++++++++++++++ lib/ResponseDefinitions.php | 3 +- lib/Service/AssignmentsService.php | 7 ++- 6 files changed, 87 insertions(+), 8 deletions(-) create mode 100644 lib/Migration/Version030500Date20260528083738.php diff --git a/appinfo/info.xml b/appinfo/info.xml index 223938c8..9926c48a 100644 --- a/appinfo/info.xml +++ b/appinfo/info.xml @@ -62,7 +62,7 @@ Known providers: More details on how to set this up in the [admin docs](https://docs.nextcloud.com/server/latest/admin_manual/ai/index.html) ]]> - 3.5.0-dev.1 + 3.5.0-dev.2 agpl Julien Veyssier Assistant diff --git a/lib/Controller/AssignmentsApiController.php b/lib/Controller/AssignmentsApiController.php index ee556d44..65a62f3c 100644 --- a/lib/Controller/AssignmentsApiController.php +++ b/lib/Controller/AssignmentsApiController.php @@ -58,9 +58,9 @@ public function __construct( #[NoAdminRequired] #[OpenAPI(scope: OpenAPI::SCOPE_DEFAULT, tags: ['assignments'])] #[Http\Attribute\ApiRoute(verb: 'POST', url: '/assignments')] - public function createUserAssignment(string $title, string $prompt, int $startsAt, string $recurrence): DataResponse { + public function createUserAssignment(string $title, string $prompt, int $startsAt, string $recurrence, string $timezone): DataResponse { try { - $assignment = $this->assignmentsService->createAssignment($this->userId, $title, $prompt, $startsAt, $recurrence); + $assignment = $this->assignmentsService->createAssignment($this->userId, $title, $prompt, $startsAt, $recurrence, $timezone); $serializedAssignment = $assignment->jsonSerialize(); return new DataResponse(['assignment' => $serializedAssignment]); } catch (InternalException $e) { @@ -141,18 +141,19 @@ public function getUserAssignment(int $id): DataResponse { * @param string|null $prompt The prompt to be sent to the assistant when the assignment is executed * @param int|null $startsAt The timestamp when the assignment should start being executed * @param string|null $recurrence The recurrence rule for the assignment, in RRULE format + * @param string|null $timezone The timezone for this assignment, omit to leave the current value in place * * @return DataResponse|DataResponse * * 200: User tasks returned * 403: User not logged in - * 400: Malformed recurrence rule + * 400: Malformed input * 404: Assignment not found */ #[NoAdminRequired] #[OpenAPI(scope: OpenAPI::SCOPE_DEFAULT, tags: ['assignments'])] #[Http\Attribute\ApiRoute(verb: 'PATCH', url: '/assignments/{id}')] - public function updateUserAssignment(int $id, ?string $prompt, ?string $recurrence, ?int $startsAt): DataResponse { + public function updateUserAssignment(int $id, ?string $prompt, ?string $recurrence, ?int $startsAt, ?string $timezone): DataResponse { if ($this->userId !== null) { try { $assignment = $this->assignmentMapper->find($this->userId, $id); @@ -169,6 +170,13 @@ public function updateUserAssignment(int $id, ?string $prompt, ?string $recurren if ($startsAt !== null) { $assignment->setStartsAt($startsAt); } + if ($timezone !== null) { + try { + $assignment->setTimezone($timezone); + } catch (\InvalidArgumentException $e) { + return new DataResponse('', Http::STATUS_BAD_REQUEST); + } + } $assignment->setUpdatedAt($this->timeFactory->now()->getTimestamp()); $this->assignmentMapper->update($assignment); /** @var AssistantAssignment $serializedAssignment */ diff --git a/lib/Db/Assignment.php b/lib/Db/Assignment.php index ee4e03bc..b4b79a6b 100644 --- a/lib/Db/Assignment.php +++ b/lib/Db/Assignment.php @@ -33,6 +33,7 @@ * @method \void setUpdatedAt(int $updatedAt) * @method \void setLastRunAt(int $lastRunAt) * @method \int getLastRunAt() + * @method \string getTimezone() */ class Assignment extends Entity implements \JsonSerializable { /** @var string */ @@ -50,6 +51,9 @@ class Assignment extends Entity implements \JsonSerializable { /** @var int */ protected $lastRunAt; + /** @var string */ + protected $timezone; + public static $columns = [ 'id', 'user_id', @@ -59,6 +63,7 @@ class Assignment extends Entity implements \JsonSerializable { 'created_at', 'updated_at', 'last_run_at', + 'timezone' ]; public static $fields = [ 'id', @@ -69,6 +74,7 @@ class Assignment extends Entity implements \JsonSerializable { 'createdAt', 'updatedAt', 'lastRunAt', + 'timezone' ]; public function __construct() { @@ -79,6 +85,7 @@ public function __construct() { $this->addType('createdAt', Types::BIGINT); $this->addType('updatedAt', Types::BIGINT); $this->addType('lastRunAt', Types::BIGINT); + $this->addType('timezone', Types::STRING); } #[\ReturnTypeWillChange] @@ -92,6 +99,7 @@ public function jsonSerialize() { 'created_at' => $this->getCreatedAt(), 'updated_at' => $this->getUpdatedAt(), 'last_run_at' => $this->getLastRunAt(), + 'timezone' => $this->getTimezone(), ]; } @@ -107,6 +115,18 @@ public function setRecurrence(string $recurrence): void { $this->setter('recurrence', [$recurrence]); } + /** + * @throws \InvalidArgumentException + */ + public function setTimezone(string $timezone): void { + try { + $tz = new \DateTimeZone($timezone); + } catch (\Throwable $e) { + throw new \InvalidArgumentException('Invalid timezone: ' . $timezone, previous: $e); + } + $this->setter('timezone', [$tz->getName()]); + } + /** * Evaluates the recurrence rule and checks if a run is due */ @@ -115,7 +135,7 @@ public function isDueToRun(\DateTimeImmutable $now): bool { $startsAt = new \DateTime('@' . $this->getStartsAt()); $lastRunAt = new \DateTime('@' . $this->getLastRunAt()); // Find recurrences after the last run or after the current time if this assignment has never run - $rule = new Rule($this->getRecurrence(), $startsAt); + $rule = new Rule($this->getRecurrence(), $startsAt, timezone: $this->getTimezone()); $transformer = new \Recurr\Transformer\ArrayTransformer(); $constraint = new AfterConstraint($this->getLastRunAt() !== 0 ? $lastRunAt : $startsAt, false); /** @var RecurrenceCollection $collection */ diff --git a/lib/Migration/Version030500Date20260528083738.php b/lib/Migration/Version030500Date20260528083738.php new file mode 100644 index 00000000..28360426 --- /dev/null +++ b/lib/Migration/Version030500Date20260528083738.php @@ -0,0 +1,45 @@ +hasTable('assistant_assignments')) { + $table = $schema->getTable('assistant_assignments'); + if (!$table->hasColumn('timezone')) { + $table->addColumn('timezone', Types::STRING, [ + 'notnull' => true, + 'length' => 256, + 'default' => 'UTC', + ]); + $schemaChanged = true; + } + } + + return $schemaChanged ? $schema : null; + } +} diff --git a/lib/ResponseDefinitions.php b/lib/ResponseDefinitions.php index 90015c5b..1a964f18 100644 --- a/lib/ResponseDefinitions.php +++ b/lib/ResponseDefinitions.php @@ -94,7 +94,8 @@ * created_at: int, * updated_at: int, * starts_at: int, - * last_run_at: int + * last_run_at: int, + * timezone: string, * } */ class ResponseDefinitions { diff --git a/lib/Service/AssignmentsService.php b/lib/Service/AssignmentsService.php index 631b6f70..c00af915 100644 --- a/lib/Service/AssignmentsService.php +++ b/lib/Service/AssignmentsService.php @@ -39,7 +39,7 @@ public function __construct( * @throws UnauthorizedException * @throws BadRequestException */ - public function createAssignment(?string $userId, string $title, string $prompt, int $startsAt, string $recurrence): Assignment { + public function createAssignment(?string $userId, string $title, string $prompt, int $startsAt, string $recurrence, string $timezone): Assignment { if ($userId === null) { throw new UnauthorizedException(); } @@ -56,6 +56,11 @@ public function createAssignment(?string $userId, string $title, string $prompt, } catch (\InvalidArgumentException $e) { throw new BadRequestException('Invalid recurrence rule', previous: $e); } + try { + $assignment->setTimezone($timezone); + } catch (\InvalidArgumentException $e) { + throw new BadRequestException('Invalid recurrence rule', previous: $e); + } try { $this->assignmentMapper->insert($assignment); } catch (Exception $e) { From 5a1bb309d7ed24c202cba617e023b3e1d9d382d3 Mon Sep 17 00:00:00 2001 From: Marcel Klehr Date: Thu, 28 May 2026 16:39:55 +0200 Subject: [PATCH 2/3] tests(assignments): Add a few unit tests for Assignment#isDueToRun for sanity Signed-off-by: Marcel Klehr --- tests/unit/Db/AssignmentTest.php | 38 ++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) create mode 100644 tests/unit/Db/AssignmentTest.php diff --git a/tests/unit/Db/AssignmentTest.php b/tests/unit/Db/AssignmentTest.php new file mode 100644 index 00000000..972b705a --- /dev/null +++ b/tests/unit/Db/AssignmentTest.php @@ -0,0 +1,38 @@ +setRecurrence($rrule); + $assignment->setStartsAt((new \DateTimeImmutable($startsAt))->getTimestamp()); + $assignment->setLastRunAt((new \DateTimeImmutable($lastRunAt))->getTimestamp()); + $assignment->setTimezone($timezone); + self::assertEquals($expected, $assignment->isDueToRun(new \DateTimeImmutable($now))); + } + + public function isDueDataProvider(): array { + return [ + ['FREQ=DAILY', '@0', '@0', 'UTC', '1970-01-02T00:00:00Z', true], + ['FREQ=DAILY;BYHOUR=8', '@0', '@0', 'UTC', '1970-01-01T08:00:00Z', true], + ['FREQ=DAILY;BYHOUR=8', '@0', '1970-01-01T08:00:00Z', 'UTC', '1970-01-01T08:00:01Z', false], + ['FREQ=DAILY;BYHOUR=8', '@0', '@0', '+0200', '1970-01-01T10:00:00Z', true], + ['FREQ=DAILY;BYHOUR=8', '@0', '1970-01-01T10:00:00Z', '+0200', '1970-01-01T10:00:00Z', false], + ['FREQ=DAILY;BYHOUR=8', '@0', '1970-01-01T10:00:00Z', '+0200', '1970-01-02T10:00:00Z', true], + ['FREQ=DAILY;BYHOUR=8', '@0', '1970-01-01T10:00:00Z', '+0200', '1970-01-02T10:00:00Z', true], + ['FREQ=DAILY;BYHOUR=8', '@0', '1970-01-01T10:00:00Z', '+0200', '2027-01-02T10:00:00Z', true], + ]; + } +} From 6af62e1df63ce3f2ec27d06e33b3960fb85be8f5 Mon Sep 17 00:00:00 2001 From: Marcel Klehr Date: Thu, 28 May 2026 16:39:55 +0200 Subject: [PATCH 3/3] fix(assignments): Regenerate openapi spec Signed-off-by: Marcel Klehr --- lib/Controller/AssignmentsApiController.php | 3 ++- openapi.json | 20 +++++++++++++++++--- 2 files changed, 19 insertions(+), 4 deletions(-) diff --git a/lib/Controller/AssignmentsApiController.php b/lib/Controller/AssignmentsApiController.php index 65a62f3c..35ac6324 100644 --- a/lib/Controller/AssignmentsApiController.php +++ b/lib/Controller/AssignmentsApiController.php @@ -49,6 +49,7 @@ public function __construct( * @param string $prompt The prompt to be sent to the assistant when the assignment is executed * @param int $startsAt The timestamp when the assignment should start being executed * @param string $recurrence The recurrence rule for the assignment, in RRULE format (e.g. "FREQ=DAILY;INTERVAL=1" for a daily assignment) + * @param string $timezone The timezone for this assignment (either the timezone name or a timezone offset, e.g. "Europe/Berlin" or "+0100" for UTC+1) * @return DataResponse|DataResponse * * 200: User assignments returned @@ -141,7 +142,7 @@ public function getUserAssignment(int $id): DataResponse { * @param string|null $prompt The prompt to be sent to the assistant when the assignment is executed * @param int|null $startsAt The timestamp when the assignment should start being executed * @param string|null $recurrence The recurrence rule for the assignment, in RRULE format - * @param string|null $timezone The timezone for this assignment, omit to leave the current value in place + * @param string|null $timezone The timezone for this assignment, omit to leave the current value in place. the value should be either the timezone name or a timezone offset, e.g. "Europe/Berlin" or "+0100" for UTC+1 * * @return DataResponse|DataResponse * diff --git a/openapi.json b/openapi.json index 310f79b7..314c6213 100644 --- a/openapi.json +++ b/openapi.json @@ -30,7 +30,8 @@ "created_at", "updated_at", "starts_at", - "last_run_at" + "last_run_at", + "timezone" ], "properties": { "id": { @@ -61,6 +62,9 @@ "last_run_at": { "type": "integer", "format": "int64" + }, + "timezone": { + "type": "string" } } }, @@ -5337,7 +5341,8 @@ "title", "prompt", "startsAt", - "recurrence" + "recurrence", + "timezone" ], "properties": { "title": { @@ -5356,6 +5361,10 @@ "recurrence": { "type": "string", "description": "The recurrence rule for the assignment, in RRULE format (e.g. \"FREQ=DAILY;INTERVAL=1\" for a daily assignment)" + }, + "timezone": { + "type": "string", + "description": "The timezone for this assignment (either the timezone name or a timezone offset, e.g. \"Europe/Berlin\" or \"+0100\" for UTC+1)" } } } @@ -5925,6 +5934,11 @@ "format": "int64", "nullable": true, "description": "The timestamp when the assignment should start being executed" + }, + "timezone": { + "type": "string", + "nullable": true, + "description": "The timezone for this assignment, omit to leave the current value in place. the value should be either the timezone name or a timezone offset, e.g. \"Europe/Berlin\" or \"+0100\" for UTC+1" } } } @@ -6023,7 +6037,7 @@ } }, "400": { - "description": "Malformed recurrence rule", + "description": "Malformed input", "content": { "application/json": { "schema": {