diff --git a/app/config/collections.php b/app/config/collections.php index f8254bf889..8425702d34 100644 --- a/app/config/collections.php +++ b/app/config/collections.php @@ -1865,6 +1865,17 @@ $collections = [ 'array' => false, 'filters' => [], ], + [ + '$id' => ID::custom('prefs'), + 'type' => Database::VAR_STRING, + 'format' => '', + 'size' => 65535, + 'signed' => true, + 'required' => false, + 'default' => new \stdClass(), + 'array' => false, + 'filters' => ['json'], + ], ], 'indexes' => [ [ diff --git a/app/config/events.php b/app/config/events.php index 6fe030d4bf..c4051f0515 100644 --- a/app/config/events.php +++ b/app/config/events.php @@ -190,6 +190,9 @@ return [ ], 'update' => [ '$description' => 'This event triggers when a bucket is updated.', + 'prefs' => [ + '$description' => 'This event triggers when a team\'s preferences is updated.', + ], ] ], 'functions' => [ diff --git a/app/controllers/api/teams.php b/app/controllers/api/teams.php index f95c779838..0f143b8f54 100644 --- a/app/controllers/api/teams.php +++ b/app/controllers/api/teams.php @@ -36,10 +36,9 @@ use Utopia\Database\Validator\Authorization; use Utopia\Database\Validator\Key; use Utopia\Database\Validator\UID; use Utopia\Locale\Locale; -use Utopia\Validator\Text; -use Utopia\Validator\Range; use Utopia\Validator\ArrayList; -use Utopia\Validator\WhiteList; +use Utopia\Validator\Assoc; +use Utopia\Validator\Text; use Appwrite\Event\Phone as EventPhone; App::post('/v1/teams') @@ -78,6 +77,7 @@ App::post('/v1/teams') ], 'name' => $name, 'total' => ($isPrivilegedUser || $isAppUser) ? 0 : 1, + 'prefs' => new \stdClass(), 'search' => implode(' ', [$teamId, $name]), ]))); @@ -201,8 +201,36 @@ App::get('/v1/teams/:teamId') $response->dynamic($team, Response::MODEL_TEAM); }); +App::get('/v1/teams/:teamId/prefs') + ->desc('Get Team Preferences') + ->groups(['api', 'teams']) + ->label('scope', 'teams.read') + ->label('sdk.auth', [APP_AUTH_TYPE_SESSION, APP_AUTH_TYPE_JWT]) + ->label('sdk.namespace', 'teams') + ->label('sdk.method', 'getPrefs') + ->label('sdk.description', '/docs/references/teams/get-team-prefs.md') + ->label('sdk.response.code', Response::STATUS_CODE_OK) + ->label('sdk.response.type', Response::CONTENT_TYPE_JSON) + ->label('sdk.response.model', Response::MODEL_PREFERENCES) + ->label('sdk.offline.model', '/teams/{teamId}/prefs') + ->param('teamId', '', new UID(), 'Team ID.') + ->inject('response') + ->inject('dbForProject') + ->action(function (string $teamId, Response $response, Database $dbForProject) { + + $team = $dbForProject->getDocument('teams', $teamId); + + if ($team->isEmpty()) { + throw new Exception(Exception::TEAM_NOT_FOUND); + } + + $prefs = $team->getAttribute('prefs', new \stdClass()); + + $response->dynamic(new Document($prefs), Response::MODEL_PREFERENCES); + }); + App::put('/v1/teams/:teamId') - ->desc('Update Team') + ->desc('Update Name') ->groups(['api', 'teams']) ->label('event', 'teams.[teamId].update') ->label('scope', 'teams.write') @@ -211,7 +239,7 @@ App::put('/v1/teams/:teamId') ->label('sdk.auth', [APP_AUTH_TYPE_SESSION, APP_AUTH_TYPE_KEY, APP_AUTH_TYPE_JWT]) ->label('sdk.namespace', 'teams') ->label('sdk.method', 'update') - ->label('sdk.description', '/docs/references/teams/update-team.md') + ->label('sdk.description', '/docs/references/teams/update-team-name.md') ->label('sdk.response.code', Response::STATUS_CODE_OK) ->label('sdk.response.type', Response::CONTENT_TYPE_JSON) ->label('sdk.response.model', Response::MODEL_TEAM) @@ -239,6 +267,42 @@ App::put('/v1/teams/:teamId') $response->dynamic($team, Response::MODEL_TEAM); }); +App::patch('/v1/teams/:teamId/prefs') + ->desc('Update Preferences') + ->groups(['api', 'teams']) + ->label('event', 'teams.[teamId].update.prefs') + ->label('scope', 'teams.write') + ->label('audits.event', 'team.update') + ->label('audits.resource', 'team/{response.$id}') + ->label('audits.userId', '{response.$id}') + ->label('sdk.auth', [APP_AUTH_TYPE_SESSION, APP_AUTH_TYPE_JWT]) + ->label('sdk.namespace', 'teams') + ->label('sdk.method', 'updatePrefs') + ->label('sdk.description', '/docs/references/teams/update-team-prefs.md') + ->label('sdk.response.code', Response::STATUS_CODE_OK) + ->label('sdk.response.type', Response::CONTENT_TYPE_JSON) + ->label('sdk.response.model', Response::MODEL_PREFERENCES) + ->label('sdk.offline.model', '/teams/{teamId}') + ->param('teamId', '', new UID(), 'Team ID.') + ->param('prefs', '', new Assoc(), 'Prefs key-value JSON object.') + ->inject('response') + ->inject('dbForProject') + ->inject('events') + ->action(function (string $teamId, array $prefs, Response $response, Database $dbForProject, Event $events) { + + $team = $dbForProject->getDocument('teams', $teamId); + + if ($team->isEmpty()) { + throw new Exception(Exception::TEAM_NOT_FOUND); + } + + $team = $dbForProject->updateDocument('teams', $team->getId(), $team->setAttribute('prefs', $prefs)); + + $events->setParam('teamId', $team->getId()); + + $response->dynamic(new Document($prefs), Response::MODEL_PREFERENCES); + }); + App::delete('/v1/teams/:teamId') ->desc('Delete Team') ->groups(['api', 'teams']) diff --git a/composer.lock b/composer.lock index 82a9de301f..c6355fb527 100644 --- a/composer.lock +++ b/composer.lock @@ -1658,16 +1658,16 @@ }, { "name": "symfony/deprecation-contracts", - "version": "v3.2.0", + "version": "v3.2.1", "source": { "type": "git", "url": "https://github.com/symfony/deprecation-contracts.git", - "reference": "1ee04c65529dea5d8744774d474e7cbd2f1206d3" + "reference": "e2d1534420bd723d0ef5aec58a22c5fe60ce6f5e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/1ee04c65529dea5d8744774d474e7cbd2f1206d3", - "reference": "1ee04c65529dea5d8744774d474e7cbd2f1206d3", + "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/e2d1534420bd723d0ef5aec58a22c5fe60ce6f5e", + "reference": "e2d1534420bd723d0ef5aec58a22c5fe60ce6f5e", "shasum": "" }, "require": { @@ -1705,7 +1705,7 @@ "description": "A generic function and convention to trigger deprecation notices", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/deprecation-contracts/tree/v3.2.0" + "source": "https://github.com/symfony/deprecation-contracts/tree/v3.2.1" }, "funding": [ { @@ -1721,7 +1721,7 @@ "type": "tidelift" } ], - "time": "2022-11-25T10:21:52+00:00" + "time": "2023-03-01T10:25:55+00:00" }, { "name": "symfony/polyfill-php80", @@ -3342,16 +3342,16 @@ }, { "name": "nikic/php-parser", - "version": "v4.15.3", + "version": "v4.15.4", "source": { "type": "git", "url": "https://github.com/nikic/PHP-Parser.git", - "reference": "570e980a201d8ed0236b0a62ddf2c9cbb2034039" + "reference": "6bb5176bc4af8bcb7d926f88718db9b96a2d4290" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/570e980a201d8ed0236b0a62ddf2c9cbb2034039", - "reference": "570e980a201d8ed0236b0a62ddf2c9cbb2034039", + "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/6bb5176bc4af8bcb7d926f88718db9b96a2d4290", + "reference": "6bb5176bc4af8bcb7d926f88718db9b96a2d4290", "shasum": "" }, "require": { @@ -3392,9 +3392,9 @@ ], "support": { "issues": "https://github.com/nikic/PHP-Parser/issues", - "source": "https://github.com/nikic/PHP-Parser/tree/v4.15.3" + "source": "https://github.com/nikic/PHP-Parser/tree/v4.15.4" }, - "time": "2023-01-16T22:05:37+00:00" + "time": "2023-03-05T19:49:14+00:00" }, { "name": "phar-io/manifest", @@ -3742,23 +3742,23 @@ }, { "name": "phpunit/php-code-coverage", - "version": "9.2.24", + "version": "9.2.25", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/php-code-coverage.git", - "reference": "2cf940ebc6355a9d430462811b5aaa308b174bed" + "reference": "0e2b40518197a8c0d4b08bc34dfff1c99c508954" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/2cf940ebc6355a9d430462811b5aaa308b174bed", - "reference": "2cf940ebc6355a9d430462811b5aaa308b174bed", + "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/0e2b40518197a8c0d4b08bc34dfff1c99c508954", + "reference": "0e2b40518197a8c0d4b08bc34dfff1c99c508954", "shasum": "" }, "require": { "ext-dom": "*", "ext-libxml": "*", "ext-xmlwriter": "*", - "nikic/php-parser": "^4.14", + "nikic/php-parser": "^4.15", "php": ">=7.3", "phpunit/php-file-iterator": "^3.0.3", "phpunit/php-text-template": "^2.0.2", @@ -3807,7 +3807,7 @@ ], "support": { "issues": "https://github.com/sebastianbergmann/php-code-coverage/issues", - "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/9.2.24" + "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/9.2.25" }, "funding": [ { @@ -3815,7 +3815,7 @@ "type": "github" } ], - "time": "2023-01-26T08:26:55+00:00" + "time": "2023-02-25T05:32:00+00:00" }, { "name": "phpunit/php-file-iterator", @@ -5127,16 +5127,16 @@ }, { "name": "squizlabs/php_codesniffer", - "version": "3.7.1", + "version": "3.7.2", "source": { "type": "git", "url": "https://github.com/squizlabs/PHP_CodeSniffer.git", - "reference": "1359e176e9307e906dc3d890bcc9603ff6d90619" + "reference": "ed8e00df0a83aa96acf703f8c2979ff33341f879" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/squizlabs/PHP_CodeSniffer/zipball/1359e176e9307e906dc3d890bcc9603ff6d90619", - "reference": "1359e176e9307e906dc3d890bcc9603ff6d90619", + "url": "https://api.github.com/repos/squizlabs/PHP_CodeSniffer/zipball/ed8e00df0a83aa96acf703f8c2979ff33341f879", + "reference": "ed8e00df0a83aa96acf703f8c2979ff33341f879", "shasum": "" }, "require": { @@ -5172,14 +5172,15 @@ "homepage": "https://github.com/squizlabs/PHP_CodeSniffer", "keywords": [ "phpcs", - "standards" + "standards", + "static analysis" ], "support": { "issues": "https://github.com/squizlabs/PHP_CodeSniffer/issues", "source": "https://github.com/squizlabs/PHP_CodeSniffer", "wiki": "https://github.com/squizlabs/PHP_CodeSniffer/wiki" }, - "time": "2022-06-18T07:21:10+00:00" + "time": "2023-02-22T23:07:41+00:00" }, { "name": "swoole/ide-helper", diff --git a/docs/references/teams/get-team-prefs.md b/docs/references/teams/get-team-prefs.md new file mode 100644 index 0000000000..a3f963fbc4 --- /dev/null +++ b/docs/references/teams/get-team-prefs.md @@ -0,0 +1 @@ +Get the team preferences by its unique ID. \ No newline at end of file diff --git a/docs/references/teams/update-team-name.md b/docs/references/teams/update-team-name.md new file mode 100644 index 0000000000..fc39de08a1 --- /dev/null +++ b/docs/references/teams/update-team-name.md @@ -0,0 +1 @@ +Update the team name by its unique ID. \ No newline at end of file diff --git a/docs/references/teams/update-team-prefs.md b/docs/references/teams/update-team-prefs.md new file mode 100644 index 0000000000..afe9e78aaf --- /dev/null +++ b/docs/references/teams/update-team-prefs.md @@ -0,0 +1 @@ +Update the team preferences by its unique ID. The object you pass is stored as is, and replaces any previous value. The maximum allowed prefs size is 64kB and throws error if exceeded. \ No newline at end of file diff --git a/docs/references/teams/update-team.md b/docs/references/teams/update-team.md deleted file mode 100644 index 1f646bad57..0000000000 --- a/docs/references/teams/update-team.md +++ /dev/null @@ -1 +0,0 @@ -Update a team using its ID. Only members with the owner role can update the team. \ No newline at end of file diff --git a/src/Appwrite/Utopia/Response/Model/Team.php b/src/Appwrite/Utopia/Response/Model/Team.php index f4580729c7..62df089438 100644 --- a/src/Appwrite/Utopia/Response/Model/Team.php +++ b/src/Appwrite/Utopia/Response/Model/Team.php @@ -40,6 +40,12 @@ class Team extends Model 'default' => 0, 'example' => 7, ]) + ->addRule('prefs', [ + 'type' => Response::MODEL_PREFERENCES, + 'description' => 'Team preferences as a key-value object', + 'default' => new \stdClass(), + 'example' => ['theme' => 'pink', 'timezone' => 'UTC'], + ]) ; } diff --git a/tests/e2e/Services/Realtime/RealtimeCustomClientTest.php b/tests/e2e/Services/Realtime/RealtimeCustomClientTest.php index 5910c9975a..6da70b038a 100644 --- a/tests/e2e/Services/Realtime/RealtimeCustomClientTest.php +++ b/tests/e2e/Services/Realtime/RealtimeCustomClientTest.php @@ -1458,6 +1458,43 @@ class RealtimeCustomClientTest extends Scope $this->assertContains("teams.*", $response['data']['events']); $this->assertNotEmpty($response['data']['payload']); + /** + * Test Team Update Prefs + */ + $team = $this->client->call(Client::METHOD_PATCH, '/teams/' . $teamId . '/prefs', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + ], $this->getHeaders()), [ + 'prefs' => [ + 'funcKey1' => 'funcValue1', + 'funcKey2' => 'funcValue2', + ] + ]); + + $this->assertEquals($team['headers']['status-code'], 200); + $this->assertEquals($team['body']['funcKey1'], 'funcValue1'); + $this->assertEquals($team['body']['funcKey2'], 'funcValue2'); + + $response = json_decode($client->receive(), true); + + $this->assertArrayHasKey('type', $response); + $this->assertArrayHasKey('data', $response); + $this->assertEquals('event', $response['type']); + $this->assertNotEmpty($response['data']); + $this->assertArrayHasKey('timestamp', $response['data']); + $this->assertCount(2, $response['data']['channels']); + $this->assertContains('teams', $response['data']['channels']); + $this->assertContains("teams.{$teamId}", $response['data']['channels']); + $this->assertContains("teams.{$teamId}.update", $response['data']['events']); + $this->assertContains("teams.{$teamId}.update.prefs", $response['data']['events']); + $this->assertContains("teams.{$teamId}", $response['data']['events']); + $this->assertContains("teams.*.update.prefs", $response['data']['events']); + $this->assertContains("teams.*.update", $response['data']['events']); + $this->assertContains("teams.*", $response['data']['events']); + $this->assertNotEmpty($response['data']['payload']); + $this->assertEquals($response['data']['payload']['funcKey1'], 'funcValue1'); + $this->assertEquals($response['data']['payload']['funcKey2'], 'funcValue2'); + $client->close(); return ['teamId' => $teamId]; diff --git a/tests/e2e/Services/Teams/TeamsBase.php b/tests/e2e/Services/Teams/TeamsBase.php index daaf65494d..3db6437bda 100644 --- a/tests/e2e/Services/Teams/TeamsBase.php +++ b/tests/e2e/Services/Teams/TeamsBase.php @@ -27,6 +27,8 @@ trait TeamsBase $this->assertEquals('Arsenal', $response1['body']['name']); $this->assertGreaterThan(-1, $response1['body']['total']); $this->assertIsInt($response1['body']['total']); + $this->assertArrayHasKey('prefs', $response1['body']); + $dateValidator = new DatetimeValidator(); $this->assertEquals(true, $dateValidator->isValid($response1['body']['$createdAt'])); @@ -48,6 +50,7 @@ trait TeamsBase $this->assertEquals('Manchester United', $response2['body']['name']); $this->assertGreaterThan(-1, $response2['body']['total']); $this->assertIsInt($response2['body']['total']); + $this->assertArrayHasKey('prefs', $response2['body']); $this->assertEquals(true, $dateValidator->isValid($response2['body']['$createdAt'])); $response3 = $this->client->call(Client::METHOD_POST, '/teams', array_merge([ @@ -64,6 +67,7 @@ trait TeamsBase $this->assertGreaterThan(-1, $response3['body']['total']); $this->assertIsInt($response3['body']['total']); $this->assertEquals(true, $dateValidator->isValid($response3['body']['$createdAt'])); + /** * Test for FAILURE */ @@ -98,6 +102,7 @@ trait TeamsBase $this->assertEquals('Arsenal', $response['body']['name']); $this->assertGreaterThan(-1, $response['body']['total']); $this->assertIsInt($response['body']['total']); + $this->assertArrayHasKey('prefs', $response['body']); $dateValidator = new DatetimeValidator(); $this->assertEquals(true, $dateValidator->isValid($response['body']['$createdAt'])); @@ -292,6 +297,7 @@ trait TeamsBase $this->assertEquals('Demo New', $response['body']['name']); $this->assertGreaterThan(-1, $response['body']['total']); $this->assertIsInt($response['body']['total']); + $this->assertArrayHasKey('prefs', $response['body']); $this->assertEquals(true, $dateValidator->isValid($response['body']['$createdAt'])); /** @@ -328,6 +334,7 @@ trait TeamsBase $this->assertEquals('Demo', $response['body']['name']); $this->assertGreaterThan(-1, $response['body']['total']); $this->assertIsInt($response['body']['total']); + $this->assertArrayHasKey('prefs', $response['body']); $dateValidator = new DatetimeValidator(); $this->assertEquals(true, $dateValidator->isValid($response['body']['$createdAt'])); @@ -351,4 +358,63 @@ trait TeamsBase return []; } + + /** + * @depends testCreateTeam + */ + public function testUpdateAndGetUserPrefs(array $data): void + { + $id = $data['teamUid'] ?? ''; + + /** + * Test for SUCCESS + */ + $team = $this->client->call(Client::METHOD_PATCH, '/teams/' . $id . '/prefs', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'prefs' => [ + 'funcKey1' => 'funcValue1', + 'funcKey2' => 'funcValue2', + ], + ]); + + $this->assertEquals($team['headers']['status-code'], 200); + $this->assertEquals($team['body']['funcKey1'], 'funcValue1'); + $this->assertEquals($team['body']['funcKey2'], 'funcValue2'); + + $team = $this->client->call(Client::METHOD_GET, '/teams/' . $id . '/prefs', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders())); + + $this->assertEquals($team['headers']['status-code'], 200); + $this->assertEquals($team['body'], [ + 'funcKey1' => 'funcValue1', + 'funcKey2' => 'funcValue2', + ]); + + $team = $this->client->call(Client::METHOD_GET, '/teams/' . $id, array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders())); + + $this->assertEquals($team['headers']['status-code'], 200); + $this->assertEquals($team['body']['prefs'], [ + 'funcKey1' => 'funcValue1', + 'funcKey2' => 'funcValue2', + ]); + + /** + * Test for FAILURE + */ + $user = $this->client->call(Client::METHOD_PATCH, '/teams/' . $id . '/prefs', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'prefs' => 'bad-string', + ]); + + $this->assertEquals($user['headers']['status-code'], 400); + } } diff --git a/tests/e2e/Services/Webhooks/WebhooksBase.php b/tests/e2e/Services/Webhooks/WebhooksBase.php index 1afa9cdb52..4d6d39d1ff 100644 --- a/tests/e2e/Services/Webhooks/WebhooksBase.php +++ b/tests/e2e/Services/Webhooks/WebhooksBase.php @@ -777,6 +777,53 @@ trait WebhooksBase return ['teamId' => $team['body']['$id']]; } + /** + * @depends testCreateTeam + */ + public function testUpdateTeamPrefs(array $data): array + { + $id = $data['teamId'] ?? ''; + + $team = $this->client->call(Client::METHOD_PATCH, '/teams/' . $id . '/prefs', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'prefs' => [ + 'prefKey1' => 'prefValue1', + 'prefKey2' => 'prefValue2', + ] + ]); + + $this->assertEquals($team['headers']['status-code'], 200); + $this->assertIsArray($team['body']); + + $webhook = $this->getLastRequest(); + $signatureKey = $this->getProject()['signatureKey']; + $payload = json_encode($webhook['data']); + $url = $webhook['url']; + $signatureExpected = base64_encode(hash_hmac('sha1', $url . $payload, $signatureKey, true)); + + $this->assertEquals($webhook['method'], 'POST'); + $this->assertEquals($webhook['headers']['Content-Type'], 'application/json'); + $this->assertEquals($webhook['headers']['User-Agent'], 'Appwrite-Server vdev. Please report abuse at security@appwrite.io'); + $this->assertStringContainsString('teams.*', $webhook['headers']['X-Appwrite-Webhook-Events']); + $this->assertStringContainsString('teams.*.update', $webhook['headers']['X-Appwrite-Webhook-Events']); + $this->assertStringContainsString('teams.*.update.prefs', $webhook['headers']['X-Appwrite-Webhook-Events']); + $this->assertStringContainsString("teams.{$id}", $webhook['headers']['X-Appwrite-Webhook-Events']); + $this->assertStringContainsString("teams.{$id}.update", $webhook['headers']['X-Appwrite-Webhook-Events']); + $this->assertStringContainsString("teams.{$id}.update.prefs", $webhook['headers']['X-Appwrite-Webhook-Events']); + $this->assertEquals($webhook['headers']['X-Appwrite-Webhook-Signature'], $signatureExpected); + $this->assertEquals($webhook['headers']['X-Appwrite-Webhook-Id'] ?? '', $this->getProject()['webhookId']); + $this->assertEquals($webhook['headers']['X-Appwrite-Webhook-Project-Id'] ?? '', $this->getProject()['$id']); + $this->assertEquals(empty($webhook['headers']['X-Appwrite-Webhook-User-Id'] ?? ''), ('server' === $this->getSide())); + $this->assertEquals($webhook['data'], [ + 'prefKey1' => 'prefValue1', + 'prefKey2' => 'prefValue2', + ]); + + return $data; + } + public function testDeleteTeam(): array { /**