diff --git a/app/config/collections.php b/app/config/collections.php index cbe99f6885..0b4dda6851 100644 --- a/app/config/collections.php +++ b/app/config/collections.php @@ -1875,6 +1875,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..55dfad77d0 100644 --- a/app/config/events.php +++ b/app/config/events.php @@ -183,13 +183,16 @@ return [ ], ], 'create' => [ - '$description' => 'This event triggers when a bucket is created.' + '$description' => 'This event triggers when a team is created.' ], 'delete' => [ - '$description' => 'This event triggers when a bucket is deleted.', + '$description' => 'This event triggers when a team is deleted.', ], 'update' => [ - '$description' => 'This event triggers when a bucket is updated.', + '$description' => 'This event triggers when a team is updated.', + 'prefs' => [ + '$description' => 'This event triggers when a team\'s preferences are updated.', + ], ] ], 'functions' => [ diff --git a/app/controllers/api/teams.php b/app/controllers/api/teams.php index 204f4dfc02..1aa7c9321c 100644 --- a/app/controllers/api/teams.php +++ b/app/controllers/api/teams.php @@ -36,8 +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\ArrayList; +use Utopia\Validator\Assoc; +use Utopia\Validator\Text; App::post('/v1/teams') ->desc('Create Team') @@ -75,6 +76,7 @@ App::post('/v1/teams') ], 'name' => $name, 'total' => ($isPrivilegedUser || $isAppUser) ? 0 : 1, + 'prefs' => new \stdClass(), 'search' => implode(' ', [$teamId, $name]), ]))); @@ -198,8 +200,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') @@ -207,8 +237,8 @@ App::put('/v1/teams/:teamId') ->label('audits.resource', 'team/{response.$id}') ->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.method', 'updateName') + ->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) @@ -241,6 +271,42 @@ App::put('/v1/teams/:teamId') $response->dynamic($team, Response::MODEL_TEAM); }); +App::put('/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}/prefs') + ->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/docs/references/teams/get-team-prefs.md b/docs/references/teams/get-team-prefs.md new file mode 100644 index 0000000000..25837f00e1 --- /dev/null +++ b/docs/references/teams/get-team-prefs.md @@ -0,0 +1 @@ +Get the team's shared preferences by its unique ID. If a preference doesn't need to be shared by all team members, prefer storing them in [user preferences](/docs/client/account#accountGetPrefs). \ 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..e708369a02 --- /dev/null +++ b/docs/references/teams/update-team-name.md @@ -0,0 +1 @@ +Update the team's 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..e4c0168e6d --- /dev/null +++ b/docs/references/teams/update-team-prefs.md @@ -0,0 +1 @@ +Update the team's 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 an 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/GraphQL/Base.php b/tests/e2e/Services/GraphQL/Base.php index faa3f09757..93bb284757 100644 --- a/tests/e2e/Services/GraphQL/Base.php +++ b/tests/e2e/Services/GraphQL/Base.php @@ -110,9 +110,12 @@ trait Base // Teams public static string $GET_TEAM = 'get_team'; + public static string $GET_TEAM_PREFERENCES = 'get_team_preferences'; public static string $GET_TEAMS = 'list_teams'; public static string $CREATE_TEAM = 'create_team'; - public static string $UPDATE_TEAM = 'update_team'; + public static string $UPDATE_TEAM_NAME = 'update_team_name'; + public static string $UPDATE_TEAM_PREFERENCES = 'update_team_preferences'; + public static string $DELETE_TEAM = 'delete_team'; public static string $GET_TEAM_MEMBERSHIP = 'get_team_membership'; public static string $GET_TEAM_MEMBERSHIPS = 'list_team_memberships'; @@ -1206,10 +1209,16 @@ trait Base }'; case self::$GET_TEAM: return 'query getTeam($teamId: String!){ - teamsGet(teamId: $teamId) { - _id - name - total + teamsGet(teamId: $teamId) { + _id + name + total + } + }'; + case self::$GET_TEAM_PREFERENCES: + return 'query getTeamPreferences($teamId: String!) { + teamsGetPrefs(teamId: $teamId) { + data } }'; case self::$GET_TEAMS: @@ -1230,12 +1239,18 @@ trait Base total } }'; - case self::$UPDATE_TEAM: - return 'mutation updateTeam($teamId: String!, $name: String!){ - teamsUpdate(teamId: $teamId, name : $name) { - _id - name - total + case self::$UPDATE_TEAM_NAME: + return 'mutation updateTeamName($teamId: String!, $name: String!){ + teamsUpdateName(teamId: $teamId, name : $name) { + _id + name + total + } + }'; + case self::$UPDATE_TEAM_PREFERENCES: + return 'mutation updateTeamPrefs($teamId: String!, $prefs: Assoc!){ + teamsUpdatePrefs(teamId: $teamId, prefs: $prefs) { + data } }'; case self::$DELETE_TEAM: diff --git a/tests/e2e/Services/GraphQL/TeamsServerTest.php b/tests/e2e/Services/GraphQL/TeamsServerTest.php index 41577e6f42..9654501fe4 100644 --- a/tests/e2e/Services/GraphQL/TeamsServerTest.php +++ b/tests/e2e/Services/GraphQL/TeamsServerTest.php @@ -111,6 +111,62 @@ class TeamsServerTest extends Scope $this->assertArrayNotHasKey('errors', $team['body']); $team = $team['body']['data']['teamsGet']; $this->assertIsArray($team); + + return $team; + } + + /** + * @depends testGetTeam + */ + public function testUpdateTeamPrefs($team) + { + $projectId = $this->getProject()['$id']; + $query = $this->getQuery(self::$UPDATE_TEAM_PREFERENCES); + $graphQLPayload = [ + 'query' => $query, + 'variables' => [ + 'teamId' => $team['_id'], + 'prefs' => [ + 'key' => 'value' + ] + ], + ]; + + $prefs = $this->client->call(Client::METHOD_POST, '/graphql', \array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + ], $this->getHeaders()), $graphQLPayload); + + $this->assertIsArray($prefs['body']['data']); + $this->assertArrayNotHasKey('errors', $prefs['body']); + $this->assertIsArray($prefs['body']['data']['teamsUpdatePrefs']); + $this->assertEquals('{"key":"value"}', $prefs['body']['data']['teamsUpdatePrefs']['data']); + + return $team; + } + + /** + * @depends testUpdateTeamPrefs + */ + public function testGetTeamPreferences($team) + { + $projectId = $this->getProject()['$id']; + $query = $this->getQuery(self::$GET_TEAM_PREFERENCES); + $graphQLPayload = [ + 'query' => $query, + 'variables' => [ + 'teamId' => $team['_id'], + ] + ]; + + $prefs = $this->client->call(Client::METHOD_POST, '/graphql', \array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + ], $this->getHeaders()), $graphQLPayload); + + $this->assertIsArray($prefs['body']['data']); + $this->assertArrayNotHasKey('errors', $prefs['body']); + $this->assertIsArray($prefs['body']['data']['teamsGetPrefs']); } /** @@ -168,7 +224,7 @@ class TeamsServerTest extends Scope public function testUpdateTeam($team) { $projectId = $this->getProject()['$id']; - $query = $this->getQuery(self::$UPDATE_TEAM); + $query = $this->getQuery(self::$UPDATE_TEAM_NAME); $graphQLPayload = [ 'query' => $query, 'variables' => [ @@ -184,7 +240,7 @@ class TeamsServerTest extends Scope $this->assertIsArray($team['body']['data']); $this->assertArrayNotHasKey('errors', $team['body']); - $team = $team['body']['data']['teamsUpdate']; + $team = $team['body']['data']['teamsUpdateName']; $this->assertEquals('New Name', $team['name']); } diff --git a/tests/e2e/Services/Realtime/RealtimeCustomClientTest.php b/tests/e2e/Services/Realtime/RealtimeCustomClientTest.php index 5d077c48e3..858fec7d29 100644 --- a/tests/e2e/Services/Realtime/RealtimeCustomClientTest.php +++ b/tests/e2e/Services/Realtime/RealtimeCustomClientTest.php @@ -1459,6 +1459,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_PUT, '/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 e56d4f5562..04b5f93691 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_PUT, '/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_PUT, '/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 332b72fc54..2bc25e15b9 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_PUT, '/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 { /**