From 0314d6cc159408b7d9f2bcef16af5939c6ded459 Mon Sep 17 00:00:00 2001 From: Prem Palanisamy Date: Fri, 13 Feb 2026 12:01:21 +0000 Subject: [PATCH 01/18] Add schedules API endpoints (GET, XList, Create) --- app/config/scopes/project.php | 6 + app/init/models.php | 3 + docs/references/schedules/create.md | 1 + docs/references/schedules/get.md | 1 + docs/references/schedules/list.md | 1 + src/Appwrite/Platform/Appwrite.php | 2 + .../Schedules/Http/Schedules/Create.php | 96 ++++++++++++++ .../Modules/Schedules/Http/Schedules/Get.php | 76 ++++++++++++ .../Schedules/Http/Schedules/XList.php | 117 ++++++++++++++++++ .../Platform/Modules/Schedules/Module.php | 14 +++ .../Modules/Schedules/Services/Http.php | 20 +++ .../Database/Validator/Queries/Schedules.php | 24 ++++ src/Appwrite/Utopia/Response.php | 4 + .../Utopia/Response/Model/Schedule.php | 95 ++++++++++++++ 14 files changed, 460 insertions(+) create mode 100644 docs/references/schedules/create.md create mode 100644 docs/references/schedules/get.md create mode 100644 docs/references/schedules/list.md create mode 100644 src/Appwrite/Platform/Modules/Schedules/Http/Schedules/Create.php create mode 100644 src/Appwrite/Platform/Modules/Schedules/Http/Schedules/Get.php create mode 100644 src/Appwrite/Platform/Modules/Schedules/Http/Schedules/XList.php create mode 100644 src/Appwrite/Platform/Modules/Schedules/Module.php create mode 100644 src/Appwrite/Platform/Modules/Schedules/Services/Http.php create mode 100644 src/Appwrite/Utopia/Database/Validator/Queries/Schedules.php create mode 100644 src/Appwrite/Utopia/Response/Model/Schedule.php diff --git a/app/config/scopes/project.php b/app/config/scopes/project.php index a6ad98e4e0..0a3feba9e7 100644 --- a/app/config/scopes/project.php +++ b/app/config/scopes/project.php @@ -145,6 +145,12 @@ return [ // List of publicly visible scopes 'rules.write' => [ 'description' => 'Access to create, update, and delete your project\'s proxy rules', ], + 'schedules.read' => [ + 'description' => 'Access to read your project\'s schedules', + ], + 'schedules.write' => [ + 'description' => 'Access to create, update, and delete your project\'s schedules', + ], 'migrations.read' => [ 'description' => 'Access to read your project\'s migrations', ], diff --git a/app/init/models.php b/app/init/models.php index b935136a63..d432852660 100644 --- a/app/init/models.php +++ b/app/init/models.php @@ -114,6 +114,7 @@ use Appwrite\Utopia\Response\Model\ResourceToken; use Appwrite\Utopia\Response\Model\Row; use Appwrite\Utopia\Response\Model\Rule; use Appwrite\Utopia\Response\Model\Runtime; +use Appwrite\Utopia\Response\Model\Schedule; use Appwrite\Utopia\Response\Model\Session; use Appwrite\Utopia\Response\Model\Site; use Appwrite\Utopia\Response\Model\Specification; @@ -198,6 +199,7 @@ Response::setModel(new BaseList('Metric List', Response::MODEL_METRIC_LIST, 'met Response::setModel(new BaseList('Variables List', Response::MODEL_VARIABLE_LIST, 'variables', Response::MODEL_VARIABLE)); Response::setModel(new BaseList('Status List', Response::MODEL_HEALTH_STATUS_LIST, 'statuses', Response::MODEL_HEALTH_STATUS)); Response::setModel(new BaseList('Rule List', Response::MODEL_PROXY_RULE_LIST, 'rules', Response::MODEL_PROXY_RULE)); +Response::setModel(new BaseList('Schedules List', Response::MODEL_SCHEDULE_LIST, 'schedules', Response::MODEL_SCHEDULE)); Response::setModel(new BaseList('Locale codes list', Response::MODEL_LOCALE_CODE_LIST, 'localeCodes', Response::MODEL_LOCALE_CODE)); Response::setModel(new BaseList('Provider list', Response::MODEL_PROVIDER_LIST, 'providers', Response::MODEL_PROVIDER)); Response::setModel(new BaseList('Message list', Response::MODEL_MESSAGE_LIST, 'messages', Response::MODEL_MESSAGE)); @@ -339,6 +341,7 @@ Response::setModel(new UsageProject()); Response::setModel(new Headers()); Response::setModel(new Specification()); Response::setModel(new Rule()); +Response::setModel(new Schedule()); Response::setModel(new TemplateSMS()); Response::setModel(new TemplateEmail()); Response::setModel(new ConsoleVariables()); diff --git a/docs/references/schedules/create.md b/docs/references/schedules/create.md new file mode 100644 index 0000000000..a10a0db1e7 --- /dev/null +++ b/docs/references/schedules/create.md @@ -0,0 +1 @@ +Create a new schedule for a resource. \ No newline at end of file diff --git a/docs/references/schedules/get.md b/docs/references/schedules/get.md new file mode 100644 index 0000000000..3a78e273e0 --- /dev/null +++ b/docs/references/schedules/get.md @@ -0,0 +1 @@ +Get a schedule by its unique ID. \ No newline at end of file diff --git a/docs/references/schedules/list.md b/docs/references/schedules/list.md new file mode 100644 index 0000000000..fe564386fa --- /dev/null +++ b/docs/references/schedules/list.md @@ -0,0 +1 @@ +Get a list of all the project's schedules. You can use the query params to filter your results. \ No newline at end of file diff --git a/src/Appwrite/Platform/Appwrite.php b/src/Appwrite/Platform/Appwrite.php index 9982b0bf1e..1007e8ee45 100644 --- a/src/Appwrite/Platform/Appwrite.php +++ b/src/Appwrite/Platform/Appwrite.php @@ -11,6 +11,7 @@ use Appwrite\Platform\Modules\Functions; use Appwrite\Platform\Modules\Health; use Appwrite\Platform\Modules\Projects; use Appwrite\Platform\Modules\Proxy; +use Appwrite\Platform\Modules\Schedules; use Appwrite\Platform\Modules\Sites; use Appwrite\Platform\Modules\Storage; use Appwrite\Platform\Modules\Tokens; @@ -28,6 +29,7 @@ class Appwrite extends Platform $this->addModule(new Projects\Module()); $this->addModule(new Functions\Module()); $this->addModule(new Health\Module()); + $this->addModule(new Schedules\Module()); $this->addModule(new Sites\Module()); $this->addModule(new Console\Module()); $this->addModule(new Proxy\Module()); diff --git a/src/Appwrite/Platform/Modules/Schedules/Http/Schedules/Create.php b/src/Appwrite/Platform/Modules/Schedules/Http/Schedules/Create.php new file mode 100644 index 0000000000..d3d4372172 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Schedules/Http/Schedules/Create.php @@ -0,0 +1,96 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_POST) + ->setHttpPath('/v1/schedules') + ->desc('Create schedule') + ->groups(['api', 'schedules']) + ->label('scope', 'schedules.write') + ->label('audits.event', 'schedule.create') + ->label('audits.resource', 'schedule/{response.$id}') + ->label('sdk', new Method( + namespace: 'schedules', + group: 'schedules', + name: 'create', + description: '/docs/references/schedules/create.md', + auth: [AuthType::ADMIN, AuthType::KEY], + responses: [ + new SDKResponse( + code: Response::STATUS_CODE_CREATED, + model: Response::MODEL_SCHEDULE, + ) + ], + )) + ->param('resourceType', '', new WhiteList([SCHEDULE_RESOURCE_TYPE_FUNCTION, SCHEDULE_RESOURCE_TYPE_EXECUTION, SCHEDULE_RESOURCE_TYPE_MESSAGE], true), 'The resource type for the schedule. Possible values: ' . implode(', ', [SCHEDULE_RESOURCE_TYPE_FUNCTION, SCHEDULE_RESOURCE_TYPE_EXECUTION, SCHEDULE_RESOURCE_TYPE_MESSAGE]) . '.') + ->param('resourceId', '', new UID(), 'The resource ID to associate with this schedule.') + ->param('schedule', '', new Cron(), 'Schedule CRON expression.') + ->param('active', false, new Boolean(), 'Whether the schedule is active.', true) + ->inject('response') + ->inject('project') + ->inject('dbForPlatform') + ->inject('authorization') + ->callback($this->action(...)); + } + + public function action( + string $resourceType, + string $resourceId, + string $schedule, + bool $active, + Response $response, + Document $project, + Database $dbForPlatform, + Authorization $authorization, + ): void { + try { + $doc = $authorization->skip( + fn () => $dbForPlatform->createDocument('schedules', new Document([ + 'region' => $project->getAttribute('region'), + 'resourceType' => $resourceType, + 'resourceId' => $resourceId, + 'resourceInternalId' => '', + 'resourceUpdatedAt' => DateTime::now(), + 'projectId' => $project->getId(), + 'schedule' => $schedule, + 'active' => $active, + ])) + ); + } catch (DuplicateException) { + throw new Exception(Exception::GENERAL_SERVER_ERROR, 'Failed to create schedule. Please try again.'); + } + + $response + ->setStatusCode(Response::STATUS_CODE_CREATED) + ->dynamic($doc, Response::MODEL_SCHEDULE); + } +} diff --git a/src/Appwrite/Platform/Modules/Schedules/Http/Schedules/Get.php b/src/Appwrite/Platform/Modules/Schedules/Http/Schedules/Get.php new file mode 100644 index 0000000000..afc617c063 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Schedules/Http/Schedules/Get.php @@ -0,0 +1,76 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_GET) + ->setHttpPath('/v1/schedules/:scheduleId') + ->desc('Get schedule') + ->groups(['api', 'schedules']) + ->label('scope', 'schedules.read') + ->label('sdk', new Method( + namespace: 'schedules', + group: 'schedules', + name: 'get', + description: '/docs/references/schedules/get.md', + auth: [AuthType::ADMIN, AuthType::KEY], + responses: [ + new SDKResponse( + code: Response::STATUS_CODE_OK, + model: Response::MODEL_SCHEDULE, + ) + ] + )) + ->param('scheduleId', '', new UID(), 'Schedule ID.') + ->inject('response') + ->inject('project') + ->inject('dbForPlatform') + ->inject('authorization') + ->callback($this->action(...)); + } + + public function action( + string $scheduleId, + Response $response, + Document $project, + Database $dbForPlatform, + Authorization $authorization, + ): void { + $schedule = $authorization->skip( + fn () => $dbForPlatform->getDocument('schedules', $scheduleId) + ); + + if ($schedule->isEmpty()) { + throw new Exception(Exception::SCHEDULE_NOT_FOUND); + } + + if ($schedule->getAttribute('projectId') !== $project->getId()) { + throw new Exception(Exception::SCHEDULE_NOT_FOUND); + } + + $response->dynamic($schedule, Response::MODEL_SCHEDULE); + } +} diff --git a/src/Appwrite/Platform/Modules/Schedules/Http/Schedules/XList.php b/src/Appwrite/Platform/Modules/Schedules/Http/Schedules/XList.php new file mode 100644 index 0000000000..c140156804 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Schedules/Http/Schedules/XList.php @@ -0,0 +1,117 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_GET) + ->setHttpPath('/v1/schedules') + ->desc('List schedules') + ->groups(['api', 'schedules']) + ->label('scope', 'schedules.read') + ->label('sdk', new Method( + namespace: 'schedules', + group: 'schedules', + name: 'list', + description: '/docs/references/schedules/list.md', + auth: [AuthType::ADMIN, AuthType::KEY], + responses: [ + new SDKResponse( + code: Response::STATUS_CODE_OK, + model: Response::MODEL_SCHEDULE_LIST, + ) + ] + )) + ->param('queries', [], new Schedules(), 'Array of query strings generated using the Query class provided by the SDK. [Learn more about queries](https://appwrite.io/docs/queries). Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE . ' queries are allowed, each ' . APP_LIMIT_ARRAY_ELEMENT_SIZE . ' characters long. You may filter on the following attributes: ' . implode(', ', Schedules::ALLOWED_ATTRIBUTES), true) + ->param('total', true, new Boolean(true), 'When set to false, the total count returned will be 0 and will not be calculated.', true) + ->inject('response') + ->inject('project') + ->inject('dbForPlatform') + ->inject('authorization') + ->callback($this->action(...)); + } + + public function action( + array $queries, + bool $includeTotal, + Response $response, + Document $project, + Database $dbForPlatform, + Authorization $authorization, + ): void { + try { + $queries = Query::parseQueries($queries); + } catch (QueryException $e) { + throw new Exception(Exception::GENERAL_QUERY_INVALID, $e->getMessage()); + } + + // Always scope to the current project + $queries[] = Query::equal('projectId', [$project->getId()]); + + $cursor = Query::getCursorQueries($queries, false); + $cursor = \reset($cursor); + + if ($cursor !== false) { + $validator = new Cursor(); + if (!$validator->isValid($cursor)) { + throw new Exception(Exception::GENERAL_QUERY_INVALID, $validator->getDescription()); + } + + $scheduleId = $cursor->getValue(); + $cursorDocument = $authorization->skip( + fn () => $dbForPlatform->getDocument('schedules', $scheduleId) + ); + + if ($cursorDocument->isEmpty()) { + throw new Exception(Exception::GENERAL_CURSOR_NOT_FOUND, "Schedule '{$scheduleId}' for the 'cursor' value not found."); + } + + $cursor->setValue($cursorDocument); + } + + $filterQueries = Query::groupByType($queries)['filters']; + + try { + $schedules = $authorization->skip( + fn () => $dbForPlatform->find('schedules', $queries) + ); + $total = $includeTotal ? $authorization->skip( + fn () => $dbForPlatform->count('schedules', $filterQueries, APP_LIMIT_COUNT) + ) : 0; + } catch (OrderException $e) { + throw new Exception(Exception::DATABASE_QUERY_ORDER_NULL, "The order attribute '{$e->getAttribute()}' had a null value. Cursor pagination requires all documents order attribute values are non-null."); + } + + $response->dynamic(new Document([ + 'schedules' => $schedules, + 'total' => $total, + ]), Response::MODEL_SCHEDULE_LIST); + } +} diff --git a/src/Appwrite/Platform/Modules/Schedules/Module.php b/src/Appwrite/Platform/Modules/Schedules/Module.php new file mode 100644 index 0000000000..39fcfa0175 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Schedules/Module.php @@ -0,0 +1,14 @@ +addService('http', new Http()); + } +} diff --git a/src/Appwrite/Platform/Modules/Schedules/Services/Http.php b/src/Appwrite/Platform/Modules/Schedules/Services/Http.php new file mode 100644 index 0000000000..1ea5baf0ce --- /dev/null +++ b/src/Appwrite/Platform/Modules/Schedules/Services/Http.php @@ -0,0 +1,20 @@ +type = Service::TYPE_HTTP; + + $this->addAction(Get::getName(), new Get()); + $this->addAction(XList::getName(), new XList()); + $this->addAction(Create::getName(), new Create()); + } +} diff --git a/src/Appwrite/Utopia/Database/Validator/Queries/Schedules.php b/src/Appwrite/Utopia/Database/Validator/Queries/Schedules.php new file mode 100644 index 0000000000..6798985c22 --- /dev/null +++ b/src/Appwrite/Utopia/Database/Validator/Queries/Schedules.php @@ -0,0 +1,24 @@ +addRule('$id', [ + 'type' => self::TYPE_STRING, + 'description' => 'Schedule ID.', + 'default' => '', + 'example' => '5e5ea5c16897e', + ]) + ->addRule('$createdAt', [ + 'type' => self::TYPE_DATETIME, + 'description' => 'Schedule creation date in ISO 8601 format.', + 'default' => '', + 'example' => self::TYPE_DATETIME_EXAMPLE, + ]) + ->addRule('$updatedAt', [ + 'type' => self::TYPE_DATETIME, + 'description' => 'Schedule update date in ISO 8601 format.', + 'default' => '', + 'example' => self::TYPE_DATETIME_EXAMPLE, + ]) + ->addRule('resourceType', [ + 'type' => self::TYPE_STRING, + 'description' => 'The resource type associated with this schedule.', + 'default' => '', + 'example' => 'function', + ]) + ->addRule('resourceId', [ + 'type' => self::TYPE_STRING, + 'description' => 'The resource ID associated with this schedule.', + 'default' => '', + 'example' => '5e5ea5c16897e', + ]) + ->addRule('resourceUpdatedAt', [ + 'type' => self::TYPE_DATETIME, + 'description' => 'The date the associated resource was last updated in ISO 8601 format.', + 'default' => '', + 'example' => self::TYPE_DATETIME_EXAMPLE, + ]) + ->addRule('projectId', [ + 'type' => self::TYPE_STRING, + 'description' => 'The project ID associated with this schedule.', + 'default' => '', + 'example' => '5e5ea5c16897e', + ]) + ->addRule('schedule', [ + 'type' => self::TYPE_STRING, + 'description' => 'The CRON schedule expression.', + 'default' => '', + 'example' => '5 4 * * *', + ]) + ->addRule('active', [ + 'type' => self::TYPE_BOOLEAN, + 'description' => 'Whether the schedule is active.', + 'default' => false, + 'example' => true, + ]) + ->addRule('region', [ + 'type' => self::TYPE_STRING, + 'description' => 'The region where the schedule is deployed.', + 'default' => '', + 'example' => 'fra', + ]) + ; + } + + /** + * Get Name + * + * @return string + */ + public function getName(): string + { + return 'Schedule'; + } + + /** + * Get Type + * + * @return string + */ + public function getType(): string + { + return Response::MODEL_SCHEDULE; + } +} From 5c28e59a833867b5b65b7006b7d0c4cdcabaeb93 Mon Sep 17 00:00:00 2001 From: Prem Palanisamy Date: Fri, 13 Feb 2026 15:41:35 +0000 Subject: [PATCH 02/18] Add E2E tests for schedules API --- phpunit.xml | 1 + tests/e2e/Scopes/ProjectCustom.php | 2 + .../e2e/Services/Schedules/SchedulesBase.php | 32 +++ .../Schedules/SchedulesCustomServerTest.php | 256 ++++++++++++++++++ 4 files changed, 291 insertions(+) create mode 100644 tests/e2e/Services/Schedules/SchedulesBase.php create mode 100644 tests/e2e/Services/Schedules/SchedulesCustomServerTest.php diff --git a/phpunit.xml b/phpunit.xml index a8578995c1..8f7323b35b 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -35,6 +35,7 @@ ./tests/e2e/Services/Webhooks ./tests/e2e/Services/Messaging ./tests/e2e/Services/Migrations + ./tests/e2e/Services/Schedules ./tests/e2e/Services/Functions/FunctionsBase.php ./tests/e2e/Services/Functions/FunctionsCustomServerTest.php ./tests/e2e/Services/Functions/FunctionsCustomClientTest.php diff --git a/tests/e2e/Scopes/ProjectCustom.php b/tests/e2e/Scopes/ProjectCustom.php index 1859d551a4..0bbaa13082 100644 --- a/tests/e2e/Scopes/ProjectCustom.php +++ b/tests/e2e/Scopes/ProjectCustom.php @@ -109,6 +109,8 @@ trait ProjectCustom 'subscribers.read', 'migrations.write', 'migrations.read', + 'schedules.read', + 'schedules.write', 'tokens.read', 'tokens.write', ], diff --git a/tests/e2e/Services/Schedules/SchedulesBase.php b/tests/e2e/Services/Schedules/SchedulesBase.php new file mode 100644 index 0000000000..01a7860de4 --- /dev/null +++ b/tests/e2e/Services/Schedules/SchedulesBase.php @@ -0,0 +1,32 @@ +client->call(Client::METHOD_POST, '/schedules', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), $params); + } + + protected function getSchedule(string $scheduleId): array + { + return $this->client->call(Client::METHOD_GET, '/schedules/' . $scheduleId, array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders())); + } + + protected function listSchedules(array $params = []): array + { + return $this->client->call(Client::METHOD_GET, '/schedules', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), $params); + } +} diff --git a/tests/e2e/Services/Schedules/SchedulesCustomServerTest.php b/tests/e2e/Services/Schedules/SchedulesCustomServerTest.php new file mode 100644 index 0000000000..000f0d9306 --- /dev/null +++ b/tests/e2e/Services/Schedules/SchedulesCustomServerTest.php @@ -0,0 +1,256 @@ +createSchedule([ + 'resourceType' => 'function', + 'resourceId' => ID::unique(), + 'schedule' => '0 0 * * *', + 'active' => true, + ]); + + $this->assertEquals(201, $response['headers']['status-code']); + $this->assertNotEmpty($response['body']['$id']); + $this->assertNotEmpty($response['body']['$createdAt']); + $this->assertNotEmpty($response['body']['$updatedAt']); + $this->assertEquals('function', $response['body']['resourceType']); + $this->assertNotEmpty($response['body']['resourceId']); + $this->assertNotEmpty($response['body']['resourceUpdatedAt']); + $this->assertNotEmpty($response['body']['projectId']); + $this->assertEquals('0 0 * * *', $response['body']['schedule']); + $this->assertTrue($response['body']['active']); + $this->assertNotEmpty($response['body']['region']); + + return ['scheduleId' => $response['body']['$id']]; + } + + public function testCreateScheduleExecutionType(): void + { + $response = $this->createSchedule([ + 'resourceType' => 'execution', + 'resourceId' => ID::unique(), + 'schedule' => '*/10 * * * *', + ]); + + $this->assertEquals(201, $response['headers']['status-code']); + $this->assertEquals('execution', $response['body']['resourceType']); + $this->assertFalse($response['body']['active']); + } + + public function testCreateScheduleMessageType(): void + { + $response = $this->createSchedule([ + 'resourceType' => 'message', + 'resourceId' => ID::unique(), + 'schedule' => '0 9 * * 1', + ]); + + $this->assertEquals(201, $response['headers']['status-code']); + $this->assertEquals('message', $response['body']['resourceType']); + } + + public function testCreateScheduleInvalidResourceType(): void + { + $response = $this->createSchedule([ + 'resourceType' => 'invalid', + 'resourceId' => ID::unique(), + 'schedule' => '0 0 * * *', + ]); + + $this->assertEquals(400, $response['headers']['status-code']); + } + + public function testCreateScheduleInvalidCron(): void + { + $response = $this->createSchedule([ + 'resourceType' => 'function', + 'resourceId' => ID::unique(), + 'schedule' => 'not-a-cron', + ]); + + $this->assertEquals(400, $response['headers']['status-code']); + } + + public function testCreateScheduleMissingRequired(): void + { + // Missing resourceType + $response = $this->createSchedule([ + 'resourceId' => ID::unique(), + 'schedule' => '0 0 * * *', + ]); + + $this->assertEquals(400, $response['headers']['status-code']); + + // Missing resourceId + $response = $this->createSchedule([ + 'resourceType' => 'function', + 'schedule' => '0 0 * * *', + ]); + + $this->assertEquals(400, $response['headers']['status-code']); + + // Missing schedule + $response = $this->createSchedule([ + 'resourceType' => 'function', + 'resourceId' => ID::unique(), + ]); + + $this->assertEquals(400, $response['headers']['status-code']); + } + + /** + * @depends testCreateSchedule + */ + public function testGetSchedule(array $data): void + { + $scheduleId = $data['scheduleId']; + + /** + * Test for SUCCESS + */ + $response = $this->getSchedule($scheduleId); + + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertEquals($scheduleId, $response['body']['$id']); + $this->assertEquals('function', $response['body']['resourceType']); + $this->assertEquals('0 0 * * *', $response['body']['schedule']); + $this->assertTrue($response['body']['active']); + } + + public function testGetScheduleNotFound(): void + { + $response = $this->getSchedule('nonexistent'); + + $this->assertEquals(404, $response['headers']['status-code']); + } + + /** + * @depends testCreateSchedule + */ + public function testListSchedules(array $data): void + { + /** + * Test for SUCCESS + */ + $response = $this->listSchedules(); + + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertIsArray($response['body']['schedules']); + $this->assertGreaterThanOrEqual(1, $response['body']['total']); + $this->assertGreaterThanOrEqual(1, \count($response['body']['schedules'])); + + // Verify schedule structure + $schedule = $response['body']['schedules'][0]; + $this->assertArrayHasKey('$id', $schedule); + $this->assertArrayHasKey('$createdAt', $schedule); + $this->assertArrayHasKey('$updatedAt', $schedule); + $this->assertArrayHasKey('resourceType', $schedule); + $this->assertArrayHasKey('resourceId', $schedule); + $this->assertArrayHasKey('projectId', $schedule); + $this->assertArrayHasKey('schedule', $schedule); + $this->assertArrayHasKey('active', $schedule); + $this->assertArrayHasKey('region', $schedule); + } + + /** + * @depends testCreateSchedule + */ + public function testListSchedulesWithQuery(array $data): void + { + // Filter by resourceType + $response = $this->listSchedules([ + 'queries' => ['equal("resourceType", "function")'], + ]); + + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertGreaterThanOrEqual(1, $response['body']['total']); + + foreach ($response['body']['schedules'] as $schedule) { + $this->assertEquals('function', $schedule['resourceType']); + } + + // Filter by active status + $response = $this->listSchedules([ + 'queries' => ['equal("active", true)'], + ]); + + $this->assertEquals(200, $response['headers']['status-code']); + + foreach ($response['body']['schedules'] as $schedule) { + $this->assertTrue($schedule['active']); + } + } + + public function testListSchedulesWithTotalDisabled(): void + { + $response = $this->listSchedules([ + 'total' => false, + ]); + + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertEquals(0, $response['body']['total']); + $this->assertIsArray($response['body']['schedules']); + } + + public function testListSchedulesInvalidQuery(): void + { + $response = $this->listSchedules([ + 'queries' => ['equal("nonexistent", "value")'], + ]); + + $this->assertEquals(400, $response['headers']['status-code']); + } + + public function testScheduleProjectIsolation(): void + { + // Create a schedule in the current project + $response = $this->createSchedule([ + 'resourceType' => 'function', + 'resourceId' => ID::unique(), + 'schedule' => '0 12 * * *', + ]); + + $this->assertEquals(201, $response['headers']['status-code']); + $scheduleId = $response['body']['$id']; + + // Create a fresh project with its own key + $freshProject = $this->getProject(true); + + // Try to get the schedule from the other project + $response = $this->client->call(\Tests\E2E\Client::METHOD_GET, '/schedules/' . $scheduleId, [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $freshProject['$id'], + 'x-appwrite-key' => $freshProject['apiKey'], + ]); + + $this->assertEquals(404, $response['headers']['status-code']); + + // List should not include schedules from other projects + $response = $this->client->call(\Tests\E2E\Client::METHOD_GET, '/schedules', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $freshProject['$id'], + 'x-appwrite-key' => $freshProject['apiKey'], + ]); + + $this->assertEquals(200, $response['headers']['status-code']); + + $scheduleIds = array_column($response['body']['schedules'], '$id'); + $this->assertNotContains($scheduleId, $scheduleIds); + } +} From f57f78014edcbe385a3fe35074a55ac30e89e2fa Mon Sep 17 00:00:00 2001 From: Prem Palanisamy Date: Fri, 13 Feb 2026 15:47:01 +0000 Subject: [PATCH 03/18] Fix query format in schedules E2E test --- tests/e2e/Services/Schedules/SchedulesCustomServerTest.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/e2e/Services/Schedules/SchedulesCustomServerTest.php b/tests/e2e/Services/Schedules/SchedulesCustomServerTest.php index 000f0d9306..609d047bd0 100644 --- a/tests/e2e/Services/Schedules/SchedulesCustomServerTest.php +++ b/tests/e2e/Services/Schedules/SchedulesCustomServerTest.php @@ -175,7 +175,7 @@ class SchedulesCustomServerTest extends Scope { // Filter by resourceType $response = $this->listSchedules([ - 'queries' => ['equal("resourceType", "function")'], + 'queries' => ['equal("resourceType", ["function"])'], ]); $this->assertEquals(200, $response['headers']['status-code']); @@ -187,7 +187,7 @@ class SchedulesCustomServerTest extends Scope // Filter by active status $response = $this->listSchedules([ - 'queries' => ['equal("active", true)'], + 'queries' => ['equal("active", [true])'], ]); $this->assertEquals(200, $response['headers']['status-code']); From 9b72b783388cc34969cf1629576f4fc812c85318 Mon Sep 17 00:00:00 2001 From: Prem Palanisamy Date: Fri, 13 Feb 2026 15:51:09 +0000 Subject: [PATCH 04/18] Use Query class in schedules E2E test --- tests/e2e/Services/Schedules/SchedulesCustomServerTest.php | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/tests/e2e/Services/Schedules/SchedulesCustomServerTest.php b/tests/e2e/Services/Schedules/SchedulesCustomServerTest.php index 609d047bd0..e4f9fd855b 100644 --- a/tests/e2e/Services/Schedules/SchedulesCustomServerTest.php +++ b/tests/e2e/Services/Schedules/SchedulesCustomServerTest.php @@ -6,6 +6,7 @@ use Tests\E2E\Scopes\ProjectCustom; use Tests\E2E\Scopes\Scope; use Tests\E2E\Scopes\SideServer; use Utopia\Database\Helpers\ID; +use Utopia\Database\Query; class SchedulesCustomServerTest extends Scope { @@ -175,7 +176,7 @@ class SchedulesCustomServerTest extends Scope { // Filter by resourceType $response = $this->listSchedules([ - 'queries' => ['equal("resourceType", ["function"])'], + 'queries' => [Query::equal('resourceType', ['function'])->toString()], ]); $this->assertEquals(200, $response['headers']['status-code']); @@ -187,7 +188,7 @@ class SchedulesCustomServerTest extends Scope // Filter by active status $response = $this->listSchedules([ - 'queries' => ['equal("active", [true])'], + 'queries' => [Query::equal('active', [true])->toString()], ]); $this->assertEquals(200, $response['headers']['status-code']); @@ -211,7 +212,7 @@ class SchedulesCustomServerTest extends Scope public function testListSchedulesInvalidQuery(): void { $response = $this->listSchedules([ - 'queries' => ['equal("nonexistent", "value")'], + 'queries' => [Query::equal('nonexistent', ['value'])->toString()], ]); $this->assertEquals(400, $response['headers']['status-code']); From 3fde05e024c4c536880df3e27ef33fe7d979bcc5 Mon Sep 17 00:00:00 2001 From: Prem Palanisamy Date: Fri, 13 Feb 2026 18:37:48 +0000 Subject: [PATCH 05/18] Add resource validation, admin scopes, and events for schedules --- app/config/events.php | 14 +++++++ app/config/roles.php | 2 + .../Schedules/Http/Schedules/Create.php | 20 +++++++++- .../e2e/Services/Schedules/SchedulesBase.php | 15 +++++++ .../Schedules/SchedulesCustomServerTest.php | 40 ++++++++++++------- 5 files changed, 76 insertions(+), 15 deletions(-) diff --git a/app/config/events.php b/app/config/events.php index c6006b569f..e029302214 100644 --- a/app/config/events.php +++ b/app/config/events.php @@ -405,6 +405,20 @@ return [ '$description' => 'This event triggers when a provider is deleted.' ], ], + 'schedules' => [ + '$model' => Response::MODEL_SCHEDULE, + '$resource' => true, + '$description' => 'This event triggers on any schedule event.', + 'create' => [ + '$description' => 'This event triggers when a schedule is created.', + ], + 'update' => [ + '$description' => 'This event triggers when a schedule is updated.', + ], + 'delete' => [ + '$description' => 'This event triggers when a schedule is deleted.', + ], + ], 'rules' => [ '$model' => Response::MODEL_PROXY_RULE, '$resource' => true, diff --git a/app/config/roles.php b/app/config/roles.php index 25a6bac4da..4473176c23 100644 --- a/app/config/roles.php +++ b/app/config/roles.php @@ -91,6 +91,8 @@ $admins = [ 'subscribers.read', 'tokens.read', 'tokens.write', + 'schedules.read', + 'schedules.write', ]; return [ diff --git a/src/Appwrite/Platform/Modules/Schedules/Http/Schedules/Create.php b/src/Appwrite/Platform/Modules/Schedules/Http/Schedules/Create.php index d3d4372172..5505a8ac8f 100644 --- a/src/Appwrite/Platform/Modules/Schedules/Http/Schedules/Create.php +++ b/src/Appwrite/Platform/Modules/Schedules/Http/Schedules/Create.php @@ -57,6 +57,7 @@ class Create extends Action ->param('active', false, new Boolean(), 'Whether the schedule is active.', true) ->inject('response') ->inject('project') + ->inject('dbForProject') ->inject('dbForPlatform') ->inject('authorization') ->callback($this->action(...)); @@ -69,16 +70,33 @@ class Create extends Action bool $active, Response $response, Document $project, + Database $dbForProject, Database $dbForPlatform, Authorization $authorization, ): void { + $collection = match ($resourceType) { + SCHEDULE_RESOURCE_TYPE_FUNCTION => 'functions', + SCHEDULE_RESOURCE_TYPE_EXECUTION => 'executions', + SCHEDULE_RESOURCE_TYPE_MESSAGE => 'messages', + }; + + $resource = $dbForProject->getDocument($collection, $resourceId); + + if ($resource->isEmpty()) { + throw new Exception(match ($resourceType) { + SCHEDULE_RESOURCE_TYPE_FUNCTION => Exception::FUNCTION_NOT_FOUND, + SCHEDULE_RESOURCE_TYPE_EXECUTION => Exception::EXECUTION_NOT_FOUND, + SCHEDULE_RESOURCE_TYPE_MESSAGE => Exception::MESSAGE_NOT_FOUND, + }); + } + try { $doc = $authorization->skip( fn () => $dbForPlatform->createDocument('schedules', new Document([ 'region' => $project->getAttribute('region'), 'resourceType' => $resourceType, 'resourceId' => $resourceId, - 'resourceInternalId' => '', + 'resourceInternalId' => $resource->getSequence(), 'resourceUpdatedAt' => DateTime::now(), 'projectId' => $project->getId(), 'schedule' => $schedule, diff --git a/tests/e2e/Services/Schedules/SchedulesBase.php b/tests/e2e/Services/Schedules/SchedulesBase.php index 01a7860de4..93d24a572d 100644 --- a/tests/e2e/Services/Schedules/SchedulesBase.php +++ b/tests/e2e/Services/Schedules/SchedulesBase.php @@ -3,6 +3,7 @@ namespace Tests\E2E\Services\Schedules; use Tests\E2E\Client; +use Utopia\Database\Helpers\ID; trait SchedulesBase { @@ -29,4 +30,18 @@ trait SchedulesBase 'x-appwrite-project' => $this->getProject()['$id'], ], $this->getHeaders()), $params); } + + protected function createFunction(array $params = []): array + { + return $this->client->call(Client::METHOD_POST, '/functions', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), array_merge([ + 'functionId' => ID::unique(), + 'name' => 'Test Schedule Function', + 'runtime' => 'node-22', + 'entrypoint' => 'index.js', + 'execute' => ['any'], + ], $params)); + } } diff --git a/tests/e2e/Services/Schedules/SchedulesCustomServerTest.php b/tests/e2e/Services/Schedules/SchedulesCustomServerTest.php index e4f9fd855b..aff48202fa 100644 --- a/tests/e2e/Services/Schedules/SchedulesCustomServerTest.php +++ b/tests/e2e/Services/Schedules/SchedulesCustomServerTest.php @@ -19,9 +19,13 @@ class SchedulesCustomServerTest extends Scope /** * Test for SUCCESS */ + $function = $this->createFunction(); + $this->assertEquals(201, $function['headers']['status-code']); + $functionId = $function['body']['$id']; + $response = $this->createSchedule([ 'resourceType' => 'function', - 'resourceId' => ID::unique(), + 'resourceId' => $functionId, 'schedule' => '0 0 * * *', 'active' => true, ]); @@ -31,39 +35,44 @@ class SchedulesCustomServerTest extends Scope $this->assertNotEmpty($response['body']['$createdAt']); $this->assertNotEmpty($response['body']['$updatedAt']); $this->assertEquals('function', $response['body']['resourceType']); - $this->assertNotEmpty($response['body']['resourceId']); + $this->assertEquals($functionId, $response['body']['resourceId']); $this->assertNotEmpty($response['body']['resourceUpdatedAt']); $this->assertNotEmpty($response['body']['projectId']); $this->assertEquals('0 0 * * *', $response['body']['schedule']); $this->assertTrue($response['body']['active']); $this->assertNotEmpty($response['body']['region']); - return ['scheduleId' => $response['body']['$id']]; + return ['scheduleId' => $response['body']['$id'], 'functionId' => $functionId]; } - public function testCreateScheduleExecutionType(): void + public function testCreateScheduleResourceNotFound(): void { + // Function not found + $response = $this->createSchedule([ + 'resourceType' => 'function', + 'resourceId' => ID::unique(), + 'schedule' => '0 0 * * *', + ]); + + $this->assertEquals(404, $response['headers']['status-code']); + + // Execution not found $response = $this->createSchedule([ 'resourceType' => 'execution', 'resourceId' => ID::unique(), 'schedule' => '*/10 * * * *', ]); - $this->assertEquals(201, $response['headers']['status-code']); - $this->assertEquals('execution', $response['body']['resourceType']); - $this->assertFalse($response['body']['active']); - } + $this->assertEquals(404, $response['headers']['status-code']); - public function testCreateScheduleMessageType(): void - { + // Message not found $response = $this->createSchedule([ 'resourceType' => 'message', 'resourceId' => ID::unique(), 'schedule' => '0 9 * * 1', ]); - $this->assertEquals(201, $response['headers']['status-code']); - $this->assertEquals('message', $response['body']['resourceType']); + $this->assertEquals(404, $response['headers']['status-code']); } public function testCreateScheduleInvalidResourceType(): void @@ -220,10 +229,13 @@ class SchedulesCustomServerTest extends Scope public function testScheduleProjectIsolation(): void { - // Create a schedule in the current project + // Create a function and schedule in the current project + $function = $this->createFunction(); + $this->assertEquals(201, $function['headers']['status-code']); + $response = $this->createSchedule([ 'resourceType' => 'function', - 'resourceId' => ID::unique(), + 'resourceId' => $function['body']['$id'], 'schedule' => '0 12 * * *', ]); From 63d2efdaac4e9f3bd032caa94c3cf9502ae5c334 Mon Sep 17 00:00:00 2001 From: Prem Palanisamy Date: Sun, 15 Feb 2026 22:46:00 +0000 Subject: [PATCH 06/18] Move schedule endpoints to projects group (/v1/projects/:projectId/schedules) --- .../Schedules/Http/Schedules/Create.php | 26 +- .../Modules/Schedules/Http/Schedules/Get.php | 21 +- .../Schedules/Http/Schedules/XList.php | 22 +- tests/e2e/Scopes/ProjectCustom.php | 2 - .../e2e/Services/Schedules/SchedulesBase.php | 65 +++-- .../Schedules/SchedulesCustomServerTest.php | 237 +++++++++++------- 6 files changed, 228 insertions(+), 145 deletions(-) diff --git a/src/Appwrite/Platform/Modules/Schedules/Http/Schedules/Create.php b/src/Appwrite/Platform/Modules/Schedules/Http/Schedules/Create.php index 5505a8ac8f..0aabe050cf 100644 --- a/src/Appwrite/Platform/Modules/Schedules/Http/Schedules/Create.php +++ b/src/Appwrite/Platform/Modules/Schedules/Http/Schedules/Create.php @@ -32,18 +32,18 @@ class Create extends Action { $this ->setHttpMethod(Action::HTTP_REQUEST_METHOD_POST) - ->setHttpPath('/v1/schedules') + ->setHttpPath('/v1/projects/:projectId/schedules') ->desc('Create schedule') - ->groups(['api', 'schedules']) + ->groups(['api', 'projects']) ->label('scope', 'schedules.write') ->label('audits.event', 'schedule.create') ->label('audits.resource', 'schedule/{response.$id}') ->label('sdk', new Method( - namespace: 'schedules', + namespace: 'projects', group: 'schedules', - name: 'create', + name: 'createSchedule', description: '/docs/references/schedules/create.md', - auth: [AuthType::ADMIN, AuthType::KEY], + auth: [AuthType::ADMIN], responses: [ new SDKResponse( code: Response::STATUS_CODE_CREATED, @@ -51,29 +51,37 @@ class Create extends Action ) ], )) + ->param('projectId', '', new UID(), 'Project unique ID.') ->param('resourceType', '', new WhiteList([SCHEDULE_RESOURCE_TYPE_FUNCTION, SCHEDULE_RESOURCE_TYPE_EXECUTION, SCHEDULE_RESOURCE_TYPE_MESSAGE], true), 'The resource type for the schedule. Possible values: ' . implode(', ', [SCHEDULE_RESOURCE_TYPE_FUNCTION, SCHEDULE_RESOURCE_TYPE_EXECUTION, SCHEDULE_RESOURCE_TYPE_MESSAGE]) . '.') ->param('resourceId', '', new UID(), 'The resource ID to associate with this schedule.') ->param('schedule', '', new Cron(), 'Schedule CRON expression.') ->param('active', false, new Boolean(), 'Whether the schedule is active.', true) ->inject('response') - ->inject('project') - ->inject('dbForProject') ->inject('dbForPlatform') + ->inject('getProjectDB') ->inject('authorization') ->callback($this->action(...)); } public function action( + string $projectId, string $resourceType, string $resourceId, string $schedule, bool $active, Response $response, - Document $project, - Database $dbForProject, Database $dbForPlatform, + callable $getProjectDB, Authorization $authorization, ): void { + $project = $dbForPlatform->getDocument('projects', $projectId); + + if ($project->isEmpty()) { + throw new Exception(Exception::PROJECT_NOT_FOUND); + } + + $dbForProject = $getProjectDB($project); + $collection = match ($resourceType) { SCHEDULE_RESOURCE_TYPE_FUNCTION => 'functions', SCHEDULE_RESOURCE_TYPE_EXECUTION => 'executions', diff --git a/src/Appwrite/Platform/Modules/Schedules/Http/Schedules/Get.php b/src/Appwrite/Platform/Modules/Schedules/Http/Schedules/Get.php index afc617c063..b16651dd0c 100644 --- a/src/Appwrite/Platform/Modules/Schedules/Http/Schedules/Get.php +++ b/src/Appwrite/Platform/Modules/Schedules/Http/Schedules/Get.php @@ -8,7 +8,6 @@ use Appwrite\SDK\Method; use Appwrite\SDK\Response as SDKResponse; use Appwrite\Utopia\Response; use Utopia\Database\Database; -use Utopia\Database\Document; use Utopia\Database\Validator\Authorization; use Utopia\Database\Validator\UID; use Utopia\Platform\Action; @@ -27,16 +26,16 @@ class Get extends Action { $this ->setHttpMethod(Action::HTTP_REQUEST_METHOD_GET) - ->setHttpPath('/v1/schedules/:scheduleId') + ->setHttpPath('/v1/projects/:projectId/schedules/:scheduleId') ->desc('Get schedule') - ->groups(['api', 'schedules']) + ->groups(['api', 'projects']) ->label('scope', 'schedules.read') ->label('sdk', new Method( - namespace: 'schedules', + namespace: 'projects', group: 'schedules', - name: 'get', + name: 'getSchedule', description: '/docs/references/schedules/get.md', - auth: [AuthType::ADMIN, AuthType::KEY], + auth: [AuthType::ADMIN], responses: [ new SDKResponse( code: Response::STATUS_CODE_OK, @@ -44,21 +43,27 @@ class Get extends Action ) ] )) + ->param('projectId', '', new UID(), 'Project unique ID.') ->param('scheduleId', '', new UID(), 'Schedule ID.') ->inject('response') - ->inject('project') ->inject('dbForPlatform') ->inject('authorization') ->callback($this->action(...)); } public function action( + string $projectId, string $scheduleId, Response $response, - Document $project, Database $dbForPlatform, Authorization $authorization, ): void { + $project = $dbForPlatform->getDocument('projects', $projectId); + + if ($project->isEmpty()) { + throw new Exception(Exception::PROJECT_NOT_FOUND); + } + $schedule = $authorization->skip( fn () => $dbForPlatform->getDocument('schedules', $scheduleId) ); diff --git a/src/Appwrite/Platform/Modules/Schedules/Http/Schedules/XList.php b/src/Appwrite/Platform/Modules/Schedules/Http/Schedules/XList.php index c140156804..77799b8593 100644 --- a/src/Appwrite/Platform/Modules/Schedules/Http/Schedules/XList.php +++ b/src/Appwrite/Platform/Modules/Schedules/Http/Schedules/XList.php @@ -15,6 +15,7 @@ use Utopia\Database\Exception\Query as QueryException; use Utopia\Database\Query; use Utopia\Database\Validator\Authorization; use Utopia\Database\Validator\Query\Cursor; +use Utopia\Database\Validator\UID; use Utopia\Platform\Action; use Utopia\Platform\Scope\HTTP; use Utopia\Validator\Boolean; @@ -32,16 +33,16 @@ class XList extends Action { $this ->setHttpMethod(Action::HTTP_REQUEST_METHOD_GET) - ->setHttpPath('/v1/schedules') + ->setHttpPath('/v1/projects/:projectId/schedules') ->desc('List schedules') - ->groups(['api', 'schedules']) + ->groups(['api', 'projects']) ->label('scope', 'schedules.read') ->label('sdk', new Method( - namespace: 'schedules', + namespace: 'projects', group: 'schedules', - name: 'list', + name: 'listSchedules', description: '/docs/references/schedules/list.md', - auth: [AuthType::ADMIN, AuthType::KEY], + auth: [AuthType::ADMIN], responses: [ new SDKResponse( code: Response::STATUS_CODE_OK, @@ -49,30 +50,35 @@ class XList extends Action ) ] )) + ->param('projectId', '', new UID(), 'Project unique ID.') ->param('queries', [], new Schedules(), 'Array of query strings generated using the Query class provided by the SDK. [Learn more about queries](https://appwrite.io/docs/queries). Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE . ' queries are allowed, each ' . APP_LIMIT_ARRAY_ELEMENT_SIZE . ' characters long. You may filter on the following attributes: ' . implode(', ', Schedules::ALLOWED_ATTRIBUTES), true) ->param('total', true, new Boolean(true), 'When set to false, the total count returned will be 0 and will not be calculated.', true) ->inject('response') - ->inject('project') ->inject('dbForPlatform') ->inject('authorization') ->callback($this->action(...)); } public function action( + string $projectId, array $queries, bool $includeTotal, Response $response, - Document $project, Database $dbForPlatform, Authorization $authorization, ): void { + $project = $dbForPlatform->getDocument('projects', $projectId); + + if ($project->isEmpty()) { + throw new Exception(Exception::PROJECT_NOT_FOUND); + } + try { $queries = Query::parseQueries($queries); } catch (QueryException $e) { throw new Exception(Exception::GENERAL_QUERY_INVALID, $e->getMessage()); } - // Always scope to the current project $queries[] = Query::equal('projectId', [$project->getId()]); $cursor = Query::getCursorQueries($queries, false); diff --git a/tests/e2e/Scopes/ProjectCustom.php b/tests/e2e/Scopes/ProjectCustom.php index 0bbaa13082..1859d551a4 100644 --- a/tests/e2e/Scopes/ProjectCustom.php +++ b/tests/e2e/Scopes/ProjectCustom.php @@ -109,8 +109,6 @@ trait ProjectCustom 'subscribers.read', 'migrations.write', 'migrations.read', - 'schedules.read', - 'schedules.write', 'tokens.read', 'tokens.write', ], diff --git a/tests/e2e/Services/Schedules/SchedulesBase.php b/tests/e2e/Services/Schedules/SchedulesBase.php index 93d24a572d..cd8f424f8d 100644 --- a/tests/e2e/Services/Schedules/SchedulesBase.php +++ b/tests/e2e/Services/Schedules/SchedulesBase.php @@ -4,44 +4,57 @@ namespace Tests\E2E\Services\Schedules; use Tests\E2E\Client; use Utopia\Database\Helpers\ID; +use Utopia\System\System; trait SchedulesBase { - protected function createSchedule(array $params = []): array + public function testCreateProject(): array { - return $this->client->call(Client::METHOD_POST, '/schedules', array_merge([ + $team = $this->client->call(Client::METHOD_POST, '/teams', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], - ], $this->getHeaders()), $params); - } + ], $this->getHeaders()), [ + 'teamId' => ID::unique(), + 'name' => 'Schedule Test Team', + ]); - protected function getSchedule(string $scheduleId): array - { - return $this->client->call(Client::METHOD_GET, '/schedules/' . $scheduleId, array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - ], $this->getHeaders())); - } + $this->assertEquals(201, $team['headers']['status-code']); - protected function listSchedules(array $params = []): array - { - return $this->client->call(Client::METHOD_GET, '/schedules', array_merge([ + $project = $this->client->call(Client::METHOD_POST, '/projects', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], - ], $this->getHeaders()), $params); - } + ], $this->getHeaders()), [ + 'projectId' => ID::unique(), + 'name' => 'Schedule Test Project', + 'teamId' => $team['body']['$id'], + 'region' => System::getEnv('_APP_REGION', 'default'), + ]); - protected function createFunction(array $params = []): array - { - return $this->client->call(Client::METHOD_POST, '/functions', array_merge([ + $this->assertEquals(201, $project['headers']['status-code']); + + $projectId = $project['body']['$id']; + + $key = $this->client->call(Client::METHOD_POST, '/projects/' . $projectId . '/keys', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], - ], $this->getHeaders()), array_merge([ - 'functionId' => ID::unique(), - 'name' => 'Test Schedule Function', - 'runtime' => 'node-22', - 'entrypoint' => 'index.js', - 'execute' => ['any'], - ], $params)); + ], $this->getHeaders()), [ + 'keyId' => ID::unique(), + 'name' => 'Schedule Test Key', + 'scopes' => [ + 'functions.read', + 'functions.write', + 'execution.read', + 'execution.write', + 'messages.read', + 'messages.write', + ], + ]); + + $this->assertEquals(201, $key['headers']['status-code']); + + return [ + 'projectId' => $projectId, + 'apiKey' => $key['body']['secret'], + ]; } } diff --git a/tests/e2e/Services/Schedules/SchedulesCustomServerTest.php b/tests/e2e/Services/Schedules/SchedulesCustomServerTest.php index aff48202fa..8ba00e4d3e 100644 --- a/tests/e2e/Services/Schedules/SchedulesCustomServerTest.php +++ b/tests/e2e/Services/Schedules/SchedulesCustomServerTest.php @@ -2,28 +2,50 @@ namespace Tests\E2E\Services\Schedules; -use Tests\E2E\Scopes\ProjectCustom; +use Tests\E2E\Client; +use Tests\E2E\Scopes\ProjectConsole; use Tests\E2E\Scopes\Scope; -use Tests\E2E\Scopes\SideServer; +use Tests\E2E\Scopes\SideClient; use Utopia\Database\Helpers\ID; use Utopia\Database\Query; +use Utopia\System\System; class SchedulesCustomServerTest extends Scope { use SchedulesBase; - use ProjectCustom; - use SideServer; + use ProjectConsole; + use SideClient; - public function testCreateSchedule(): array + /** + * @depends testCreateProject + */ + public function testCreateSchedule($data): array { + $id = $data['projectId'] ?? ''; + $apiKey = $data['apiKey'] ?? ''; + /** * Test for SUCCESS */ - $function = $this->createFunction(); + $function = $this->client->call(Client::METHOD_POST, '/functions', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $id, + 'x-appwrite-key' => $apiKey, + ], [ + 'functionId' => ID::unique(), + 'name' => 'Test Schedule Function', + 'runtime' => 'node-22', + 'entrypoint' => 'index.js', + 'execute' => ['any'], + ]); + $this->assertEquals(201, $function['headers']['status-code']); $functionId = $function['body']['$id']; - $response = $this->createSchedule([ + $response = $this->client->call(Client::METHOD_POST, '/projects/' . $id . '/schedules', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ 'resourceType' => 'function', 'resourceId' => $functionId, 'schedule' => '0 0 * * *', @@ -42,13 +64,20 @@ class SchedulesCustomServerTest extends Scope $this->assertTrue($response['body']['active']); $this->assertNotEmpty($response['body']['region']); - return ['scheduleId' => $response['body']['$id'], 'functionId' => $functionId]; - } + $data = array_merge($data, [ + 'scheduleId' => $response['body']['$id'], + 'functionId' => $functionId, + ]); - public function testCreateScheduleResourceNotFound(): void - { - // Function not found - $response = $this->createSchedule([ + /** + * Test for FAILURE + */ + + // Resource not found + $response = $this->client->call(Client::METHOD_POST, '/projects/' . $id . '/schedules', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ 'resourceType' => 'function', 'resourceId' => ID::unique(), 'schedule' => '0 0 * * *', @@ -56,51 +85,35 @@ class SchedulesCustomServerTest extends Scope $this->assertEquals(404, $response['headers']['status-code']); - // Execution not found - $response = $this->createSchedule([ - 'resourceType' => 'execution', - 'resourceId' => ID::unique(), - 'schedule' => '*/10 * * * *', - ]); - - $this->assertEquals(404, $response['headers']['status-code']); - - // Message not found - $response = $this->createSchedule([ - 'resourceType' => 'message', - 'resourceId' => ID::unique(), - 'schedule' => '0 9 * * 1', - ]); - - $this->assertEquals(404, $response['headers']['status-code']); - } - - public function testCreateScheduleInvalidResourceType(): void - { - $response = $this->createSchedule([ + // Invalid resource type + $response = $this->client->call(Client::METHOD_POST, '/projects/' . $id . '/schedules', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ 'resourceType' => 'invalid', 'resourceId' => ID::unique(), 'schedule' => '0 0 * * *', ]); $this->assertEquals(400, $response['headers']['status-code']); - } - public function testCreateScheduleInvalidCron(): void - { - $response = $this->createSchedule([ + // Invalid cron + $response = $this->client->call(Client::METHOD_POST, '/projects/' . $id . '/schedules', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ 'resourceType' => 'function', 'resourceId' => ID::unique(), 'schedule' => 'not-a-cron', ]); $this->assertEquals(400, $response['headers']['status-code']); - } - public function testCreateScheduleMissingRequired(): void - { // Missing resourceType - $response = $this->createSchedule([ + $response = $this->client->call(Client::METHOD_POST, '/projects/' . $id . '/schedules', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ 'resourceId' => ID::unique(), 'schedule' => '0 0 * * *', ]); @@ -108,7 +121,10 @@ class SchedulesCustomServerTest extends Scope $this->assertEquals(400, $response['headers']['status-code']); // Missing resourceId - $response = $this->createSchedule([ + $response = $this->client->call(Client::METHOD_POST, '/projects/' . $id . '/schedules', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ 'resourceType' => 'function', 'schedule' => '0 0 * * *', ]); @@ -116,49 +132,69 @@ class SchedulesCustomServerTest extends Scope $this->assertEquals(400, $response['headers']['status-code']); // Missing schedule - $response = $this->createSchedule([ + $response = $this->client->call(Client::METHOD_POST, '/projects/' . $id . '/schedules', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ 'resourceType' => 'function', 'resourceId' => ID::unique(), ]); $this->assertEquals(400, $response['headers']['status-code']); + + return $data; } /** * @depends testCreateSchedule */ - public function testGetSchedule(array $data): void + public function testGetSchedule($data): array { - $scheduleId = $data['scheduleId']; + $id = $data['projectId'] ?? ''; + $scheduleId = $data['scheduleId'] ?? ''; /** * Test for SUCCESS */ - $response = $this->getSchedule($scheduleId); + $response = $this->client->call(Client::METHOD_GET, '/projects/' . $id . '/schedules/' . $scheduleId, array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), []); $this->assertEquals(200, $response['headers']['status-code']); + $this->assertNotEmpty($response['body']['$id']); $this->assertEquals($scheduleId, $response['body']['$id']); $this->assertEquals('function', $response['body']['resourceType']); $this->assertEquals('0 0 * * *', $response['body']['schedule']); $this->assertTrue($response['body']['active']); - } - public function testGetScheduleNotFound(): void - { - $response = $this->getSchedule('nonexistent'); + /** + * Test for FAILURE + */ + $response = $this->client->call(Client::METHOD_GET, '/projects/' . $id . '/schedules/error', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), []); $this->assertEquals(404, $response['headers']['status-code']); + + return $data; } /** * @depends testCreateSchedule */ - public function testListSchedules(array $data): void + public function testListSchedules($data): array { + $id = $data['projectId'] ?? ''; + /** * Test for SUCCESS */ - $response = $this->listSchedules(); + $response = $this->client->call(Client::METHOD_GET, '/projects/' . $id . '/schedules', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), []); $this->assertEquals(200, $response['headers']['status-code']); $this->assertIsArray($response['body']['schedules']); @@ -176,15 +212,12 @@ class SchedulesCustomServerTest extends Scope $this->assertArrayHasKey('schedule', $schedule); $this->assertArrayHasKey('active', $schedule); $this->assertArrayHasKey('region', $schedule); - } - /** - * @depends testCreateSchedule - */ - public function testListSchedulesWithQuery(array $data): void - { - // Filter by resourceType - $response = $this->listSchedules([ + /** Filter by resourceType */ + $response = $this->client->call(Client::METHOD_GET, '/projects/' . $id . '/schedules', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ 'queries' => [Query::equal('resourceType', ['function'])->toString()], ]); @@ -195,8 +228,11 @@ class SchedulesCustomServerTest extends Scope $this->assertEquals('function', $schedule['resourceType']); } - // Filter by active status - $response = $this->listSchedules([ + /** Filter by active status */ + $response = $this->client->call(Client::METHOD_GET, '/projects/' . $id . '/schedules', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ 'queries' => [Query::equal('active', [true])->toString()], ]); @@ -205,61 +241,78 @@ class SchedulesCustomServerTest extends Scope foreach ($response['body']['schedules'] as $schedule) { $this->assertTrue($schedule['active']); } - } - public function testListSchedulesWithTotalDisabled(): void - { - $response = $this->listSchedules([ + /** List with total disabled */ + $response = $this->client->call(Client::METHOD_GET, '/projects/' . $id . '/schedules', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ 'total' => false, ]); $this->assertEquals(200, $response['headers']['status-code']); $this->assertEquals(0, $response['body']['total']); $this->assertIsArray($response['body']['schedules']); - } - public function testListSchedulesInvalidQuery(): void - { - $response = $this->listSchedules([ + /** + * Test for FAILURE + */ + $response = $this->client->call(Client::METHOD_GET, '/projects/' . $id . '/schedules', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ 'queries' => [Query::equal('nonexistent', ['value'])->toString()], ]); $this->assertEquals(400, $response['headers']['status-code']); + + return $data; } - public function testScheduleProjectIsolation(): void + /** + * @depends testCreateSchedule + */ + public function testScheduleProjectIsolation($data): void { - // Create a function and schedule in the current project - $function = $this->createFunction(); - $this->assertEquals(201, $function['headers']['status-code']); + $scheduleId = $data['scheduleId'] ?? ''; - $response = $this->createSchedule([ - 'resourceType' => 'function', - 'resourceId' => $function['body']['$id'], - 'schedule' => '0 12 * * *', + // Create a second project + $team = $this->client->call(Client::METHOD_POST, '/teams', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'teamId' => ID::unique(), + 'name' => 'Isolation Test Team', ]); - $this->assertEquals(201, $response['headers']['status-code']); - $scheduleId = $response['body']['$id']; + $this->assertEquals(201, $team['headers']['status-code']); - // Create a fresh project with its own key - $freshProject = $this->getProject(true); + $otherProject = $this->client->call(Client::METHOD_POST, '/projects', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'projectId' => ID::unique(), + 'name' => 'Isolation Test Project', + 'teamId' => $team['body']['$id'], + 'region' => System::getEnv('_APP_REGION', 'default'), + ]); + + $this->assertEquals(201, $otherProject['headers']['status-code']); + $otherProjectId = $otherProject['body']['$id']; // Try to get the schedule from the other project - $response = $this->client->call(\Tests\E2E\Client::METHOD_GET, '/schedules/' . $scheduleId, [ + $response = $this->client->call(Client::METHOD_GET, '/projects/' . $otherProjectId . '/schedules/' . $scheduleId, array_merge([ 'content-type' => 'application/json', - 'x-appwrite-project' => $freshProject['$id'], - 'x-appwrite-key' => $freshProject['apiKey'], - ]); + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), []); $this->assertEquals(404, $response['headers']['status-code']); // List should not include schedules from other projects - $response = $this->client->call(\Tests\E2E\Client::METHOD_GET, '/schedules', [ + $response = $this->client->call(Client::METHOD_GET, '/projects/' . $otherProjectId . '/schedules', array_merge([ 'content-type' => 'application/json', - 'x-appwrite-project' => $freshProject['$id'], - 'x-appwrite-key' => $freshProject['apiKey'], - ]); + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), []); $this->assertEquals(200, $response['headers']['status-code']); From 374b0879800ce3169057a0f9ebe2b58eef6dd9b2 Mon Sep 17 00:00:00 2001 From: Prem Palanisamy Date: Mon, 16 Feb 2026 06:33:11 +0000 Subject: [PATCH 07/18] Rename SchedulesCustomServerTest to SchedulesConsoleClientTest --- ...dulesCustomServerTest.php => SchedulesConsoleClientTest.php} | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) rename tests/e2e/Services/Schedules/{SchedulesCustomServerTest.php => SchedulesConsoleClientTest.php} (99%) diff --git a/tests/e2e/Services/Schedules/SchedulesCustomServerTest.php b/tests/e2e/Services/Schedules/SchedulesConsoleClientTest.php similarity index 99% rename from tests/e2e/Services/Schedules/SchedulesCustomServerTest.php rename to tests/e2e/Services/Schedules/SchedulesConsoleClientTest.php index 8ba00e4d3e..8d9d556f12 100644 --- a/tests/e2e/Services/Schedules/SchedulesCustomServerTest.php +++ b/tests/e2e/Services/Schedules/SchedulesConsoleClientTest.php @@ -10,7 +10,7 @@ use Utopia\Database\Helpers\ID; use Utopia\Database\Query; use Utopia\System\System; -class SchedulesCustomServerTest extends Scope +class SchedulesConsoleClientTest extends Scope { use SchedulesBase; use ProjectConsole; From 24b1a7064685b2fe282605649feb9ac5b177ce98 Mon Sep 17 00:00:00 2001 From: Prem Palanisamy Date: Mon, 16 Feb 2026 11:18:50 +0000 Subject: [PATCH 08/18] Add AAA Certificate Services root cert to fix SSL verification --- Dockerfile | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/Dockerfile b/Dockerfile index 9afe164809..94ff6eee27 100755 --- a/Dockerfile +++ b/Dockerfile @@ -14,6 +14,10 @@ RUN composer install --ignore-platform-reqs --optimize-autoloader \ FROM appwrite/base:1.0.0 AS base +RUN wget -qO /usr/local/share/ca-certificates/AAA_Certificate_Services.crt \ + "https://crt.sh/?d=331986" \ + && update-ca-certificates + LABEL maintainer="team@appwrite.io" ARG VERSION=dev From 2c35b7a4b5c901e2d22f8149dc47587f63c9ca55 Mon Sep 17 00:00:00 2001 From: Prem Palanisamy Date: Mon, 16 Feb 2026 11:41:19 +0000 Subject: [PATCH 09/18] Fix testNoHostValidationWithDevKey: avoid external dependency on example.com --- Dockerfile | 4 ---- 1 file changed, 4 deletions(-) diff --git a/Dockerfile b/Dockerfile index 94ff6eee27..9afe164809 100755 --- a/Dockerfile +++ b/Dockerfile @@ -14,10 +14,6 @@ RUN composer install --ignore-platform-reqs --optimize-autoloader \ FROM appwrite/base:1.0.0 AS base -RUN wget -qO /usr/local/share/ca-certificates/AAA_Certificate_Services.crt \ - "https://crt.sh/?d=331986" \ - && update-ca-certificates - LABEL maintainer="team@appwrite.io" ARG VERSION=dev From 9dbd225118b38adef7aeab3ad21ed8f23fede561 Mon Sep 17 00:00:00 2001 From: Prem Palanisamy Date: Mon, 16 Feb 2026 12:36:40 +0000 Subject: [PATCH 10/18] Refactor schedule Create to use protected methods for extensibility --- .../Schedules/Http/Schedules/Create.php | 45 ++++++++++++++----- 1 file changed, 34 insertions(+), 11 deletions(-) diff --git a/src/Appwrite/Platform/Modules/Schedules/Http/Schedules/Create.php b/src/Appwrite/Platform/Modules/Schedules/Http/Schedules/Create.php index 0aabe050cf..eabc382131 100644 --- a/src/Appwrite/Platform/Modules/Schedules/Http/Schedules/Create.php +++ b/src/Appwrite/Platform/Modules/Schedules/Http/Schedules/Create.php @@ -28,8 +28,37 @@ class Create extends Action return 'createSchedule'; } + protected function getResourceTypes(): array + { + return [ + SCHEDULE_RESOURCE_TYPE_FUNCTION, + SCHEDULE_RESOURCE_TYPE_EXECUTION, + SCHEDULE_RESOURCE_TYPE_MESSAGE, + ]; + } + + protected function getCollectionMap(): array + { + return [ + SCHEDULE_RESOURCE_TYPE_FUNCTION => 'functions', + SCHEDULE_RESOURCE_TYPE_EXECUTION => 'executions', + SCHEDULE_RESOURCE_TYPE_MESSAGE => 'messages', + ]; + } + + protected function getNotFoundExceptionMap(): array + { + return [ + SCHEDULE_RESOURCE_TYPE_FUNCTION => Exception::FUNCTION_NOT_FOUND, + SCHEDULE_RESOURCE_TYPE_EXECUTION => Exception::EXECUTION_NOT_FOUND, + SCHEDULE_RESOURCE_TYPE_MESSAGE => Exception::MESSAGE_NOT_FOUND, + ]; + } + public function __construct() { + $resourceTypes = $this->getResourceTypes(); + $this ->setHttpMethod(Action::HTTP_REQUEST_METHOD_POST) ->setHttpPath('/v1/projects/:projectId/schedules') @@ -52,7 +81,7 @@ class Create extends Action ], )) ->param('projectId', '', new UID(), 'Project unique ID.') - ->param('resourceType', '', new WhiteList([SCHEDULE_RESOURCE_TYPE_FUNCTION, SCHEDULE_RESOURCE_TYPE_EXECUTION, SCHEDULE_RESOURCE_TYPE_MESSAGE], true), 'The resource type for the schedule. Possible values: ' . implode(', ', [SCHEDULE_RESOURCE_TYPE_FUNCTION, SCHEDULE_RESOURCE_TYPE_EXECUTION, SCHEDULE_RESOURCE_TYPE_MESSAGE]) . '.') + ->param('resourceType', '', new WhiteList($resourceTypes, true), 'The resource type for the schedule. Possible values: ' . implode(', ', $resourceTypes) . '.') ->param('resourceId', '', new UID(), 'The resource ID to associate with this schedule.') ->param('schedule', '', new Cron(), 'Schedule CRON expression.') ->param('active', false, new Boolean(), 'Whether the schedule is active.', true) @@ -82,20 +111,14 @@ class Create extends Action $dbForProject = $getProjectDB($project); - $collection = match ($resourceType) { - SCHEDULE_RESOURCE_TYPE_FUNCTION => 'functions', - SCHEDULE_RESOURCE_TYPE_EXECUTION => 'executions', - SCHEDULE_RESOURCE_TYPE_MESSAGE => 'messages', - }; + $collectionMap = $this->getCollectionMap(); + $collection = $collectionMap[$resourceType] ?? throw new Exception(Exception::GENERAL_ARGUMENT_INVALID, 'Invalid resource type: ' . $resourceType); $resource = $dbForProject->getDocument($collection, $resourceId); if ($resource->isEmpty()) { - throw new Exception(match ($resourceType) { - SCHEDULE_RESOURCE_TYPE_FUNCTION => Exception::FUNCTION_NOT_FOUND, - SCHEDULE_RESOURCE_TYPE_EXECUTION => Exception::EXECUTION_NOT_FOUND, - SCHEDULE_RESOURCE_TYPE_MESSAGE => Exception::MESSAGE_NOT_FOUND, - }); + $notFoundMap = $this->getNotFoundExceptionMap(); + throw new Exception($notFoundMap[$resourceType] ?? Exception::GENERAL_ARGUMENT_INVALID, 'Resource not found'); } try { From d8987ad975477ba3b25e1868cc2c229ef9e9525f Mon Sep 17 00:00:00 2001 From: Prem Palanisamy Date: Mon, 16 Feb 2026 13:29:03 +0000 Subject: [PATCH 11/18] Fix schedule Create endpoint and tests --- .../Schedules/Http/Schedules/Create.php | 51 ++++++++++++------- .../e2e/Services/Schedules/SchedulesBase.php | 2 +- .../Schedules/SchedulesConsoleClientTest.php | 34 ++++++------- 3 files changed, 51 insertions(+), 36 deletions(-) diff --git a/src/Appwrite/Platform/Modules/Schedules/Http/Schedules/Create.php b/src/Appwrite/Platform/Modules/Schedules/Http/Schedules/Create.php index eabc382131..4214778566 100644 --- a/src/Appwrite/Platform/Modules/Schedules/Http/Schedules/Create.php +++ b/src/Appwrite/Platform/Modules/Schedules/Http/Schedules/Create.php @@ -2,6 +2,7 @@ namespace Appwrite\Platform\Modules\Schedules\Http\Schedules; +use Appwrite\Event\Event; use Appwrite\Extend\Exception; use Appwrite\SDK\AuthType; use Appwrite\SDK\Method; @@ -17,6 +18,7 @@ use Utopia\Database\Validator\UID; use Utopia\Platform\Action; use Utopia\Platform\Scope\HTTP; use Utopia\Validator\Boolean; +use Utopia\Validator\JSON; use Utopia\Validator\WhiteList; class Create extends Action @@ -65,6 +67,7 @@ class Create extends Action ->desc('Create schedule') ->groups(['api', 'projects']) ->label('scope', 'schedules.write') + ->label('event', 'schedules.[scheduleId].create') ->label('audits.event', 'schedule.create') ->label('audits.resource', 'schedule/{response.$id}') ->label('sdk', new Method( @@ -77,18 +80,20 @@ class Create extends Action new SDKResponse( code: Response::STATUS_CODE_CREATED, model: Response::MODEL_SCHEDULE, - ) + ), ], )) - ->param('projectId', '', new UID(), 'Project unique ID.') - ->param('resourceType', '', new WhiteList($resourceTypes, true), 'The resource type for the schedule. Possible values: ' . implode(', ', $resourceTypes) . '.') - ->param('resourceId', '', new UID(), 'The resource ID to associate with this schedule.') - ->param('schedule', '', new Cron(), 'Schedule CRON expression.') - ->param('active', false, new Boolean(), 'Whether the schedule is active.', true) + ->param('projectId', '', new UID, 'Project unique ID.') + ->param('resourceType', '', new WhiteList($resourceTypes, true), 'The resource type for the schedule. Possible values: '.implode(', ', $resourceTypes).'.') + ->param('resourceId', '', new UID, 'The resource ID to associate with this schedule.') + ->param('schedule', '', new Cron, 'Schedule CRON expression.') + ->param('active', false, new Boolean, 'Whether the schedule is active.', true) + ->param('data', null, new JSON, 'Schedule data as a JSON string. Used to store resource-specific context needed for execution.', true) ->inject('response') ->inject('dbForPlatform') ->inject('getProjectDB') ->inject('authorization') + ->inject('queueForEvents') ->callback($this->action(...)); } @@ -98,10 +103,12 @@ class Create extends Action string $resourceId, string $schedule, bool $active, + ?string $data, Response $response, Database $dbForPlatform, callable $getProjectDB, Authorization $authorization, + Event $queueForEvents, ): void { $project = $dbForPlatform->getDocument('projects', $projectId); @@ -112,7 +119,7 @@ class Create extends Action $dbForProject = $getProjectDB($project); $collectionMap = $this->getCollectionMap(); - $collection = $collectionMap[$resourceType] ?? throw new Exception(Exception::GENERAL_ARGUMENT_INVALID, 'Invalid resource type: ' . $resourceType); + $collection = $collectionMap[$resourceType] ?? throw new Exception(Exception::GENERAL_ARGUMENT_INVALID, 'Invalid resource type: '.$resourceType); $resource = $dbForProject->getDocument($collection, $resourceId); @@ -121,23 +128,31 @@ class Create extends Action throw new Exception($notFoundMap[$resourceType] ?? Exception::GENERAL_ARGUMENT_INVALID, 'Resource not found'); } + $attributes = [ + 'region' => $project->getAttribute('region'), + 'resourceType' => $resourceType, + 'resourceId' => $resourceId, + 'resourceInternalId' => $resource->getSequence(), + 'resourceUpdatedAt' => DateTime::now(), + 'projectId' => $project->getId(), + 'schedule' => $schedule, + 'active' => $active, + ]; + + if ($data !== null) { + $attributes['data'] = \json_decode($data, true); + } + try { $doc = $authorization->skip( - fn () => $dbForPlatform->createDocument('schedules', new Document([ - 'region' => $project->getAttribute('region'), - 'resourceType' => $resourceType, - 'resourceId' => $resourceId, - 'resourceInternalId' => $resource->getSequence(), - 'resourceUpdatedAt' => DateTime::now(), - 'projectId' => $project->getId(), - 'schedule' => $schedule, - 'active' => $active, - ])) + fn () => $dbForPlatform->createDocument('schedules', new Document($attributes)) ); } catch (DuplicateException) { - throw new Exception(Exception::GENERAL_SERVER_ERROR, 'Failed to create schedule. Please try again.'); + throw new Exception(Exception::DOCUMENT_ALREADY_EXISTS); } + $queueForEvents->setParam('scheduleId', $doc->getId()); + $response ->setStatusCode(Response::STATUS_CODE_CREATED) ->dynamic($doc, Response::MODEL_SCHEDULE); diff --git a/tests/e2e/Services/Schedules/SchedulesBase.php b/tests/e2e/Services/Schedules/SchedulesBase.php index cd8f424f8d..98f822fd23 100644 --- a/tests/e2e/Services/Schedules/SchedulesBase.php +++ b/tests/e2e/Services/Schedules/SchedulesBase.php @@ -34,7 +34,7 @@ trait SchedulesBase $projectId = $project['body']['$id']; - $key = $this->client->call(Client::METHOD_POST, '/projects/' . $projectId . '/keys', array_merge([ + $key = $this->client->call(Client::METHOD_POST, '/projects/'.$projectId.'/keys', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], ], $this->getHeaders()), [ diff --git a/tests/e2e/Services/Schedules/SchedulesConsoleClientTest.php b/tests/e2e/Services/Schedules/SchedulesConsoleClientTest.php index 8d9d556f12..99a7571340 100644 --- a/tests/e2e/Services/Schedules/SchedulesConsoleClientTest.php +++ b/tests/e2e/Services/Schedules/SchedulesConsoleClientTest.php @@ -12,8 +12,8 @@ use Utopia\System\System; class SchedulesConsoleClientTest extends Scope { - use SchedulesBase; use ProjectConsole; + use SchedulesBase; use SideClient; /** @@ -42,7 +42,7 @@ class SchedulesConsoleClientTest extends Scope $this->assertEquals(201, $function['headers']['status-code']); $functionId = $function['body']['$id']; - $response = $this->client->call(Client::METHOD_POST, '/projects/' . $id . '/schedules', array_merge([ + $response = $this->client->call(Client::METHOD_POST, '/projects/'.$id.'/schedules', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], ], $this->getHeaders()), [ @@ -74,7 +74,7 @@ class SchedulesConsoleClientTest extends Scope */ // Resource not found - $response = $this->client->call(Client::METHOD_POST, '/projects/' . $id . '/schedules', array_merge([ + $response = $this->client->call(Client::METHOD_POST, '/projects/'.$id.'/schedules', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], ], $this->getHeaders()), [ @@ -86,7 +86,7 @@ class SchedulesConsoleClientTest extends Scope $this->assertEquals(404, $response['headers']['status-code']); // Invalid resource type - $response = $this->client->call(Client::METHOD_POST, '/projects/' . $id . '/schedules', array_merge([ + $response = $this->client->call(Client::METHOD_POST, '/projects/'.$id.'/schedules', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], ], $this->getHeaders()), [ @@ -98,7 +98,7 @@ class SchedulesConsoleClientTest extends Scope $this->assertEquals(400, $response['headers']['status-code']); // Invalid cron - $response = $this->client->call(Client::METHOD_POST, '/projects/' . $id . '/schedules', array_merge([ + $response = $this->client->call(Client::METHOD_POST, '/projects/'.$id.'/schedules', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], ], $this->getHeaders()), [ @@ -110,7 +110,7 @@ class SchedulesConsoleClientTest extends Scope $this->assertEquals(400, $response['headers']['status-code']); // Missing resourceType - $response = $this->client->call(Client::METHOD_POST, '/projects/' . $id . '/schedules', array_merge([ + $response = $this->client->call(Client::METHOD_POST, '/projects/'.$id.'/schedules', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], ], $this->getHeaders()), [ @@ -121,7 +121,7 @@ class SchedulesConsoleClientTest extends Scope $this->assertEquals(400, $response['headers']['status-code']); // Missing resourceId - $response = $this->client->call(Client::METHOD_POST, '/projects/' . $id . '/schedules', array_merge([ + $response = $this->client->call(Client::METHOD_POST, '/projects/'.$id.'/schedules', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], ], $this->getHeaders()), [ @@ -132,7 +132,7 @@ class SchedulesConsoleClientTest extends Scope $this->assertEquals(400, $response['headers']['status-code']); // Missing schedule - $response = $this->client->call(Client::METHOD_POST, '/projects/' . $id . '/schedules', array_merge([ + $response = $this->client->call(Client::METHOD_POST, '/projects/'.$id.'/schedules', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], ], $this->getHeaders()), [ @@ -156,7 +156,7 @@ class SchedulesConsoleClientTest extends Scope /** * Test for SUCCESS */ - $response = $this->client->call(Client::METHOD_GET, '/projects/' . $id . '/schedules/' . $scheduleId, array_merge([ + $response = $this->client->call(Client::METHOD_GET, '/projects/'.$id.'/schedules/'.$scheduleId, array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], ], $this->getHeaders()), []); @@ -171,7 +171,7 @@ class SchedulesConsoleClientTest extends Scope /** * Test for FAILURE */ - $response = $this->client->call(Client::METHOD_GET, '/projects/' . $id . '/schedules/error', array_merge([ + $response = $this->client->call(Client::METHOD_GET, '/projects/'.$id.'/schedules/'.ID::unique(), array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], ], $this->getHeaders()), []); @@ -191,7 +191,7 @@ class SchedulesConsoleClientTest extends Scope /** * Test for SUCCESS */ - $response = $this->client->call(Client::METHOD_GET, '/projects/' . $id . '/schedules', array_merge([ + $response = $this->client->call(Client::METHOD_GET, '/projects/'.$id.'/schedules', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], ], $this->getHeaders()), []); @@ -214,7 +214,7 @@ class SchedulesConsoleClientTest extends Scope $this->assertArrayHasKey('region', $schedule); /** Filter by resourceType */ - $response = $this->client->call(Client::METHOD_GET, '/projects/' . $id . '/schedules', array_merge([ + $response = $this->client->call(Client::METHOD_GET, '/projects/'.$id.'/schedules', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], ], $this->getHeaders()), [ @@ -229,7 +229,7 @@ class SchedulesConsoleClientTest extends Scope } /** Filter by active status */ - $response = $this->client->call(Client::METHOD_GET, '/projects/' . $id . '/schedules', array_merge([ + $response = $this->client->call(Client::METHOD_GET, '/projects/'.$id.'/schedules', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], ], $this->getHeaders()), [ @@ -243,7 +243,7 @@ class SchedulesConsoleClientTest extends Scope } /** List with total disabled */ - $response = $this->client->call(Client::METHOD_GET, '/projects/' . $id . '/schedules', array_merge([ + $response = $this->client->call(Client::METHOD_GET, '/projects/'.$id.'/schedules', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], ], $this->getHeaders()), [ @@ -257,7 +257,7 @@ class SchedulesConsoleClientTest extends Scope /** * Test for FAILURE */ - $response = $this->client->call(Client::METHOD_GET, '/projects/' . $id . '/schedules', array_merge([ + $response = $this->client->call(Client::METHOD_GET, '/projects/'.$id.'/schedules', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], ], $this->getHeaders()), [ @@ -301,7 +301,7 @@ class SchedulesConsoleClientTest extends Scope $otherProjectId = $otherProject['body']['$id']; // Try to get the schedule from the other project - $response = $this->client->call(Client::METHOD_GET, '/projects/' . $otherProjectId . '/schedules/' . $scheduleId, array_merge([ + $response = $this->client->call(Client::METHOD_GET, '/projects/'.$otherProjectId.'/schedules/'.$scheduleId, array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], ], $this->getHeaders()), []); @@ -309,7 +309,7 @@ class SchedulesConsoleClientTest extends Scope $this->assertEquals(404, $response['headers']['status-code']); // List should not include schedules from other projects - $response = $this->client->call(Client::METHOD_GET, '/projects/' . $otherProjectId . '/schedules', array_merge([ + $response = $this->client->call(Client::METHOD_GET, '/projects/'.$otherProjectId.'/schedules', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], ], $this->getHeaders()), []); From 2c8b1fb98a971e2e21fe8c7da2b0eec7f5c40bc6 Mon Sep 17 00:00:00 2001 From: Prem Palanisamy Date: Mon, 16 Feb 2026 13:42:25 +0000 Subject: [PATCH 12/18] Add parentheses to validator constructors in schedule Create --- .../Modules/Schedules/Http/Schedules/Create.php | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/Appwrite/Platform/Modules/Schedules/Http/Schedules/Create.php b/src/Appwrite/Platform/Modules/Schedules/Http/Schedules/Create.php index 4214778566..1db2230012 100644 --- a/src/Appwrite/Platform/Modules/Schedules/Http/Schedules/Create.php +++ b/src/Appwrite/Platform/Modules/Schedules/Http/Schedules/Create.php @@ -83,12 +83,12 @@ class Create extends Action ), ], )) - ->param('projectId', '', new UID, 'Project unique ID.') + ->param('projectId', '', new UID(), 'Project unique ID.') ->param('resourceType', '', new WhiteList($resourceTypes, true), 'The resource type for the schedule. Possible values: '.implode(', ', $resourceTypes).'.') - ->param('resourceId', '', new UID, 'The resource ID to associate with this schedule.') - ->param('schedule', '', new Cron, 'Schedule CRON expression.') - ->param('active', false, new Boolean, 'Whether the schedule is active.', true) - ->param('data', null, new JSON, 'Schedule data as a JSON string. Used to store resource-specific context needed for execution.', true) + ->param('resourceId', '', new UID(), 'The resource ID to associate with this schedule.') + ->param('schedule', '', new Cron(), 'Schedule CRON expression.') + ->param('active', false, new Boolean(), 'Whether the schedule is active.', true) + ->param('data', null, new JSON(), 'Schedule data as a JSON string. Used to store resource-specific context needed for execution.', true) ->inject('response') ->inject('dbForPlatform') ->inject('getProjectDB') From e1c35c743c6dbf71f4816a114ee7a75e169594a3 Mon Sep 17 00:00:00 2001 From: Prem Palanisamy Date: Mon, 16 Feb 2026 19:53:43 +0000 Subject: [PATCH 13/18] Add data field to Schedule response model and remove unused events --- app/config/events.php | 6 ------ .../Utopia/Response/Model/Schedule.php | 6 ++++++ .../Schedules/SchedulesConsoleClientTest.php | 19 +++++++++++++++++++ 3 files changed, 25 insertions(+), 6 deletions(-) diff --git a/app/config/events.php b/app/config/events.php index e029302214..11dc2e0e4a 100644 --- a/app/config/events.php +++ b/app/config/events.php @@ -412,12 +412,6 @@ return [ 'create' => [ '$description' => 'This event triggers when a schedule is created.', ], - 'update' => [ - '$description' => 'This event triggers when a schedule is updated.', - ], - 'delete' => [ - '$description' => 'This event triggers when a schedule is deleted.', - ], ], 'rules' => [ '$model' => Response::MODEL_PROXY_RULE, diff --git a/src/Appwrite/Utopia/Response/Model/Schedule.php b/src/Appwrite/Utopia/Response/Model/Schedule.php index c327a030ce..b5adc6ecbe 100644 --- a/src/Appwrite/Utopia/Response/Model/Schedule.php +++ b/src/Appwrite/Utopia/Response/Model/Schedule.php @@ -58,6 +58,12 @@ class Schedule extends Model 'default' => '', 'example' => '5 4 * * *', ]) + ->addRule('data', [ + 'type' => self::TYPE_JSON, + 'description' => 'Schedule data used to store resource-specific context needed for execution.', + 'default' => [], + 'example' => [], + ]) ->addRule('active', [ 'type' => self::TYPE_BOOLEAN, 'description' => 'Whether the schedule is active.', diff --git a/tests/e2e/Services/Schedules/SchedulesConsoleClientTest.php b/tests/e2e/Services/Schedules/SchedulesConsoleClientTest.php index 99a7571340..813658cd8c 100644 --- a/tests/e2e/Services/Schedules/SchedulesConsoleClientTest.php +++ b/tests/e2e/Services/Schedules/SchedulesConsoleClientTest.php @@ -61,9 +61,26 @@ class SchedulesConsoleClientTest extends Scope $this->assertNotEmpty($response['body']['resourceUpdatedAt']); $this->assertNotEmpty($response['body']['projectId']); $this->assertEquals('0 0 * * *', $response['body']['schedule']); + $this->assertArrayHasKey('data', $response['body']); $this->assertTrue($response['body']['active']); $this->assertNotEmpty($response['body']['region']); + // Create with data + $scheduleData = ['key' => 'value']; + $responseWithData = $this->client->call(Client::METHOD_POST, '/projects/'.$id.'/schedules', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'resourceType' => 'function', + 'resourceId' => $functionId, + 'schedule' => '0 12 * * *', + 'active' => true, + 'data' => json_encode($scheduleData), + ]); + + $this->assertEquals(201, $responseWithData['headers']['status-code']); + $this->assertEquals($scheduleData, $responseWithData['body']['data']); + $data = array_merge($data, [ 'scheduleId' => $response['body']['$id'], 'functionId' => $functionId, @@ -166,6 +183,7 @@ class SchedulesConsoleClientTest extends Scope $this->assertEquals($scheduleId, $response['body']['$id']); $this->assertEquals('function', $response['body']['resourceType']); $this->assertEquals('0 0 * * *', $response['body']['schedule']); + $this->assertArrayHasKey('data', $response['body']); $this->assertTrue($response['body']['active']); /** @@ -210,6 +228,7 @@ class SchedulesConsoleClientTest extends Scope $this->assertArrayHasKey('resourceId', $schedule); $this->assertArrayHasKey('projectId', $schedule); $this->assertArrayHasKey('schedule', $schedule); + $this->assertArrayHasKey('data', $schedule); $this->assertArrayHasKey('active', $schedule); $this->assertArrayHasKey('region', $schedule); From d6a3fd62429b8ab3bd7e2f840573a276f8626fc7 Mon Sep 17 00:00:00 2001 From: Prem Palanisamy Date: Tue, 17 Feb 2026 09:13:02 +0000 Subject: [PATCH 14/18] Address review comments: nest in project namespace, refactor maps, remove auth skip --- phpunit.xml | 1 - .../Http/{ => Projects}/Schedules/Create.php | 30 ++++++++----------- .../Http/{ => Projects}/Schedules/Get.php | 9 ++---- .../Http/{ => Projects}/Schedules/XList.php | 17 +++-------- .../Modules/Schedules/Services/Http.php | 6 ++-- .../Schedules/SchedulesBase.php | 2 +- .../Schedules/SchedulesConsoleClientTest.php | 2 +- 7 files changed, 23 insertions(+), 44 deletions(-) rename src/Appwrite/Platform/Modules/Schedules/Http/{ => Projects}/Schedules/Create.php (84%) rename src/Appwrite/Platform/Modules/Schedules/Http/{ => Projects}/Schedules/Get.php (87%) rename src/Appwrite/Platform/Modules/Schedules/Http/{ => Projects}/Schedules/XList.php (86%) rename tests/e2e/Services/{ => Projects}/Schedules/SchedulesBase.php (97%) rename tests/e2e/Services/{ => Projects}/Schedules/SchedulesConsoleClientTest.php (99%) diff --git a/phpunit.xml b/phpunit.xml index 8f7323b35b..a8578995c1 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -35,7 +35,6 @@ ./tests/e2e/Services/Webhooks ./tests/e2e/Services/Messaging ./tests/e2e/Services/Migrations - ./tests/e2e/Services/Schedules ./tests/e2e/Services/Functions/FunctionsBase.php ./tests/e2e/Services/Functions/FunctionsCustomServerTest.php ./tests/e2e/Services/Functions/FunctionsCustomClientTest.php diff --git a/src/Appwrite/Platform/Modules/Schedules/Http/Schedules/Create.php b/src/Appwrite/Platform/Modules/Schedules/Http/Projects/Schedules/Create.php similarity index 84% rename from src/Appwrite/Platform/Modules/Schedules/Http/Schedules/Create.php rename to src/Appwrite/Platform/Modules/Schedules/Http/Projects/Schedules/Create.php index 1db2230012..be64c1a2ca 100644 --- a/src/Appwrite/Platform/Modules/Schedules/Http/Schedules/Create.php +++ b/src/Appwrite/Platform/Modules/Schedules/Http/Projects/Schedules/Create.php @@ -1,6 +1,6 @@ 'functions', SCHEDULE_RESOURCE_TYPE_EXECUTION => 'executions', SCHEDULE_RESOURCE_TYPE_MESSAGE => 'messages', - ]; + default => throw new Exception(Exception::GENERAL_ARGUMENT_INVALID, 'Invalid resource type: ' . $resourceType), + }; } - protected function getNotFoundExceptionMap(): array + protected function getNotFoundException(string $resourceType): string { - return [ + return match ($resourceType) { SCHEDULE_RESOURCE_TYPE_FUNCTION => Exception::FUNCTION_NOT_FOUND, SCHEDULE_RESOURCE_TYPE_EXECUTION => Exception::EXECUTION_NOT_FOUND, SCHEDULE_RESOURCE_TYPE_MESSAGE => Exception::MESSAGE_NOT_FOUND, - ]; + default => Exception::GENERAL_ARGUMENT_INVALID, + }; } public function __construct() @@ -92,7 +93,6 @@ class Create extends Action ->inject('response') ->inject('dbForPlatform') ->inject('getProjectDB') - ->inject('authorization') ->inject('queueForEvents') ->callback($this->action(...)); } @@ -107,7 +107,6 @@ class Create extends Action Response $response, Database $dbForPlatform, callable $getProjectDB, - Authorization $authorization, Event $queueForEvents, ): void { $project = $dbForPlatform->getDocument('projects', $projectId); @@ -118,14 +117,11 @@ class Create extends Action $dbForProject = $getProjectDB($project); - $collectionMap = $this->getCollectionMap(); - $collection = $collectionMap[$resourceType] ?? throw new Exception(Exception::GENERAL_ARGUMENT_INVALID, 'Invalid resource type: '.$resourceType); - + $collection = $this->getCollection($resourceType); $resource = $dbForProject->getDocument($collection, $resourceId); if ($resource->isEmpty()) { - $notFoundMap = $this->getNotFoundExceptionMap(); - throw new Exception($notFoundMap[$resourceType] ?? Exception::GENERAL_ARGUMENT_INVALID, 'Resource not found'); + throw new Exception($this->getNotFoundException($resourceType), 'Resource not found'); } $attributes = [ @@ -144,9 +140,7 @@ class Create extends Action } try { - $doc = $authorization->skip( - fn () => $dbForPlatform->createDocument('schedules', new Document($attributes)) - ); + $doc = $dbForPlatform->createDocument('schedules', new Document($attributes)); } catch (DuplicateException) { throw new Exception(Exception::DOCUMENT_ALREADY_EXISTS); } diff --git a/src/Appwrite/Platform/Modules/Schedules/Http/Schedules/Get.php b/src/Appwrite/Platform/Modules/Schedules/Http/Projects/Schedules/Get.php similarity index 87% rename from src/Appwrite/Platform/Modules/Schedules/Http/Schedules/Get.php rename to src/Appwrite/Platform/Modules/Schedules/Http/Projects/Schedules/Get.php index b16651dd0c..b3a1cc28ea 100644 --- a/src/Appwrite/Platform/Modules/Schedules/Http/Schedules/Get.php +++ b/src/Appwrite/Platform/Modules/Schedules/Http/Projects/Schedules/Get.php @@ -1,6 +1,6 @@ param('scheduleId', '', new UID(), 'Schedule ID.') ->inject('response') ->inject('dbForPlatform') - ->inject('authorization') ->callback($this->action(...)); } @@ -56,7 +54,6 @@ class Get extends Action string $scheduleId, Response $response, Database $dbForPlatform, - Authorization $authorization, ): void { $project = $dbForPlatform->getDocument('projects', $projectId); @@ -64,9 +61,7 @@ class Get extends Action throw new Exception(Exception::PROJECT_NOT_FOUND); } - $schedule = $authorization->skip( - fn () => $dbForPlatform->getDocument('schedules', $scheduleId) - ); + $schedule = $dbForPlatform->getDocument('schedules', $scheduleId); if ($schedule->isEmpty()) { throw new Exception(Exception::SCHEDULE_NOT_FOUND); diff --git a/src/Appwrite/Platform/Modules/Schedules/Http/Schedules/XList.php b/src/Appwrite/Platform/Modules/Schedules/Http/Projects/Schedules/XList.php similarity index 86% rename from src/Appwrite/Platform/Modules/Schedules/Http/Schedules/XList.php rename to src/Appwrite/Platform/Modules/Schedules/Http/Projects/Schedules/XList.php index 77799b8593..174ef670cc 100644 --- a/src/Appwrite/Platform/Modules/Schedules/Http/Schedules/XList.php +++ b/src/Appwrite/Platform/Modules/Schedules/Http/Projects/Schedules/XList.php @@ -1,6 +1,6 @@ param('total', true, new Boolean(true), 'When set to false, the total count returned will be 0 and will not be calculated.', true) ->inject('response') ->inject('dbForPlatform') - ->inject('authorization') ->callback($this->action(...)); } @@ -65,7 +63,6 @@ class XList extends Action bool $includeTotal, Response $response, Database $dbForPlatform, - Authorization $authorization, ): void { $project = $dbForPlatform->getDocument('projects', $projectId); @@ -91,9 +88,7 @@ class XList extends Action } $scheduleId = $cursor->getValue(); - $cursorDocument = $authorization->skip( - fn () => $dbForPlatform->getDocument('schedules', $scheduleId) - ); + $cursorDocument = $dbForPlatform->getDocument('schedules', $scheduleId); if ($cursorDocument->isEmpty()) { throw new Exception(Exception::GENERAL_CURSOR_NOT_FOUND, "Schedule '{$scheduleId}' for the 'cursor' value not found."); @@ -105,12 +100,8 @@ class XList extends Action $filterQueries = Query::groupByType($queries)['filters']; try { - $schedules = $authorization->skip( - fn () => $dbForPlatform->find('schedules', $queries) - ); - $total = $includeTotal ? $authorization->skip( - fn () => $dbForPlatform->count('schedules', $filterQueries, APP_LIMIT_COUNT) - ) : 0; + $schedules = $dbForPlatform->find('schedules', $queries); + $total = $includeTotal ? $dbForPlatform->count('schedules', $filterQueries, APP_LIMIT_COUNT) : 0; } catch (OrderException $e) { throw new Exception(Exception::DATABASE_QUERY_ORDER_NULL, "The order attribute '{$e->getAttribute()}' had a null value. Cursor pagination requires all documents order attribute values are non-null."); } diff --git a/src/Appwrite/Platform/Modules/Schedules/Services/Http.php b/src/Appwrite/Platform/Modules/Schedules/Services/Http.php index 1ea5baf0ce..a3bba33bd8 100644 --- a/src/Appwrite/Platform/Modules/Schedules/Services/Http.php +++ b/src/Appwrite/Platform/Modules/Schedules/Services/Http.php @@ -2,9 +2,9 @@ namespace Appwrite\Platform\Modules\Schedules\Services; -use Appwrite\Platform\Modules\Schedules\Http\Schedules\Create; -use Appwrite\Platform\Modules\Schedules\Http\Schedules\Get; -use Appwrite\Platform\Modules\Schedules\Http\Schedules\XList; +use Appwrite\Platform\Modules\Schedules\Http\Projects\Schedules\Create; +use Appwrite\Platform\Modules\Schedules\Http\Projects\Schedules\Get; +use Appwrite\Platform\Modules\Schedules\Http\Projects\Schedules\XList; use Utopia\Platform\Service; class Http extends Service diff --git a/tests/e2e/Services/Schedules/SchedulesBase.php b/tests/e2e/Services/Projects/Schedules/SchedulesBase.php similarity index 97% rename from tests/e2e/Services/Schedules/SchedulesBase.php rename to tests/e2e/Services/Projects/Schedules/SchedulesBase.php index 98f822fd23..4c89917bf3 100644 --- a/tests/e2e/Services/Schedules/SchedulesBase.php +++ b/tests/e2e/Services/Projects/Schedules/SchedulesBase.php @@ -1,6 +1,6 @@ Date: Tue, 17 Feb 2026 09:30:34 +0000 Subject: [PATCH 15/18] coderabbit - Fix misleading resourceUpdatedAt description --- src/Appwrite/Utopia/Response/Model/Schedule.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Appwrite/Utopia/Response/Model/Schedule.php b/src/Appwrite/Utopia/Response/Model/Schedule.php index b5adc6ecbe..9122d39ea0 100644 --- a/src/Appwrite/Utopia/Response/Model/Schedule.php +++ b/src/Appwrite/Utopia/Response/Model/Schedule.php @@ -42,7 +42,7 @@ class Schedule extends Model ]) ->addRule('resourceUpdatedAt', [ 'type' => self::TYPE_DATETIME, - 'description' => 'The date the associated resource was last updated in ISO 8601 format.', + 'description' => 'Change-tracking timestamp used by the scheduler to detect resource changes in ISO 8601 format.', 'default' => '', 'example' => self::TYPE_DATETIME_EXAMPLE, ]) From 4940239c99a357b94976bc9eac0a5a8a2bc73df7 Mon Sep 17 00:00:00 2001 From: Prem Palanisamy Date: Tue, 17 Feb 2026 11:26:17 +0000 Subject: [PATCH 16/18] Move schedules module into projects namespace --- .../{ => projects}/schedules/create.md | 0 .../{ => projects}/schedules/get.md | 0 .../{ => projects}/schedules/list.md | 0 src/Appwrite/Platform/Appwrite.php | 2 -- .../Http}/Schedules/Create.php | 6 ++++-- .../Http}/Schedules/Get.php | 8 +++++--- .../Http}/Schedules/XList.php | 8 +++++--- .../Modules/Projects/Services/Http.php | 7 +++++++ .../Platform/Modules/Schedules/Module.php | 14 ------------- .../Modules/Schedules/Services/Http.php | 20 ------------------- 10 files changed, 21 insertions(+), 44 deletions(-) rename docs/references/{ => projects}/schedules/create.md (100%) rename docs/references/{ => projects}/schedules/get.md (100%) rename docs/references/{ => projects}/schedules/list.md (100%) rename src/Appwrite/Platform/Modules/{Schedules/Http/Projects => Projects/Http}/Schedules/Create.php (96%) rename src/Appwrite/Platform/Modules/{Schedules/Http/Projects => Projects/Http}/Schedules/Get.php (90%) rename src/Appwrite/Platform/Modules/{Schedules/Http/Projects => Projects/Http}/Schedules/XList.php (94%) delete mode 100644 src/Appwrite/Platform/Modules/Schedules/Module.php delete mode 100644 src/Appwrite/Platform/Modules/Schedules/Services/Http.php diff --git a/docs/references/schedules/create.md b/docs/references/projects/schedules/create.md similarity index 100% rename from docs/references/schedules/create.md rename to docs/references/projects/schedules/create.md diff --git a/docs/references/schedules/get.md b/docs/references/projects/schedules/get.md similarity index 100% rename from docs/references/schedules/get.md rename to docs/references/projects/schedules/get.md diff --git a/docs/references/schedules/list.md b/docs/references/projects/schedules/list.md similarity index 100% rename from docs/references/schedules/list.md rename to docs/references/projects/schedules/list.md diff --git a/src/Appwrite/Platform/Appwrite.php b/src/Appwrite/Platform/Appwrite.php index 1007e8ee45..9982b0bf1e 100644 --- a/src/Appwrite/Platform/Appwrite.php +++ b/src/Appwrite/Platform/Appwrite.php @@ -11,7 +11,6 @@ use Appwrite\Platform\Modules\Functions; use Appwrite\Platform\Modules\Health; use Appwrite\Platform\Modules\Projects; use Appwrite\Platform\Modules\Proxy; -use Appwrite\Platform\Modules\Schedules; use Appwrite\Platform\Modules\Sites; use Appwrite\Platform\Modules\Storage; use Appwrite\Platform\Modules\Tokens; @@ -29,7 +28,6 @@ class Appwrite extends Platform $this->addModule(new Projects\Module()); $this->addModule(new Functions\Module()); $this->addModule(new Health\Module()); - $this->addModule(new Schedules\Module()); $this->addModule(new Sites\Module()); $this->addModule(new Console\Module()); $this->addModule(new Proxy\Module()); diff --git a/src/Appwrite/Platform/Modules/Schedules/Http/Projects/Schedules/Create.php b/src/Appwrite/Platform/Modules/Projects/Http/Schedules/Create.php similarity index 96% rename from src/Appwrite/Platform/Modules/Schedules/Http/Projects/Schedules/Create.php rename to src/Appwrite/Platform/Modules/Projects/Http/Schedules/Create.php index be64c1a2ca..4bc5b9a271 100644 --- a/src/Appwrite/Platform/Modules/Schedules/Http/Projects/Schedules/Create.php +++ b/src/Appwrite/Platform/Modules/Projects/Http/Schedules/Create.php @@ -1,10 +1,11 @@ param('projectId', '', new UID(), 'Project unique ID.') ->param('resourceType', '', new WhiteList($resourceTypes, true), 'The resource type for the schedule. Possible values: '.implode(', ', $resourceTypes).'.') diff --git a/src/Appwrite/Platform/Modules/Schedules/Http/Projects/Schedules/Get.php b/src/Appwrite/Platform/Modules/Projects/Http/Schedules/Get.php similarity index 90% rename from src/Appwrite/Platform/Modules/Schedules/Http/Projects/Schedules/Get.php rename to src/Appwrite/Platform/Modules/Projects/Http/Schedules/Get.php index b3a1cc28ea..4938ed2415 100644 --- a/src/Appwrite/Platform/Modules/Schedules/Http/Projects/Schedules/Get.php +++ b/src/Appwrite/Platform/Modules/Projects/Http/Schedules/Get.php @@ -1,9 +1,10 @@ param('projectId', '', new UID(), 'Project unique ID.') ->param('scheduleId', '', new UID(), 'Schedule ID.') diff --git a/src/Appwrite/Platform/Modules/Schedules/Http/Projects/Schedules/XList.php b/src/Appwrite/Platform/Modules/Projects/Http/Schedules/XList.php similarity index 94% rename from src/Appwrite/Platform/Modules/Schedules/Http/Projects/Schedules/XList.php rename to src/Appwrite/Platform/Modules/Projects/Http/Schedules/XList.php index 174ef670cc..ee5261efa4 100644 --- a/src/Appwrite/Platform/Modules/Schedules/Http/Projects/Schedules/XList.php +++ b/src/Appwrite/Platform/Modules/Projects/Http/Schedules/XList.php @@ -1,9 +1,10 @@ param('projectId', '', new UID(), 'Project unique ID.') ->param('queries', [], new Schedules(), 'Array of query strings generated using the Query class provided by the SDK. [Learn more about queries](https://appwrite.io/docs/queries). Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE . ' queries are allowed, each ' . APP_LIMIT_ARRAY_ELEMENT_SIZE . ' characters long. You may filter on the following attributes: ' . implode(', ', Schedules::ALLOWED_ATTRIBUTES), true) diff --git a/src/Appwrite/Platform/Modules/Projects/Services/Http.php b/src/Appwrite/Platform/Modules/Projects/Services/Http.php index 587f101d61..8b0d6f87c8 100644 --- a/src/Appwrite/Platform/Modules/Projects/Services/Http.php +++ b/src/Appwrite/Platform/Modules/Projects/Services/Http.php @@ -12,6 +12,9 @@ use Appwrite\Platform\Modules\Projects\Http\Projects\Labels\Update as UpdateProj use Appwrite\Platform\Modules\Projects\Http\Projects\Team\Update as UpdateProjectTeam; use Appwrite\Platform\Modules\Projects\Http\Projects\Update as UpdateProject; use Appwrite\Platform\Modules\Projects\Http\Projects\XList as ListProjects; +use Appwrite\Platform\Modules\Projects\Http\Schedules\Create as CreateSchedule; +use Appwrite\Platform\Modules\Projects\Http\Schedules\Get as GetSchedule; +use Appwrite\Platform\Modules\Projects\Http\Schedules\XList as ListSchedules; use Utopia\Platform\Service; class Http extends Service @@ -30,5 +33,9 @@ class Http extends Service $this->addAction(ListProjects::getName(), new ListProjects()); $this->addAction(UpdateProjectLabels::getName(), new UpdateProjectLabels()); $this->addAction(UpdateProjectTeam::getName(), new UpdateProjectTeam()); + + $this->addAction(CreateSchedule::getName(), new CreateSchedule()); + $this->addAction(GetSchedule::getName(), new GetSchedule()); + $this->addAction(ListSchedules::getName(), new ListSchedules()); } } diff --git a/src/Appwrite/Platform/Modules/Schedules/Module.php b/src/Appwrite/Platform/Modules/Schedules/Module.php deleted file mode 100644 index 39fcfa0175..0000000000 --- a/src/Appwrite/Platform/Modules/Schedules/Module.php +++ /dev/null @@ -1,14 +0,0 @@ -addService('http', new Http()); - } -} diff --git a/src/Appwrite/Platform/Modules/Schedules/Services/Http.php b/src/Appwrite/Platform/Modules/Schedules/Services/Http.php deleted file mode 100644 index a3bba33bd8..0000000000 --- a/src/Appwrite/Platform/Modules/Schedules/Services/Http.php +++ /dev/null @@ -1,20 +0,0 @@ -type = Service::TYPE_HTTP; - - $this->addAction(Get::getName(), new Get()); - $this->addAction(XList::getName(), new XList()); - $this->addAction(Create::getName(), new Create()); - } -} From 63bb69dfb1ab79d78b9da9e62c223a2a039f510a Mon Sep 17 00:00:00 2001 From: Prem Palanisamy Date: Tue, 17 Feb 2026 11:58:27 +0000 Subject: [PATCH 17/18] Update docs for directory change --- .../projects/{schedules/create.md => create-schedule.md} | 0 .../references/projects/{schedules/get.md => get-schedule.md} | 0 .../projects/{schedules/list.md => list-schedules.md} | 0 .../Platform/Modules/Projects/Http/Schedules/Create.php | 4 +--- src/Appwrite/Platform/Modules/Projects/Http/Schedules/Get.php | 4 +--- .../Platform/Modules/Projects/Http/Schedules/XList.php | 4 +--- 6 files changed, 3 insertions(+), 9 deletions(-) rename docs/references/projects/{schedules/create.md => create-schedule.md} (100%) rename docs/references/projects/{schedules/get.md => get-schedule.md} (100%) rename docs/references/projects/{schedules/list.md => list-schedules.md} (100%) diff --git a/docs/references/projects/schedules/create.md b/docs/references/projects/create-schedule.md similarity index 100% rename from docs/references/projects/schedules/create.md rename to docs/references/projects/create-schedule.md diff --git a/docs/references/projects/schedules/get.md b/docs/references/projects/get-schedule.md similarity index 100% rename from docs/references/projects/schedules/get.md rename to docs/references/projects/get-schedule.md diff --git a/docs/references/projects/schedules/list.md b/docs/references/projects/list-schedules.md similarity index 100% rename from docs/references/projects/schedules/list.md rename to docs/references/projects/list-schedules.md diff --git a/src/Appwrite/Platform/Modules/Projects/Http/Schedules/Create.php b/src/Appwrite/Platform/Modules/Projects/Http/Schedules/Create.php index 4bc5b9a271..e00809300d 100644 --- a/src/Appwrite/Platform/Modules/Projects/Http/Schedules/Create.php +++ b/src/Appwrite/Platform/Modules/Projects/Http/Schedules/Create.php @@ -5,7 +5,6 @@ namespace Appwrite\Platform\Modules\Projects\Http\Schedules; use Appwrite\Event\Event; use Appwrite\Extend\Exception; use Appwrite\SDK\AuthType; -use Appwrite\SDK\ContentType; use Appwrite\SDK\Method; use Appwrite\SDK\Response as SDKResponse; use Appwrite\Task\Validator\Cron; @@ -76,7 +75,7 @@ class Create extends Action namespace: 'projects', group: 'schedules', name: 'createSchedule', - description: '/docs/references/projects/schedules/create.md', + description: '/docs/references/projects/create-schedule.md', auth: [AuthType::ADMIN], responses: [ new SDKResponse( @@ -84,7 +83,6 @@ class Create extends Action model: Response::MODEL_SCHEDULE, ), ], - contentType: ContentType::JSON, )) ->param('projectId', '', new UID(), 'Project unique ID.') ->param('resourceType', '', new WhiteList($resourceTypes, true), 'The resource type for the schedule. Possible values: '.implode(', ', $resourceTypes).'.') diff --git a/src/Appwrite/Platform/Modules/Projects/Http/Schedules/Get.php b/src/Appwrite/Platform/Modules/Projects/Http/Schedules/Get.php index 4938ed2415..d14d4c361d 100644 --- a/src/Appwrite/Platform/Modules/Projects/Http/Schedules/Get.php +++ b/src/Appwrite/Platform/Modules/Projects/Http/Schedules/Get.php @@ -4,7 +4,6 @@ namespace Appwrite\Platform\Modules\Projects\Http\Schedules; use Appwrite\Extend\Exception; use Appwrite\SDK\AuthType; -use Appwrite\SDK\ContentType; use Appwrite\SDK\Method; use Appwrite\SDK\Response as SDKResponse; use Appwrite\Utopia\Response; @@ -34,7 +33,7 @@ class Get extends Action namespace: 'projects', group: 'schedules', name: 'getSchedule', - description: '/docs/references/projects/schedules/get.md', + description: '/docs/references/projects/get-schedule.md', auth: [AuthType::ADMIN], responses: [ new SDKResponse( @@ -42,7 +41,6 @@ class Get extends Action model: Response::MODEL_SCHEDULE, ) ], - contentType: ContentType::JSON, )) ->param('projectId', '', new UID(), 'Project unique ID.') ->param('scheduleId', '', new UID(), 'Schedule ID.') diff --git a/src/Appwrite/Platform/Modules/Projects/Http/Schedules/XList.php b/src/Appwrite/Platform/Modules/Projects/Http/Schedules/XList.php index ee5261efa4..66879f798f 100644 --- a/src/Appwrite/Platform/Modules/Projects/Http/Schedules/XList.php +++ b/src/Appwrite/Platform/Modules/Projects/Http/Schedules/XList.php @@ -4,7 +4,6 @@ namespace Appwrite\Platform\Modules\Projects\Http\Schedules; use Appwrite\Extend\Exception; use Appwrite\SDK\AuthType; -use Appwrite\SDK\ContentType; use Appwrite\SDK\Method; use Appwrite\SDK\Response as SDKResponse; use Appwrite\Utopia\Database\Validator\Queries\Schedules; @@ -41,7 +40,7 @@ class XList extends Action namespace: 'projects', group: 'schedules', name: 'listSchedules', - description: '/docs/references/projects/schedules/list.md', + description: '/docs/references/projects/list-schedules.md', auth: [AuthType::ADMIN], responses: [ new SDKResponse( @@ -49,7 +48,6 @@ class XList extends Action model: Response::MODEL_SCHEDULE_LIST, ) ], - contentType: ContentType::JSON, )) ->param('projectId', '', new UID(), 'Project unique ID.') ->param('queries', [], new Schedules(), 'Array of query strings generated using the Query class provided by the SDK. [Learn more about queries](https://appwrite.io/docs/queries). Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE . ' queries are allowed, each ' . APP_LIMIT_ARRAY_ELEMENT_SIZE . ' characters long. You may filter on the following attributes: ' . implode(', ', Schedules::ALLOWED_ATTRIBUTES), true) From bd2e788c2a875db26a279b9725b477ea52fb3162 Mon Sep 17 00:00:00 2001 From: Prem Palanisamy Date: Wed, 18 Feb 2026 07:31:37 +0000 Subject: [PATCH 18/18] Update utopia-php/database to 5.3.1 --- composer.lock | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/composer.lock b/composer.lock index 5a6958c241..f17670f7b1 100644 --- a/composer.lock +++ b/composer.lock @@ -3797,16 +3797,16 @@ }, { "name": "utopia-php/database", - "version": "5.3.0", + "version": "5.3.1", "source": { "type": "git", "url": "https://github.com/utopia-php/database.git", - "reference": "5e49f32ebb6e40a78209231564c40e5068810429" + "reference": "e3305f6ca64eb5dd715e30212fd32d5d834a3a46" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/utopia-php/database/zipball/5e49f32ebb6e40a78209231564c40e5068810429", - "reference": "5e49f32ebb6e40a78209231564c40e5068810429", + "url": "https://api.github.com/repos/utopia-php/database/zipball/e3305f6ca64eb5dd715e30212fd32d5d834a3a46", + "reference": "e3305f6ca64eb5dd715e30212fd32d5d834a3a46", "shasum": "" }, "require": { @@ -3849,9 +3849,9 @@ ], "support": { "issues": "https://github.com/utopia-php/database/issues", - "source": "https://github.com/utopia-php/database/tree/5.3.0" + "source": "https://github.com/utopia-php/database/tree/5.3.1" }, - "time": "2026-02-17T11:50:01+00:00" + "time": "2026-02-18T07:15:40+00:00" }, { "name": "utopia-php/detector", @@ -8885,7 +8885,7 @@ ], "aliases": [], "minimum-stability": "stable", - "stability-flags": {}, + "stability-flags": [], "prefer-stable": false, "prefer-lowest": false, "platform": {