From 8d1acef95d5eff4a168ca42c31ad71d0f5c4406f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Wed, 31 Dec 2025 15:44:18 +0100 Subject: [PATCH 1/2] Implement project labels --- app/config/collections/platform.php | 13 +- app/controllers/api/projects.php | 1 + .../Projects/Http/Projects/Labels/Update.php | 86 ++++++++ .../Modules/Projects/Services/Http.php | 2 + .../Database/Validator/Queries/Projects.php | 3 +- .../Utopia/Response/Model/Project.php | 7 + .../Projects/ProjectsConsoleClientTest.php | 205 ++++++++++++++++++ 7 files changed, 315 insertions(+), 2 deletions(-) create mode 100644 src/Appwrite/Platform/Modules/Projects/Http/Projects/Labels/Update.php diff --git a/app/config/collections/platform.php b/app/config/collections/platform.php index e919df8e1a..f923ac4897 100644 --- a/app/config/collections/platform.php +++ b/app/config/collections/platform.php @@ -330,7 +330,18 @@ $platformCollections = [ 'default' => null, 'array' => false, 'filters' => ['datetime'], - ] + ], + [ + '$id' => ID::custom('labels'), + 'type' => Database::VAR_STRING, + 'format' => '', + 'size' => 128, + 'signed' => true, + 'required' => false, + 'default' => null, + 'array' => true, + 'filters' => [], + ], ], 'indexes' => [ [ diff --git a/app/controllers/api/projects.php b/app/controllers/api/projects.php index 49c0003588..40bde3baf3 100644 --- a/app/controllers/api/projects.php +++ b/app/controllers/api/projects.php @@ -204,6 +204,7 @@ App::post('/v1/projects') 'accessedAt' => DateTime::now(), 'search' => implode(' ', [$projectId, $name]), 'database' => $dsn, + 'labels' => [], ])); } catch (Duplicate) { throw new Exception(Exception::PROJECT_ALREADY_EXISTS); diff --git a/src/Appwrite/Platform/Modules/Projects/Http/Projects/Labels/Update.php b/src/Appwrite/Platform/Modules/Projects/Http/Projects/Labels/Update.php new file mode 100644 index 0000000000..1a06c1ee84 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Projects/Http/Projects/Labels/Update.php @@ -0,0 +1,86 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_PUT) + ->setHttpPath('/v1/projects/:projectId/labels') + ->desc('Update project labels') + ->groups(['api', 'projects']) + ->label('scope', 'projects.write') + ->label('sdk', new Method( + namespace: 'projects', + group: 'projects', + name: 'updateLabels', + description: <<param('projectId', '', new UID(), 'Project unique ID.') + ->param('labels', [], new ArrayList(new Text(36, allowList: [...Text::NUMBERS, ...Text::ALPHABET_UPPER, ...Text::ALPHABET_LOWER]), APP_LIMIT_ARRAY_LABELS_SIZE), 'Array of project labels. Replaces the previous labels. Maximum of ' . APP_LIMIT_ARRAY_LABELS_SIZE . ' labels are allowed, each up to 36 alphanumeric characters long.') + ->inject('response') + ->inject('dbForPlatform') + ->callback($this->action(...)); + } + + /** + * @param array $labels + */ + public function action( + string $projectId, + array $labels, + Response $response, + Database $dbForPlatform + ): void { + $project = $dbForPlatform->getDocument('projects', $projectId); + + if ($project->isEmpty()) { + throw new Exception(Exception::PROJECT_NOT_FOUND); + } + + $project->setAttribute('labels', (array) \array_values(\array_unique($labels))); + + $project = $dbForPlatform->updateDocument('projects', $project->getId(), $project); + + $response->dynamic($project, Response::MODEL_PROJECT); + } +} diff --git a/src/Appwrite/Platform/Modules/Projects/Services/Http.php b/src/Appwrite/Platform/Modules/Projects/Services/Http.php index 2a0dd0aa60..cce05a9570 100644 --- a/src/Appwrite/Platform/Modules/Projects/Services/Http.php +++ b/src/Appwrite/Platform/Modules/Projects/Services/Http.php @@ -7,6 +7,7 @@ use Appwrite\Platform\Modules\Projects\Http\DevKeys\Delete as DeleteDevKey; use Appwrite\Platform\Modules\Projects\Http\DevKeys\Get as GetDevKey; use Appwrite\Platform\Modules\Projects\Http\DevKeys\Update as UpdateDevKey; use Appwrite\Platform\Modules\Projects\Http\DevKeys\XList as ListDevKeys; +use Appwrite\Platform\Modules\Projects\Http\Projects\Labels\Update as UpdateProjectLabels; use Appwrite\Platform\Modules\Projects\Http\Projects\XList as ListProjects; use Utopia\Platform\Service; @@ -22,5 +23,6 @@ class Http extends Service $this->addAction(DeleteDevKey::getName(), new DeleteDevKey()); $this->addAction(ListProjects::getName(), new ListProjects()); + $this->addAction(UpdateProjectLabels::getName(), new UpdateProjectLabels()); } } diff --git a/src/Appwrite/Utopia/Database/Validator/Queries/Projects.php b/src/Appwrite/Utopia/Database/Validator/Queries/Projects.php index d179703274..d96e373949 100644 --- a/src/Appwrite/Utopia/Database/Validator/Queries/Projects.php +++ b/src/Appwrite/Utopia/Database/Validator/Queries/Projects.php @@ -6,7 +6,8 @@ class Projects extends Base { public const ALLOWED_ATTRIBUTES = [ 'name', - 'teamId' + 'teamId', + 'labels', ]; /** diff --git a/src/Appwrite/Utopia/Response/Model/Project.php b/src/Appwrite/Utopia/Response/Model/Project.php index 7641e96090..c516aab73f 100644 --- a/src/Appwrite/Utopia/Response/Model/Project.php +++ b/src/Appwrite/Utopia/Response/Model/Project.php @@ -276,6 +276,13 @@ class Project extends Model 'default' => '', 'example' => self::TYPE_DATETIME_EXAMPLE, ]) + ->addRule('labels', [ + 'type' => self::TYPE_STRING, + 'description' => 'Labels for the project.', + 'default' => [], + 'example' => ['vip'], + 'array' => true, + ]) ; $services = Config::getParam('services', []); diff --git a/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php b/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php index fae6031672..9a4453458a 100644 --- a/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php +++ b/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php @@ -5382,4 +5382,209 @@ class ProjectsConsoleClientTest extends Scope /** * Devkeys Tests ends here ------------------------------------------------ */ + + public function testProjectLabels(): void + { + // Setup: Prepare team + $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' => 'Query Select Test Team', + ]); + + $this->assertEquals(201, $team['headers']['status-code']); + $teamId = $team['body']['$id']; + + // Setup: Prepare project + $project = $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' => 'Test project - Labels 1', + 'teamId' => $teamId, + 'region' => System::getEnv('_APP_REGION', 'default') + ]); + + $this->assertEquals(201, $project['headers']['status-code']); + $this->assertIsArray($project['body']['labels']); + $this->assertCount(0, $project['body']['labels']); + $projectId = $project['body']['$id']; + + // Apply labels + $project = $this->client->call(Client::METHOD_PUT, '/projects/' . $projectId . '/labels', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'labels' => ['vip', 'imagine', 'blocked'] + ]); + + $this->assertEquals(200, $project['headers']['status-code']); + $this->assertIsArray($project['body']['labels']); + $this->assertCount(3, $project['body']['labels']); + $this->assertEquals('vip', $project['body']['labels'][0]); + $this->assertEquals('imagine', $project['body']['labels'][1]); + $this->assertEquals('blocked', $project['body']['labels'][2]); + + // Update labels + $project = $this->client->call(Client::METHOD_PUT, '/projects/' . $projectId . '/labels', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'labels' => ['nonvip', 'imagine'] + ]); + $this->assertEquals(200, $project['headers']['status-code']); + $this->assertIsArray($project['body']['labels']); + $this->assertCount(2, $project['body']['labels']); + $this->assertEquals('nonvip', $project['body']['labels'][0]); + $this->assertEquals('imagine', $project['body']['labels'][1]); + + // Filter by labels + $projects = $this->client->call(Client::METHOD_GET, '/projects', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'queries' => [ + Query::contains('labels', ['nonvip'])->toString(), + ] + ]); + $this->assertEquals(200, $project['headers']['status-code']); + $this->assertEquals(1, $projects['body']['total']); + $this->assertEquals($projectId, $projects['body']['projects'][0]['$id']); + + $projects = $this->client->call(Client::METHOD_GET, '/projects', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'queries' => [ + Query::contains('labels', ['vip'])->toString(), + ] + ]); + $this->assertEquals(200, $project['headers']['status-code']); + $this->assertEquals(0, $projects['body']['total']); + + $projects = $this->client->call(Client::METHOD_GET, '/projects', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'queries' => [ + Query::contains('labels', ['imagine'])->toString(), + ] + ]); + $this->assertEquals(200, $project['headers']['status-code']); + $this->assertEquals(1, $projects['body']['total']); + $this->assertEquals($projectId, $projects['body']['projects'][0]['$id']); + + $projects = $this->client->call(Client::METHOD_GET, '/projects', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'queries' => [ + Query::contains('labels', ['nonvip', 'imagine'])->toString(), + ] + ]); + $this->assertEquals(200, $project['headers']['status-code']); + $this->assertEquals(1, $projects['body']['total']); + $this->assertEquals($projectId, $projects['body']['projects'][0]['$id']); + + // Setup: Second project with only imagine label + $project = $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' => 'Test project - Labels 2', + 'teamId' => $teamId, + 'region' => System::getEnv('_APP_REGION', 'default') + ]); + + $this->assertEquals(201, $project['headers']['status-code']); + $this->assertIsArray($project['body']['labels']); + $this->assertCount(0, $project['body']['labels']); + $projectId2 = $project['body']['$id']; + + $project = $this->client->call(Client::METHOD_PUT, '/projects/' . $projectId2 . '/labels', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'labels' => ['vip', 'imagine'] + ]); + $this->assertEquals(200, $project['headers']['status-code']); + $this->assertIsArray($project['body']['labels']); + $this->assertCount(2, $project['body']['labels']); + $this->assertEquals('vip', $project['body']['labels'][0]); + $this->assertEquals('imagine', $project['body']['labels'][1]); + + // List of imagine has both + $projects = $this->client->call(Client::METHOD_GET, '/projects', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'queries' => [ + Query::contains('labels', ['imagine'])->toString(), + ] + ]); + $this->assertEquals(200, $project['headers']['status-code']); + $this->assertEquals(2, $projects['body']['total']); + $this->assertEquals($projectId, $projects['body']['projects'][0]['$id']); + $this->assertEquals($projectId2, $projects['body']['projects'][1]['$id']); + + // List of vip only has second + $projects = $this->client->call(Client::METHOD_GET, '/projects', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'queries' => [ + Query::contains('labels', ['vip'])->toString(), + ] + ]); + $this->assertEquals(200, $project['headers']['status-code']); + $this->assertEquals(1, $projects['body']['total']); + $this->assertEquals($projectId2, $projects['body']['projects'][0]['$id']); + + // List of vip and imagine has second + $projects = $this->client->call(Client::METHOD_GET, '/projects', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'queries' => [ + Query::contains('labels', ['vip'])->toString(), + Query::contains('labels', ['imagine'])->toString(), + ] + ]); + $this->assertEquals(200, $project['headers']['status-code']); + $this->assertEquals(1, $projects['body']['total']); + $this->assertEquals($projectId2, $projects['body']['projects'][0]['$id']); + + // List of vip or imagine has second + $projects = $this->client->call(Client::METHOD_GET, '/projects', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'queries' => [ + Query::contains('labels', ['vip', 'imagine'])->toString(), + ] + ]); + $this->assertEquals(200, $project['headers']['status-code']); + $this->assertEquals(2, $projects['body']['total']); + $this->assertEquals($projectId, $projects['body']['projects'][0]['$id']); + $this->assertEquals($projectId2, $projects['body']['projects'][1]['$id']); + + // Cleanup + $response = $this->client->call(Client::METHOD_DELETE, '/projects/' . $projectId, array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders())); + + $this->assertEquals(204, $response['headers']['status-code']); + + $response = $this->client->call(Client::METHOD_DELETE, '/teams/' . $teamId, array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders())); + + $this->assertEquals(204, $response['headers']['status-code']); + } } From 1db12e78efdd7c2734f47e02762030f444e58a87 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Mon, 5 Jan 2026 14:42:03 +0100 Subject: [PATCH 2/2] AI code review --- app/config/collections/platform.php | 2 +- .../Projects/ProjectsConsoleClientTest.php | 23 ++++++++++++------- 2 files changed, 16 insertions(+), 9 deletions(-) diff --git a/app/config/collections/platform.php b/app/config/collections/platform.php index f923ac4897..b2e077ad61 100644 --- a/app/config/collections/platform.php +++ b/app/config/collections/platform.php @@ -338,7 +338,7 @@ $platformCollections = [ 'size' => 128, 'signed' => true, 'required' => false, - 'default' => null, + 'default' => [], 'array' => true, 'filters' => [], ], diff --git a/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php b/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php index 9a4453458a..d055b876f9 100644 --- a/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php +++ b/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php @@ -5450,7 +5450,7 @@ class ProjectsConsoleClientTest extends Scope Query::contains('labels', ['nonvip'])->toString(), ] ]); - $this->assertEquals(200, $project['headers']['status-code']); + $this->assertEquals(200, $projects['headers']['status-code']); $this->assertEquals(1, $projects['body']['total']); $this->assertEquals($projectId, $projects['body']['projects'][0]['$id']); @@ -5462,7 +5462,7 @@ class ProjectsConsoleClientTest extends Scope Query::contains('labels', ['vip'])->toString(), ] ]); - $this->assertEquals(200, $project['headers']['status-code']); + $this->assertEquals(200, $projects['headers']['status-code']); $this->assertEquals(0, $projects['body']['total']); $projects = $this->client->call(Client::METHOD_GET, '/projects', array_merge([ @@ -5473,7 +5473,7 @@ class ProjectsConsoleClientTest extends Scope Query::contains('labels', ['imagine'])->toString(), ] ]); - $this->assertEquals(200, $project['headers']['status-code']); + $this->assertEquals(200, $projects['headers']['status-code']); $this->assertEquals(1, $projects['body']['total']); $this->assertEquals($projectId, $projects['body']['projects'][0]['$id']); @@ -5485,7 +5485,7 @@ class ProjectsConsoleClientTest extends Scope Query::contains('labels', ['nonvip', 'imagine'])->toString(), ] ]); - $this->assertEquals(200, $project['headers']['status-code']); + $this->assertEquals(200, $projects['headers']['status-code']); $this->assertEquals(1, $projects['body']['total']); $this->assertEquals($projectId, $projects['body']['projects'][0]['$id']); @@ -5526,7 +5526,7 @@ class ProjectsConsoleClientTest extends Scope Query::contains('labels', ['imagine'])->toString(), ] ]); - $this->assertEquals(200, $project['headers']['status-code']); + $this->assertEquals(200, $projects['headers']['status-code']); $this->assertEquals(2, $projects['body']['total']); $this->assertEquals($projectId, $projects['body']['projects'][0]['$id']); $this->assertEquals($projectId2, $projects['body']['projects'][1]['$id']); @@ -5540,7 +5540,7 @@ class ProjectsConsoleClientTest extends Scope Query::contains('labels', ['vip'])->toString(), ] ]); - $this->assertEquals(200, $project['headers']['status-code']); + $this->assertEquals(200, $projects['headers']['status-code']); $this->assertEquals(1, $projects['body']['total']); $this->assertEquals($projectId2, $projects['body']['projects'][0]['$id']); @@ -5554,7 +5554,7 @@ class ProjectsConsoleClientTest extends Scope Query::contains('labels', ['imagine'])->toString(), ] ]); - $this->assertEquals(200, $project['headers']['status-code']); + $this->assertEquals(200, $projects['headers']['status-code']); $this->assertEquals(1, $projects['body']['total']); $this->assertEquals($projectId2, $projects['body']['projects'][0]['$id']); @@ -5567,7 +5567,7 @@ class ProjectsConsoleClientTest extends Scope Query::contains('labels', ['vip', 'imagine'])->toString(), ] ]); - $this->assertEquals(200, $project['headers']['status-code']); + $this->assertEquals(200, $projects['headers']['status-code']); $this->assertEquals(2, $projects['body']['total']); $this->assertEquals($projectId, $projects['body']['projects'][0]['$id']); $this->assertEquals($projectId2, $projects['body']['projects'][1]['$id']); @@ -5580,6 +5580,13 @@ class ProjectsConsoleClientTest extends Scope $this->assertEquals(204, $response['headers']['status-code']); + $response = $this->client->call(Client::METHOD_DELETE, '/projects/' . $projectId2, array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders())); + + $this->assertEquals(204, $response['headers']['status-code']); + $response = $this->client->call(Client::METHOD_DELETE, '/teams/' . $teamId, array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'],